Patching: exclusions + correspondance prod<->hors-prod + validations
- /patching/config-exclusions: exclusions iTop par serveur + bulk + push iTop
- /quickwin/config: liste globale reboot packages (au lieu de per-server)
- /patching/correspondance: builder mark PROD/NON-PROD + bulk change env/app
+ auto-detect par nomenclature + exclut stock/obsolete
- /patching/validations: workflow post-patching (en_attente/OK/KO/force)
validator obligatoire depuis contacts iTop
- /patching/validations/history/{id}: historique par serveur
- Auto creation patch_validation apres status='patched' dans QuickWin
- check_prod_validations: banniere rouge sur quickwin detail si non-prod non valides
- Menu: Correspondance sous Serveurs, Config exclusions+Validations sous Patching
- Colonne Equivalent(s) sur /servers + section Correspondance sur detail
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ba0bff0f6e
commit
a706e240ca
679
app/routers/patching.py
Normal file
679
app/routers/patching.py
Normal file
@ -0,0 +1,679 @@
|
||||
"""Router Patching — exclusions, correspondance prod↔hors-prod, validations."""
|
||||
from fastapi import APIRouter, Request, Depends, Query, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import text
|
||||
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context
|
||||
from ..services import correspondance_service as corr
|
||||
from ..config import APP_NAME
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
def _can_edit_excludes(perms):
|
||||
"""Peut éditer les exclusions : admin, coordinator, operator (pas viewer)."""
|
||||
return can_edit(perms, "servers") or can_edit(perms, "campaigns") or can_edit(perms, "quickwin")
|
||||
|
||||
|
||||
@router.get("/patching/config-exclusions", response_class=HTMLResponse)
|
||||
async def config_exclusions_page(request: Request, db=Depends(get_db),
|
||||
search: str = Query(""),
|
||||
domain: str = Query(""),
|
||||
env: str = Query(""),
|
||||
zone: str = Query(""),
|
||||
tier: str = Query(""),
|
||||
os: str = Query(""),
|
||||
application: str = Query(""),
|
||||
has_excludes: str = Query(""),
|
||||
page: int = Query(1),
|
||||
per_page: int = Query(30)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return RedirectResponse(url="/dashboard")
|
||||
|
||||
# Requête principale
|
||||
where = ["1=1"]
|
||||
params = {}
|
||||
if search:
|
||||
where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%"
|
||||
if domain:
|
||||
where.append("d.code = :d"); params["d"] = domain
|
||||
if env:
|
||||
where.append("e.code = :e"); params["e"] = env
|
||||
if zone:
|
||||
where.append("z.name = :z"); params["z"] = zone
|
||||
if tier:
|
||||
where.append("s.tier = :t"); params["t"] = tier
|
||||
if os:
|
||||
where.append("s.os_family = :o"); params["o"] = os
|
||||
if application:
|
||||
where.append("s.application_name = :app"); params["app"] = application
|
||||
if has_excludes == "yes":
|
||||
where.append("s.patch_excludes IS NOT NULL AND s.patch_excludes != ''")
|
||||
elif has_excludes == "no":
|
||||
where.append("(s.patch_excludes IS NULL OR s.patch_excludes = '')")
|
||||
|
||||
wc = " AND ".join(where)
|
||||
|
||||
# Count
|
||||
total = db.execute(text(f"""
|
||||
SELECT COUNT(*) FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN zones z ON s.zone_id = z.id
|
||||
WHERE {wc}
|
||||
"""), params).scalar() or 0
|
||||
|
||||
per_page = max(10, min(per_page, 200))
|
||||
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||
page = max(1, min(page, total_pages))
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
rows = db.execute(text(f"""
|
||||
SELECT s.id, s.hostname, s.os_family, s.os_version, s.tier, s.etat,
|
||||
s.patch_excludes, s.application_name,
|
||||
d.name as domain_name, d.code as domain_code,
|
||||
e.name as env_name, e.code as env_code,
|
||||
z.name as zone_name
|
||||
FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN zones z ON s.zone_id = z.id
|
||||
WHERE {wc}
|
||||
ORDER BY s.hostname
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""), {**params, "limit": per_page, "offset": offset}).fetchall()
|
||||
|
||||
# Listes pour filtres
|
||||
domains = db.execute(text("SELECT code, name FROM domains ORDER BY name")).fetchall()
|
||||
envs = db.execute(text("SELECT code, name FROM environments ORDER BY name")).fetchall()
|
||||
zones = db.execute(text("SELECT DISTINCT name FROM zones ORDER BY name")).fetchall()
|
||||
applications = db.execute(text("""SELECT application_name, COUNT(*) as c FROM servers
|
||||
WHERE application_name IS NOT NULL AND application_name != ''
|
||||
GROUP BY application_name ORDER BY application_name""")).fetchall()
|
||||
all_apps = db.execute(text("""SELECT id, nom_court FROM applications
|
||||
WHERE itop_id IS NOT NULL ORDER BY nom_court""")).fetchall()
|
||||
|
||||
# Stats globales
|
||||
stats = {
|
||||
"total_servers": db.execute(text("SELECT COUNT(*) FROM servers")).scalar(),
|
||||
"with_excludes": db.execute(text("SELECT COUNT(*) FROM servers WHERE patch_excludes IS NOT NULL AND patch_excludes != ''")).scalar(),
|
||||
}
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({
|
||||
"app_name": APP_NAME,
|
||||
"servers": rows, "total": total,
|
||||
"page": page, "per_page": per_page, "total_pages": total_pages,
|
||||
"filters": {"search": search, "domain": domain, "env": env,
|
||||
"zone": zone, "tier": tier, "os": os,
|
||||
"application": application, "has_excludes": has_excludes},
|
||||
"domains": domains, "envs": envs, "zones": [z.name for z in zones],
|
||||
"applications": applications, "all_apps": all_apps,
|
||||
"stats": stats,
|
||||
"msg": request.query_params.get("msg"),
|
||||
})
|
||||
return templates.TemplateResponse("patching_config_exclusions.html", ctx)
|
||||
|
||||
|
||||
@router.post("/patching/config-exclusions/{server_id}/save")
|
||||
async def save_server_excludes(request: Request, server_id: int, db=Depends(get_db),
|
||||
patch_excludes: str = Form("")):
|
||||
"""Enregistre les exclusions d'un serveur + push iTop."""
|
||||
from fastapi.responses import JSONResponse
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
|
||||
# Normalise : split sur espaces/newlines, dédoublonne, rejoint avec un espace
|
||||
parts = [p.strip() for p in patch_excludes.replace("\n", " ").replace("\t", " ").split() if p.strip()]
|
||||
seen = set()
|
||||
cleaned = []
|
||||
for p in parts:
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
cleaned.append(p)
|
||||
new_val = " ".join(cleaned)
|
||||
srv = db.execute(text("SELECT id, hostname FROM servers WHERE id=:id"), {"id": server_id}).fetchone()
|
||||
if not srv:
|
||||
return JSONResponse({"ok": False, "msg": "Serveur introuvable"}, status_code=404)
|
||||
|
||||
# 1. Maj base locale
|
||||
db.execute(text("UPDATE servers SET patch_excludes=:pe, updated_at=NOW() WHERE id=:id"),
|
||||
{"pe": new_val, "id": server_id})
|
||||
db.commit()
|
||||
|
||||
# 2. Push iTop immédiat (best effort)
|
||||
itop_result = {"pushed": False, "msg": ""}
|
||||
try:
|
||||
from ..services.itop_service import ITopClient
|
||||
from ..services.secrets_service import get_secret
|
||||
url = get_secret(db, "itop_url")
|
||||
u = get_secret(db, "itop_user")
|
||||
p = get_secret(db, "itop_pass")
|
||||
if url and u and p:
|
||||
client = ITopClient(url, u, p)
|
||||
r = client._call("core/get", **{"class": "VirtualMachine",
|
||||
"key": f'SELECT VirtualMachine WHERE name = "{srv.hostname}"', "output_fields": "name"})
|
||||
if r.get("objects"):
|
||||
vm_id = list(r["objects"].values())[0]["key"]
|
||||
upd = client.update("VirtualMachine", vm_id, {"patch_excludes": new_val})
|
||||
if upd.get("code") == 0:
|
||||
itop_result = {"pushed": True, "msg": "Poussé vers iTop"}
|
||||
else:
|
||||
itop_result = {"pushed": False, "msg": f"iTop: {upd.get('message','')[:80]}"}
|
||||
except Exception as e:
|
||||
itop_result = {"pushed": False, "msg": f"iTop error: {str(e)[:80]}"}
|
||||
|
||||
return JSONResponse({"ok": True, "patch_excludes": new_val, "itop": itop_result})
|
||||
|
||||
|
||||
@router.post("/patching/config-exclusions/bulk")
|
||||
async def bulk_update_excludes(request: Request, db=Depends(get_db)):
|
||||
"""Bulk add/remove pattern sur plusieurs serveurs."""
|
||||
from fastapi.responses import JSONResponse
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
|
||||
body = await request.json()
|
||||
server_ids = body.get("server_ids", [])
|
||||
pattern = (body.get("pattern") or "").strip()
|
||||
action = body.get("action", "add") # "add" | "remove" | "replace"
|
||||
|
||||
if not server_ids or not pattern and action != "replace":
|
||||
return JSONResponse({"ok": False, "msg": "Paramètres manquants"})
|
||||
|
||||
ids = [int(x) for x in server_ids if str(x).isdigit()]
|
||||
if not ids:
|
||||
return JSONResponse({"ok": False, "msg": "Aucun serveur valide"})
|
||||
|
||||
placeholders = ",".join(str(i) for i in ids)
|
||||
rows = db.execute(text(f"SELECT id, hostname, patch_excludes FROM servers WHERE id IN ({placeholders})")).fetchall()
|
||||
|
||||
updated = 0
|
||||
for r in rows:
|
||||
current = (r.patch_excludes or "").strip()
|
||||
parts = current.split() if current else []
|
||||
if action == "add":
|
||||
if pattern not in parts:
|
||||
parts.append(pattern)
|
||||
elif action == "remove":
|
||||
parts = [p for p in parts if p != pattern]
|
||||
elif action == "replace":
|
||||
parts = pattern.split()
|
||||
new_val = " ".join(parts)
|
||||
if new_val != current:
|
||||
db.execute(text("UPDATE servers SET patch_excludes=:pe, updated_at=NOW() WHERE id=:id"),
|
||||
{"pe": new_val, "id": r.id})
|
||||
updated += 1
|
||||
db.commit()
|
||||
|
||||
# Push iTop en batch (best effort, async conceptually)
|
||||
itop_pushed = 0
|
||||
itop_errors = 0
|
||||
try:
|
||||
from ..services.itop_service import ITopClient
|
||||
from ..services.secrets_service import get_secret
|
||||
url = get_secret(db, "itop_url")
|
||||
u = get_secret(db, "itop_user")
|
||||
p = get_secret(db, "itop_pass")
|
||||
if url and u and p:
|
||||
client = ITopClient(url, u, p)
|
||||
# Refresh après update
|
||||
rows2 = db.execute(text(f"SELECT hostname, patch_excludes FROM servers WHERE id IN ({placeholders})")).fetchall()
|
||||
for r in rows2:
|
||||
resp = client._call("core/get", **{"class": "VirtualMachine",
|
||||
"key": f'SELECT VirtualMachine WHERE name = "{r.hostname}"', "output_fields": "name"})
|
||||
if resp.get("objects"):
|
||||
vm_id = list(resp["objects"].values())[0]["key"]
|
||||
up = client.update("VirtualMachine", vm_id, {"patch_excludes": r.patch_excludes or ""})
|
||||
if up.get("code") == 0:
|
||||
itop_pushed += 1
|
||||
else:
|
||||
itop_errors += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return JSONResponse({"ok": True, "updated": updated, "itop_pushed": itop_pushed, "itop_errors": itop_errors})
|
||||
|
||||
|
||||
@router.post("/patching/config-exclusions/bulk-application")
|
||||
async def bulk_update_application(request: Request, db=Depends(get_db)):
|
||||
"""Bulk changement de solution applicative sur plusieurs serveurs."""
|
||||
from fastapi.responses import JSONResponse
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False, "msg": "Non authentifié"}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
|
||||
body = await request.json()
|
||||
server_ids = body.get("server_ids", [])
|
||||
application_id = body.get("application_id") # int ou None/"" pour désassocier
|
||||
|
||||
ids = [int(x) for x in server_ids if str(x).isdigit()]
|
||||
if not ids:
|
||||
return JSONResponse({"ok": False, "msg": "Aucun serveur"})
|
||||
|
||||
app_id_val = None
|
||||
app_itop_id = None
|
||||
app_name = None
|
||||
if application_id and str(application_id).strip().isdigit():
|
||||
app_id_val = int(application_id)
|
||||
row = db.execute(text("SELECT itop_id, nom_court FROM applications WHERE id=:id"),
|
||||
{"id": app_id_val}).fetchone()
|
||||
if row:
|
||||
app_itop_id = row.itop_id
|
||||
app_name = row.nom_court
|
||||
else:
|
||||
return JSONResponse({"ok": False, "msg": "Application introuvable"})
|
||||
|
||||
placeholders = ",".join(str(i) for i in ids)
|
||||
db.execute(text(f"""UPDATE servers SET application_id=:aid, application_name=:an, updated_at=NOW()
|
||||
WHERE id IN ({placeholders})"""), {"aid": app_id_val, "an": app_name})
|
||||
db.commit()
|
||||
updated = len(ids)
|
||||
|
||||
# Push iTop
|
||||
itop_pushed = 0
|
||||
itop_errors = 0
|
||||
try:
|
||||
from ..services.itop_service import ITopClient
|
||||
from ..services.secrets_service import get_secret
|
||||
url = get_secret(db, "itop_url")
|
||||
u = get_secret(db, "itop_user")
|
||||
p = get_secret(db, "itop_pass")
|
||||
if url and u and p:
|
||||
client = ITopClient(url, u, p)
|
||||
new_list = [{"applicationsolution_id": int(app_itop_id)}] if app_itop_id else []
|
||||
hosts = db.execute(text(f"SELECT hostname FROM servers WHERE id IN ({placeholders})")).fetchall()
|
||||
for h in hosts:
|
||||
try:
|
||||
rr = client._call("core/get", **{"class": "VirtualMachine",
|
||||
"key": f'SELECT VirtualMachine WHERE name = "{h.hostname}"', "output_fields": "name"})
|
||||
if rr.get("objects"):
|
||||
vm_id = list(rr["objects"].values())[0]["key"]
|
||||
up = client.update("VirtualMachine", vm_id, {"applicationsolution_list": new_list})
|
||||
if up.get("code") == 0:
|
||||
itop_pushed += 1
|
||||
else:
|
||||
itop_errors += 1
|
||||
except Exception:
|
||||
itop_errors += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return JSONResponse({"ok": True, "updated": updated, "itop_pushed": itop_pushed, "itop_errors": itop_errors,
|
||||
"app_name": app_name or "(aucune)"})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# Correspondance prod ↔ hors-prod
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/patching/correspondance", response_class=HTMLResponse)
|
||||
async def correspondance_page(request: Request, db=Depends(get_db),
|
||||
search: str = Query(""), application: str = Query(""),
|
||||
domain: str = Query(""), env: str = Query("")):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms) and not can_view(perms, "campaigns"):
|
||||
return RedirectResponse(url="/dashboard")
|
||||
|
||||
servers = corr.get_servers_for_builder(db, search=search, app=application,
|
||||
domain=domain, env=env)
|
||||
|
||||
applications = db.execute(text("""SELECT DISTINCT application_name FROM servers
|
||||
WHERE application_name IS NOT NULL AND application_name != ''
|
||||
ORDER BY application_name""")).fetchall()
|
||||
envs = db.execute(text("SELECT DISTINCT name FROM environments ORDER BY name")).fetchall()
|
||||
domains = db.execute(text("SELECT DISTINCT name FROM domains ORDER BY name")).fetchall()
|
||||
all_apps = db.execute(text("""SELECT id, nom_court FROM applications
|
||||
WHERE itop_id IS NOT NULL ORDER BY nom_court""")).fetchall()
|
||||
|
||||
# Stats globales
|
||||
stats = {
|
||||
"total_links": db.execute(text("SELECT COUNT(*) FROM server_correspondance")).scalar() or 0,
|
||||
"filtered": len(servers),
|
||||
}
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({"app_name": APP_NAME, "servers": servers, "stats": stats,
|
||||
"applications": applications,
|
||||
"envs": [e.name for e in envs],
|
||||
"domains": [d.name for d in domains],
|
||||
"all_apps": all_apps,
|
||||
"search": search, "application": application,
|
||||
"domain": domain, "env": env,
|
||||
"can_edit": _can_edit_excludes(perms),
|
||||
"msg": request.query_params.get("msg", "")})
|
||||
return templates.TemplateResponse("patching_correspondance.html", ctx)
|
||||
|
||||
|
||||
@router.post("/patching/correspondance/bulk-env")
|
||||
async def correspondance_bulk_env(request: Request, db=Depends(get_db)):
|
||||
"""Change l'environnement réel de N serveurs (PatchCenter + push iTop)."""
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
body = await request.json()
|
||||
server_ids = [int(x) for x in body.get("server_ids", []) if str(x).isdigit()]
|
||||
env_name = (body.get("env_name") or "").strip()
|
||||
if not server_ids or not env_name:
|
||||
return JSONResponse({"ok": False, "msg": "Paramètres manquants"})
|
||||
|
||||
# Trouver env_id
|
||||
env_row = db.execute(text("SELECT id FROM environments WHERE name=:n"), {"n": env_name}).fetchone()
|
||||
if not env_row:
|
||||
return JSONResponse({"ok": False, "msg": f"Env '{env_name}' introuvable"})
|
||||
env_id = env_row.id
|
||||
|
||||
placeholders = ",".join(str(i) for i in server_ids)
|
||||
# Pour chaque serveur : trouver/créer le domain_env correspondant et l'affecter
|
||||
updated = 0
|
||||
srvs = db.execute(text(f"""SELECT s.id, s.hostname, s.domain_env_id, de.domain_id
|
||||
FROM servers s LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
WHERE s.id IN ({placeholders})""")).fetchall()
|
||||
|
||||
for s in srvs:
|
||||
if not s.domain_id:
|
||||
continue # pas de domaine actuel, skip
|
||||
# Trouver ou créer domain_environments(domain_id, env_id)
|
||||
de = db.execute(text("""SELECT id FROM domain_environments
|
||||
WHERE domain_id=:d AND environment_id=:e"""),
|
||||
{"d": s.domain_id, "e": env_id}).fetchone()
|
||||
if not de:
|
||||
db.execute(text("""INSERT INTO domain_environments (domain_id, environment_id)
|
||||
VALUES (:d, :e)"""), {"d": s.domain_id, "e": env_id})
|
||||
db.commit()
|
||||
de = db.execute(text("""SELECT id FROM domain_environments
|
||||
WHERE domain_id=:d AND environment_id=:e"""),
|
||||
{"d": s.domain_id, "e": env_id}).fetchone()
|
||||
db.execute(text("UPDATE servers SET domain_env_id=:de_id, updated_at=NOW() WHERE id=:sid"),
|
||||
{"de_id": de.id, "sid": s.id})
|
||||
updated += 1
|
||||
db.commit()
|
||||
|
||||
# Push iTop
|
||||
itop_pushed = 0
|
||||
itop_errors = 0
|
||||
try:
|
||||
from ..services.itop_service import ITopClient
|
||||
from ..services.secrets_service import get_secret
|
||||
url = get_secret(db, "itop_url")
|
||||
u = get_secret(db, "itop_user")
|
||||
p = get_secret(db, "itop_pass")
|
||||
if url and u and p:
|
||||
client = ITopClient(url, u, p)
|
||||
for s in srvs:
|
||||
try:
|
||||
rr = client._call("core/get", **{"class": "VirtualMachine",
|
||||
"key": f'SELECT VirtualMachine WHERE name = "{s.hostname}"', "output_fields": "name"})
|
||||
if rr.get("objects"):
|
||||
vm_id = list(rr["objects"].values())[0]["key"]
|
||||
upd = client.update("VirtualMachine", vm_id, {
|
||||
"environnement_id": f"SELECT Environnement WHERE name = '{env_name}'"
|
||||
})
|
||||
if upd.get("code") == 0:
|
||||
itop_pushed += 1
|
||||
else:
|
||||
itop_errors += 1
|
||||
except Exception:
|
||||
itop_errors += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return JSONResponse({"ok": True, "updated": updated,
|
||||
"itop_pushed": itop_pushed, "itop_errors": itop_errors,
|
||||
"env_name": env_name})
|
||||
|
||||
|
||||
@router.post("/patching/correspondance/bulk-application")
|
||||
async def correspondance_bulk_app(request: Request, db=Depends(get_db)):
|
||||
"""Change la solution applicative de N serveurs (PatchCenter + push iTop)."""
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
body = await request.json()
|
||||
server_ids = [int(x) for x in body.get("server_ids", []) if str(x).isdigit()]
|
||||
application_id = body.get("application_id")
|
||||
if not server_ids:
|
||||
return JSONResponse({"ok": False, "msg": "Aucun serveur"})
|
||||
|
||||
app_id_val = None
|
||||
app_itop_id = None
|
||||
app_name = None
|
||||
if application_id and str(application_id).strip().isdigit():
|
||||
app_id_val = int(application_id)
|
||||
row = db.execute(text("SELECT itop_id, nom_court FROM applications WHERE id=:id"),
|
||||
{"id": app_id_val}).fetchone()
|
||||
if row:
|
||||
app_itop_id = row.itop_id
|
||||
app_name = row.nom_court
|
||||
else:
|
||||
return JSONResponse({"ok": False, "msg": "Application introuvable"})
|
||||
|
||||
placeholders = ",".join(str(i) for i in server_ids)
|
||||
db.execute(text(f"""UPDATE servers SET application_id=:aid, application_name=:an, updated_at=NOW()
|
||||
WHERE id IN ({placeholders})"""), {"aid": app_id_val, "an": app_name})
|
||||
db.commit()
|
||||
|
||||
itop_pushed = 0
|
||||
itop_errors = 0
|
||||
try:
|
||||
from ..services.itop_service import ITopClient
|
||||
from ..services.secrets_service import get_secret
|
||||
url = get_secret(db, "itop_url")
|
||||
u = get_secret(db, "itop_user")
|
||||
p = get_secret(db, "itop_pass")
|
||||
if url and u and p:
|
||||
client = ITopClient(url, u, p)
|
||||
new_list = [{"applicationsolution_id": int(app_itop_id)}] if app_itop_id else []
|
||||
hosts = db.execute(text(f"SELECT hostname FROM servers WHERE id IN ({placeholders})")).fetchall()
|
||||
for h in hosts:
|
||||
try:
|
||||
rr = client._call("core/get", **{"class": "VirtualMachine",
|
||||
"key": f'SELECT VirtualMachine WHERE name = "{h.hostname}"', "output_fields": "name"})
|
||||
if rr.get("objects"):
|
||||
vm_id = list(rr["objects"].values())[0]["key"]
|
||||
up = client.update("VirtualMachine", vm_id, {"applicationsolution_list": new_list})
|
||||
if up.get("code") == 0:
|
||||
itop_pushed += 1
|
||||
else:
|
||||
itop_errors += 1
|
||||
except Exception:
|
||||
itop_errors += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return JSONResponse({"ok": True, "updated": len(server_ids),
|
||||
"itop_pushed": itop_pushed, "itop_errors": itop_errors,
|
||||
"app_name": app_name or "(aucune)"})
|
||||
|
||||
|
||||
@router.post("/patching/correspondance/bulk-create")
|
||||
async def correspondance_bulk_create(request: Request, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
body = await request.json()
|
||||
prod_ids = [int(x) for x in body.get("prod_ids", []) if str(x).isdigit()]
|
||||
nonprod_ids = [int(x) for x in body.get("nonprod_ids", []) if str(x).isdigit()]
|
||||
env_labels = body.get("env_labels", {})
|
||||
if not prod_ids or not nonprod_ids:
|
||||
return JSONResponse({"ok": False, "msg": "Au moins 1 prod et 1 non-prod requis"})
|
||||
r = corr.bulk_create_correspondance(db, prod_ids, nonprod_ids, env_labels, user.get("uid"))
|
||||
return JSONResponse({"ok": True, **r})
|
||||
|
||||
|
||||
@router.post("/patching/correspondance/auto-detect")
|
||||
async def correspondance_auto_detect(request: Request, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
stats = corr.detect_correspondances(db)
|
||||
return JSONResponse({"ok": True, **{k: v for k, v in stats.items() if k != "plan"}})
|
||||
|
||||
|
||||
@router.post("/patching/correspondance/link")
|
||||
async def correspondance_link(request: Request, db=Depends(get_db),
|
||||
prod_id: int = Form(...), nonprod_id: int = Form(...),
|
||||
env_code: str = Form(""), note: str = Form("")):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
corr.create_manual_link(db, prod_id, nonprod_id, env_code, note, user.get("uid"))
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/patching/correspondance/link-by-host")
|
||||
async def correspondance_link_by_host(request: Request, db=Depends(get_db),
|
||||
prod_id: int = Form(...),
|
||||
nonprod_hostname: str = Form(...),
|
||||
env_code: str = Form(""), note: str = Form("")):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
row = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname)=LOWER(:h)"),
|
||||
{"h": nonprod_hostname.strip()}).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"ok": False, "msg": f"Serveur '{nonprod_hostname}' introuvable"})
|
||||
corr.create_manual_link(db, prod_id, row.id, env_code, note, user.get("uid"))
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/patching/correspondance/{corr_id}/delete")
|
||||
async def correspondance_delete(request: Request, corr_id: int, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
corr.delete_link(db, corr_id)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════
|
||||
# Validations post-patching
|
||||
# ═══════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/patching/validations", response_class=HTMLResponse)
|
||||
async def validations_page(request: Request, db=Depends(get_db),
|
||||
status: str = Query("en_attente"),
|
||||
campaign_id: int = Query(None),
|
||||
env: str = Query("")):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return RedirectResponse(url="/dashboard")
|
||||
|
||||
validations = corr.get_pending_validations(db, env=env, campaign_id=campaign_id, status=status)
|
||||
|
||||
# Contacts validateurs (responsables + référents)
|
||||
contacts = db.execute(text("""SELECT id, name, email, role, team
|
||||
FROM contacts WHERE is_active=true
|
||||
AND role IN ('responsable_applicatif','responsable_domaine','referent_technique','ra_prod','ra_test')
|
||||
ORDER BY name""")).fetchall()
|
||||
|
||||
stats = {
|
||||
"en_attente": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='en_attente'")).scalar() or 0,
|
||||
"validated_ok": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='validated_ok'")).scalar() or 0,
|
||||
"validated_ko": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='validated_ko'")).scalar() or 0,
|
||||
"forced": db.execute(text("SELECT COUNT(*) FROM patch_validation WHERE status='forced'")).scalar() or 0,
|
||||
}
|
||||
|
||||
envs = db.execute(text("SELECT DISTINCT name FROM environments ORDER BY name")).fetchall()
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({"app_name": APP_NAME, "validations": validations, "contacts": contacts,
|
||||
"stats": stats, "status": status, "campaign_id": campaign_id, "env": env,
|
||||
"envs": [e.name for e in envs],
|
||||
"can_force": can_edit(perms, "campaigns") or user.get("role") == "admin",
|
||||
"msg": request.query_params.get("msg", "")})
|
||||
return templates.TemplateResponse("patching_validations.html", ctx)
|
||||
|
||||
|
||||
@router.post("/patching/validations/mark")
|
||||
async def validations_mark(request: Request, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"ok": False}, status_code=401)
|
||||
perms = get_user_perms(db, user)
|
||||
if not _can_edit_excludes(perms):
|
||||
return JSONResponse({"ok": False, "msg": "Permission refusée"}, status_code=403)
|
||||
|
||||
body = await request.json()
|
||||
ids = body.get("validation_ids", [])
|
||||
status = body.get("status", "validated_ok")
|
||||
contact_id = body.get("contact_id")
|
||||
forced_reason = body.get("forced_reason", "")
|
||||
notes = body.get("notes", "")
|
||||
|
||||
if status not in ("validated_ok", "validated_ko", "forced"):
|
||||
return JSONResponse({"ok": False, "msg": "Status invalide"})
|
||||
if status == "forced" and not forced_reason.strip():
|
||||
return JSONResponse({"ok": False, "msg": "Raison obligatoire pour forcer"})
|
||||
if status in ("validated_ok", "validated_ko") and not contact_id:
|
||||
return JSONResponse({"ok": False, "msg": "Validateur obligatoire"})
|
||||
|
||||
validator_name = None
|
||||
if contact_id:
|
||||
row = db.execute(text("SELECT name FROM contacts WHERE id=:id"),
|
||||
{"id": int(contact_id)}).fetchone()
|
||||
if row:
|
||||
validator_name = row.name
|
||||
|
||||
n = corr.mark_validation(db, ids, status, contact_id, validator_name,
|
||||
forced_reason, notes, user.get("uid"))
|
||||
return JSONResponse({"ok": True, "updated": n})
|
||||
|
||||
|
||||
@router.get("/patching/validations/history/{server_id}", response_class=HTMLResponse)
|
||||
async def validations_history(request: Request, server_id: int, db=Depends(get_db)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
srv = db.execute(text("SELECT id, hostname FROM servers WHERE id=:id"), {"id": server_id}).fetchone()
|
||||
if not srv:
|
||||
return HTMLResponse("Serveur introuvable", status_code=404)
|
||||
history = corr.get_validation_history(db, server_id)
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({"app_name": APP_NAME, "server": srv, "history": history})
|
||||
return templates.TemplateResponse("patching_validations_history.html", ctx)
|
||||
@ -10,7 +10,7 @@ from ..services.quickwin_service import (
|
||||
get_server_configs, upsert_server_config, delete_server_config,
|
||||
get_eligible_servers, list_runs, get_run, get_run_entries,
|
||||
create_run, delete_run, update_entry_field,
|
||||
can_start_prod, get_run_stats, inject_yum_history,
|
||||
can_start_prod, check_prod_validations, get_run_stats, inject_yum_history,
|
||||
advance_run_status, get_step_stats, mark_snapshot, mark_all_snapshots,
|
||||
build_yum_commands, get_available_servers, get_available_filters,
|
||||
add_entries_to_run, remove_entries_from_run,
|
||||
@ -55,53 +55,32 @@ async def quickwin_page(request: Request, db=Depends(get_db)):
|
||||
|
||||
# -- Config exclusions par serveur --
|
||||
|
||||
DEFAULT_REBOOT_PACKAGES = (
|
||||
"kernel* glibc* systemd* dbus* polkit* linux-firmware* microcode_ctl* "
|
||||
"tuned* dracut* grub2* kexec-tools* libselinux* selinux-policy* shim* "
|
||||
"mokutil* net-snmp* NetworkManager* network-scripts* nss* openssl-libs*"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/quickwin/config", response_class=HTMLResponse)
|
||||
async def quickwin_config_page(request: Request, db=Depends(get_db),
|
||||
page: int = Query(1),
|
||||
per_page: int = Query(14),
|
||||
search: str = Query(""),
|
||||
env: str = Query(""),
|
||||
domain: str = Query(""),
|
||||
zone: str = Query("")):
|
||||
async def quickwin_config_page(request: Request, db=Depends(get_db)):
|
||||
"""Page d'édition de la liste globale des packages qui nécessitent un reboot.
|
||||
Cette liste est utilisée par QuickWin (en plus des exclusions iTop par serveur)."""
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_view(perms, "campaigns") and not can_view(perms, "quickwin"):
|
||||
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
|
||||
return RedirectResponse(url="/dashboard")
|
||||
|
||||
configs = get_server_configs(db)
|
||||
|
||||
# Filtres
|
||||
filtered = configs
|
||||
if search:
|
||||
filtered = [s for s in filtered if search.lower() in s.hostname.lower()]
|
||||
if env:
|
||||
filtered = [s for s in filtered if s.environnement == env]
|
||||
if domain:
|
||||
filtered = [s for s in filtered if s.domaine == domain]
|
||||
if zone:
|
||||
filtered = [s for s in filtered if (s.zone or '') == zone]
|
||||
|
||||
# Pagination
|
||||
per_page = max(5, min(per_page, 100))
|
||||
total = len(filtered)
|
||||
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||
page = max(1, min(page, total_pages))
|
||||
start = (page - 1) * per_page
|
||||
page_servers = filtered[start:start + per_page]
|
||||
from ..services.secrets_service import get_secret
|
||||
current = get_secret(db, "patching_reboot_packages") or DEFAULT_REBOOT_PACKAGES
|
||||
|
||||
ctx = base_context(request, db, user)
|
||||
ctx.update({
|
||||
"app_name": APP_NAME,
|
||||
"all_servers": page_servers,
|
||||
"all_configs": configs,
|
||||
"default_excludes": DEFAULT_GENERAL_EXCLUDES,
|
||||
"total_count": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total_pages": total_pages,
|
||||
"filters": {"search": search, "env": env, "domain": domain, "zone": zone},
|
||||
"reboot_packages": current,
|
||||
"default_packages": DEFAULT_REBOOT_PACKAGES,
|
||||
"msg": request.query_params.get("msg"),
|
||||
})
|
||||
return templates.TemplateResponse("quickwin_config.html", ctx)
|
||||
@ -109,44 +88,20 @@ async def quickwin_config_page(request: Request, db=Depends(get_db),
|
||||
|
||||
@router.post("/quickwin/config/save")
|
||||
async def quickwin_config_save(request: Request, db=Depends(get_db),
|
||||
server_id: int = Form(0),
|
||||
general_excludes: str = Form(""),
|
||||
specific_excludes: str = Form(""),
|
||||
notes: str = Form("")):
|
||||
reboot_packages: str = Form("")):
|
||||
"""Sauvegarde la liste globale des packages nécessitant un reboot."""
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
if server_id:
|
||||
upsert_server_config(db, server_id, general_excludes.strip(),
|
||||
specific_excludes.strip(), notes.strip())
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
|
||||
return RedirectResponse(url="/dashboard")
|
||||
from ..services.secrets_service import set_secret
|
||||
set_secret(db, "patching_reboot_packages", reboot_packages.strip(),
|
||||
"Packages nécessitant un reboot (QuickWin)")
|
||||
return RedirectResponse(url="/quickwin/config?msg=saved", status_code=303)
|
||||
|
||||
|
||||
@router.post("/quickwin/config/delete")
|
||||
async def quickwin_config_delete(request: Request, db=Depends(get_db),
|
||||
config_id: int = Form(0)):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
if config_id:
|
||||
delete_server_config(db, config_id)
|
||||
return RedirectResponse(url="/quickwin/config?msg=deleted", status_code=303)
|
||||
|
||||
|
||||
@router.post("/quickwin/config/bulk-add")
|
||||
async def quickwin_config_bulk_add(request: Request, db=Depends(get_db),
|
||||
server_ids: str = Form(""),
|
||||
general_excludes: str = Form("")):
|
||||
"""Ajouter plusieurs serveurs d'un coup avec les memes exclusions generales"""
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
|
||||
for sid in ids:
|
||||
upsert_server_config(db, sid, general_excludes.strip(), "", "")
|
||||
return RedirectResponse(url=f"/quickwin/config?msg=added_{len(ids)}", status_code=303)
|
||||
|
||||
|
||||
# -- Runs QuickWin --
|
||||
|
||||
@router.post("/quickwin/create")
|
||||
@ -168,7 +123,7 @@ async def quickwin_create(request: Request, db=Depends(get_db),
|
||||
|
||||
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
|
||||
if not ids:
|
||||
# Prendre tous les serveurs eligibles (linux, en_production, secops)
|
||||
# Prendre tous les serveurs eligibles (linux, production, secops)
|
||||
eligible = get_eligible_servers(db)
|
||||
ids = [s.id for s in eligible]
|
||||
|
||||
@ -189,6 +144,9 @@ async def quickwin_correspondance_redirect(request: Request, db=Depends(get_db))
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login")
|
||||
perms = get_user_perms(db, user)
|
||||
if not can_edit(perms, "campaigns") and not can_edit(perms, "quickwin"):
|
||||
return RedirectResponse(url="/dashboard")
|
||||
runs = list_runs(db)
|
||||
if not runs:
|
||||
return RedirectResponse(url="/quickwin?msg=no_run")
|
||||
@ -221,6 +179,7 @@ async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db),
|
||||
entries = get_run_entries(db, run_id)
|
||||
stats = get_run_stats(db, run_id)
|
||||
prod_ok = can_start_prod(db, run_id)
|
||||
validations_ok, validations_blockers = check_prod_validations(db, run_id)
|
||||
step_stats_hp = get_step_stats(db, run_id, "hprod")
|
||||
step_stats_pr = get_step_stats(db, run_id, "prod")
|
||||
|
||||
@ -279,6 +238,7 @@ async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db),
|
||||
"p_page": p_page, "p_total_pages": p_total_pages,
|
||||
"per_page": per_page,
|
||||
"prod_ok": prod_ok,
|
||||
"validations_ok": validations_ok, "validations_blockers": validations_blockers,
|
||||
"step_hp": step_stats_hp, "step_pr": step_stats_pr,
|
||||
"scope": scope,
|
||||
"filters": {"search": search, "status": status, "domain": domain,
|
||||
|
||||
442
app/services/correspondance_service.py
Normal file
442
app/services/correspondance_service.py
Normal file
@ -0,0 +1,442 @@
|
||||
"""Service de correspondance prod ↔ hors-prod + validations post-patching.
|
||||
|
||||
Détection automatique par signature de hostname :
|
||||
- 2ème caractère = environnement SANEF (p=prod, r=recette, t=test, i=preprod, v=validation, d=dev, o=preprod, s=prod)
|
||||
- Signature = hostname avec le 2ème char remplacé par "_"
|
||||
- Tous les hostnames avec la même signature sont candidats correspondants.
|
||||
|
||||
Exceptions (ls-*, sp-*, etc.) : ne sont pas traitées automatiquement.
|
||||
"""
|
||||
import logging
|
||||
from sqlalchemy import text
|
||||
from collections import defaultdict
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Lettres prod (un prod pour une signature)
|
||||
PROD_CHARS = {"p", "s"} # p=Production, s=Production secours (à valider)
|
||||
|
||||
# Lettres hors-prod avec label
|
||||
NONPROD_CHARS = {
|
||||
"r": "Recette", "t": "Test", "i": "Pre-production",
|
||||
"v": "Validation", "d": "Developpement", "o": "Pre-production",
|
||||
}
|
||||
|
||||
# Préfixes qui ne suivent PAS la nomenclature
|
||||
EXCEPTION_PREFIXES = ("ls-", "sp")
|
||||
|
||||
|
||||
def _signature(hostname):
|
||||
"""Retourne (signature, env_char) ou (None, None) si non analysable."""
|
||||
hn = (hostname or "").lower().strip()
|
||||
if not hn or len(hn) < 3:
|
||||
return None, None
|
||||
# Préfixes d'exception
|
||||
for pref in EXCEPTION_PREFIXES:
|
||||
if hn.startswith(pref):
|
||||
return None, None
|
||||
# Format standard : X{env_char}YYYYYY
|
||||
env_char = hn[1]
|
||||
if env_char not in PROD_CHARS and env_char not in NONPROD_CHARS:
|
||||
return None, None
|
||||
signature = hn[0] + "_" + hn[2:]
|
||||
return signature, env_char
|
||||
|
||||
|
||||
def detect_correspondances(db, dry_run=False):
|
||||
"""Parcourt tous les serveurs, groupe par signature, crée les liens auto.
|
||||
Ne touche pas aux liens 'manual' ou 'exception' existants.
|
||||
Retourne un dict de stats."""
|
||||
stats = {"signatures": 0, "prod_found": 0, "nonprod_found": 0,
|
||||
"links_created": 0, "links_kept_manual": 0, "orphan_nonprod": 0,
|
||||
"ambiguous": 0, "exceptions": 0}
|
||||
|
||||
# Tous les serveurs actifs (exclut stock/obsolete)
|
||||
rows = db.execute(text("""SELECT id, hostname FROM servers
|
||||
WHERE etat NOT IN ('stock','obsolete') ORDER BY hostname""")).fetchall()
|
||||
|
||||
by_signature = defaultdict(list) # signature -> [(server_id, env_char, hostname)]
|
||||
for r in rows:
|
||||
sig, env = _signature(r.hostname)
|
||||
if sig is None:
|
||||
stats["exceptions"] += 1
|
||||
continue
|
||||
by_signature[sig].append((r.id, env, r.hostname))
|
||||
|
||||
stats["signatures"] = len(by_signature)
|
||||
|
||||
if dry_run:
|
||||
# Préparer plan
|
||||
plan = []
|
||||
for sig, members in by_signature.items():
|
||||
prods = [m for m in members if m[1] in PROD_CHARS]
|
||||
nps = [m for m in members if m[1] in NONPROD_CHARS]
|
||||
if len(prods) == 1 and nps:
|
||||
plan.append({"signature": sig, "prod": prods[0][2],
|
||||
"nonprods": [(n[2], NONPROD_CHARS[n[1]]) for n in nps]})
|
||||
elif len(prods) > 1 and nps:
|
||||
stats["ambiguous"] += 1
|
||||
elif not prods and nps:
|
||||
stats["orphan_nonprod"] += len(nps)
|
||||
stats["plan"] = plan[:50]
|
||||
return stats
|
||||
|
||||
for sig, members in by_signature.items():
|
||||
prods = [m for m in members if m[1] in PROD_CHARS]
|
||||
nonprods = [m for m in members if m[1] in NONPROD_CHARS]
|
||||
stats["prod_found"] += len(prods)
|
||||
stats["nonprod_found"] += len(nonprods)
|
||||
|
||||
if not prods and nonprods:
|
||||
stats["orphan_nonprod"] += len(nonprods)
|
||||
continue
|
||||
|
||||
if len(prods) > 1:
|
||||
stats["ambiguous"] += 1
|
||||
# On n'auto-détecte pas quand plusieurs prods (ambigu)
|
||||
continue
|
||||
|
||||
if len(prods) == 1 and nonprods:
|
||||
prod_id = prods[0][0]
|
||||
for np_id, np_env, np_host in nonprods:
|
||||
env_label = NONPROD_CHARS.get(np_env, "Autre")
|
||||
# Insert si pas déjà présent + pas 'manual' ou 'exception'
|
||||
existing = db.execute(text("""SELECT id, source FROM server_correspondance
|
||||
WHERE prod_server_id=:p AND nonprod_server_id=:n"""),
|
||||
{"p": prod_id, "n": np_id}).fetchone()
|
||||
if existing:
|
||||
if existing.source in ("manual", "exception"):
|
||||
stats["links_kept_manual"] += 1
|
||||
# sinon déjà auto, on skip
|
||||
continue
|
||||
try:
|
||||
db.execute(text("""INSERT INTO server_correspondance
|
||||
(prod_server_id, nonprod_server_id, environment_code, source)
|
||||
VALUES (:p, :n, :env, 'auto')"""),
|
||||
{"p": prod_id, "n": np_id, "env": env_label})
|
||||
stats["links_created"] += 1
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
|
||||
db.commit()
|
||||
return stats
|
||||
|
||||
|
||||
def get_servers_for_builder(db, search="", app="", domain="", env=""):
|
||||
"""Retourne tous les serveurs matchant les filtres, avec leurs correspondances existantes.
|
||||
Exclut les serveurs en stock / obsolete (décommissionnés, EOL)."""
|
||||
where = ["s.etat NOT IN ('stock','obsolete')"]
|
||||
params = {}
|
||||
if search:
|
||||
where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%"
|
||||
if app:
|
||||
where.append("s.application_name = :app"); params["app"] = app
|
||||
if domain:
|
||||
where.append("d.name = :dom"); params["dom"] = domain
|
||||
if env:
|
||||
where.append("e.name = :env"); params["env"] = env
|
||||
wc = " AND ".join(where)
|
||||
|
||||
return db.execute(text(f"""
|
||||
SELECT s.id, s.hostname, s.application_name,
|
||||
e.name as env_name, d.name as domain_name, z.name as zone_name,
|
||||
(SELECT COUNT(*) FROM server_correspondance sc WHERE sc.prod_server_id = s.id) as n_as_prod,
|
||||
(SELECT COUNT(*) FROM server_correspondance sc WHERE sc.nonprod_server_id = s.id) as n_as_nonprod
|
||||
FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
LEFT JOIN zones z ON s.zone_id = z.id
|
||||
WHERE {wc}
|
||||
ORDER BY e.name, s.hostname
|
||||
LIMIT 500
|
||||
"""), params).fetchall()
|
||||
|
||||
|
||||
def bulk_create_correspondance(db, prod_ids, nonprod_ids, env_labels, user_id):
|
||||
"""Crée toutes les correspondances prod × non-prod.
|
||||
env_labels est un dict {nonprod_id: env_label} optionnel."""
|
||||
created = 0
|
||||
skipped = 0
|
||||
for pid in prod_ids:
|
||||
for npid in nonprod_ids:
|
||||
if pid == npid:
|
||||
continue
|
||||
existing = db.execute(text("""SELECT id FROM server_correspondance
|
||||
WHERE prod_server_id=:p AND nonprod_server_id=:n"""),
|
||||
{"p": pid, "n": npid}).fetchone()
|
||||
if existing:
|
||||
skipped += 1
|
||||
continue
|
||||
env = (env_labels or {}).get(str(npid)) or (env_labels or {}).get(npid) or ""
|
||||
db.execute(text("""INSERT INTO server_correspondance
|
||||
(prod_server_id, nonprod_server_id, environment_code, source, created_by)
|
||||
VALUES (:p, :n, :env, 'manual', :uid)"""),
|
||||
{"p": pid, "n": npid, "env": env, "uid": user_id})
|
||||
created += 1
|
||||
db.commit()
|
||||
return {"created": created, "skipped": skipped}
|
||||
|
||||
|
||||
def get_correspondance_view(db, search="", app="", env=""):
|
||||
"""Vue hiérarchique des correspondances groupées par application.
|
||||
Exclut les serveurs en stock/obsolete."""
|
||||
where = ["s.etat NOT IN ('stock','obsolete')"]
|
||||
params = {}
|
||||
if search:
|
||||
where.append("s.hostname ILIKE :s"); params["s"] = f"%{search}%"
|
||||
if app:
|
||||
where.append("s.application_name = :app"); params["app"] = app
|
||||
if env:
|
||||
where.append("e.name = :env"); params["env"] = env
|
||||
else:
|
||||
# Par défaut : tout ce qui ressemble à prod (Production ou code prod)
|
||||
where.append("(e.name ILIKE '%production%' OR e.code ILIKE '%prod%')")
|
||||
|
||||
wc = " AND ".join(where)
|
||||
|
||||
prods = db.execute(text(f"""
|
||||
SELECT s.id, s.hostname, s.application_name, e.name as env_name,
|
||||
d.name as domain_name
|
||||
FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
WHERE {wc}
|
||||
ORDER BY s.application_name, s.hostname
|
||||
"""), params).fetchall()
|
||||
|
||||
results = []
|
||||
for p in prods:
|
||||
corrs = db.execute(text("""SELECT sc.id as corr_id, sc.environment_code, sc.source, sc.note,
|
||||
ns.id as np_id, ns.hostname as np_hostname,
|
||||
(SELECT pv.status FROM patch_validation pv WHERE pv.server_id = ns.id
|
||||
ORDER BY pv.patch_date DESC LIMIT 1) as last_validation_status,
|
||||
(SELECT pv.validated_at FROM patch_validation pv WHERE pv.server_id = ns.id
|
||||
ORDER BY pv.patch_date DESC LIMIT 1) as last_validated_at,
|
||||
(SELECT pv.patch_date FROM patch_validation pv WHERE pv.server_id = ns.id
|
||||
ORDER BY pv.patch_date DESC LIMIT 1) as last_patch_date
|
||||
FROM server_correspondance sc
|
||||
JOIN servers ns ON sc.nonprod_server_id = ns.id
|
||||
WHERE sc.prod_server_id = :pid
|
||||
ORDER BY sc.environment_code, ns.hostname"""), {"pid": p.id}).fetchall()
|
||||
|
||||
# Validation status agrégé du prod
|
||||
# Compter statuts des hors-prod liés
|
||||
n_total = len(corrs)
|
||||
n_ok = sum(1 for c in corrs if c.last_validation_status in ("validated_ok", "forced"))
|
||||
n_pending = sum(1 for c in corrs if c.last_validation_status == "en_attente")
|
||||
n_ko = sum(1 for c in corrs if c.last_validation_status == "validated_ko")
|
||||
|
||||
if n_total == 0:
|
||||
global_status = "no_nonprod" # gris
|
||||
elif n_ko > 0:
|
||||
global_status = "ko"
|
||||
elif n_pending > 0:
|
||||
global_status = "pending"
|
||||
elif n_ok == n_total:
|
||||
global_status = "all_ok"
|
||||
else:
|
||||
global_status = "partial"
|
||||
|
||||
results.append({
|
||||
"prod_id": p.id, "prod_hostname": p.hostname,
|
||||
"application": p.application_name, "domain": p.domain_name,
|
||||
"env": p.env_name,
|
||||
"correspondants": [dict(c._mapping) for c in corrs],
|
||||
"n_total": n_total, "n_ok": n_ok, "n_pending": n_pending, "n_ko": n_ko,
|
||||
"global_status": global_status,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_server_links(db, server_id):
|
||||
"""Pour un serveur donné, retourne ses liens :
|
||||
- as_prod : liste des hors-prod qui lui sont liés (si ce serveur est prod)
|
||||
- as_nonprod : liste des prod auxquels il est lié (si ce serveur est non-prod)
|
||||
Chaque item : {hostname, env_name, environment_code, source, corr_id}
|
||||
"""
|
||||
as_prod = db.execute(text("""SELECT sc.id as corr_id, sc.environment_code, sc.source,
|
||||
ns.id, ns.hostname, e.name as env_name
|
||||
FROM server_correspondance sc
|
||||
JOIN servers ns ON sc.nonprod_server_id = ns.id
|
||||
LEFT JOIN domain_environments de ON ns.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
WHERE sc.prod_server_id = :id ORDER BY e.name, ns.hostname"""),
|
||||
{"id": server_id}).fetchall()
|
||||
|
||||
as_nonprod = db.execute(text("""SELECT sc.id as corr_id, sc.environment_code, sc.source,
|
||||
ps.id, ps.hostname, e.name as env_name
|
||||
FROM server_correspondance sc
|
||||
JOIN servers ps ON sc.prod_server_id = ps.id
|
||||
LEFT JOIN domain_environments de ON ps.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
WHERE sc.nonprod_server_id = :id ORDER BY ps.hostname"""),
|
||||
{"id": server_id}).fetchall()
|
||||
|
||||
return {
|
||||
"as_prod": [dict(r._mapping) for r in as_prod],
|
||||
"as_nonprod": [dict(r._mapping) for r in as_nonprod],
|
||||
}
|
||||
|
||||
|
||||
def get_links_bulk(db, server_ids):
|
||||
"""Pour une liste d'IDs, retourne un dict {server_id: {as_prod: [...], as_nonprod: [...]}}.
|
||||
Optimisé pour affichage en liste (/servers)."""
|
||||
if not server_ids:
|
||||
return {}
|
||||
placeholders = ",".join(str(i) for i in server_ids if str(i).isdigit())
|
||||
if not placeholders:
|
||||
return {}
|
||||
result = {sid: {"as_prod": [], "as_nonprod": []} for sid in server_ids}
|
||||
|
||||
# Prod → non-prods
|
||||
rows = db.execute(text(f"""SELECT sc.prod_server_id as sid, sc.environment_code,
|
||||
ns.hostname, e.name as env_name
|
||||
FROM server_correspondance sc
|
||||
JOIN servers ns ON sc.nonprod_server_id = ns.id
|
||||
LEFT JOIN domain_environments de ON ns.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
WHERE sc.prod_server_id IN ({placeholders})
|
||||
ORDER BY ns.hostname""")).fetchall()
|
||||
for r in rows:
|
||||
if r.sid in result:
|
||||
result[r.sid]["as_prod"].append({"hostname": r.hostname,
|
||||
"env_name": r.env_name, "environment_code": r.environment_code})
|
||||
|
||||
# Non-prod → prods
|
||||
rows = db.execute(text(f"""SELECT sc.nonprod_server_id as sid,
|
||||
ps.hostname, e.name as env_name
|
||||
FROM server_correspondance sc
|
||||
JOIN servers ps ON sc.prod_server_id = ps.id
|
||||
LEFT JOIN domain_environments de ON ps.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
WHERE sc.nonprod_server_id IN ({placeholders})
|
||||
ORDER BY ps.hostname""")).fetchall()
|
||||
for r in rows:
|
||||
if r.sid in result:
|
||||
result[r.sid]["as_nonprod"].append({"hostname": r.hostname, "env_name": r.env_name})
|
||||
return result
|
||||
|
||||
|
||||
def get_orphan_nonprod(db):
|
||||
"""Retourne les hors-prod sans prod associée (exclut stock/obsolete)."""
|
||||
rows = db.execute(text("""
|
||||
SELECT s.id, s.hostname, s.application_name, e.name as env_name,
|
||||
d.name as domain_name
|
||||
FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
WHERE e.name IS NOT NULL AND e.name NOT ILIKE '%production%'
|
||||
AND s.etat NOT IN ('stock','obsolete')
|
||||
AND NOT EXISTS (SELECT 1 FROM server_correspondance sc WHERE sc.nonprod_server_id = s.id)
|
||||
ORDER BY s.application_name, s.hostname
|
||||
LIMIT 500
|
||||
""")).fetchall()
|
||||
return rows
|
||||
|
||||
|
||||
def create_manual_link(db, prod_id, nonprod_id, env_code, note, user_id):
|
||||
"""Crée un lien manuel."""
|
||||
existing = db.execute(text("""SELECT id FROM server_correspondance
|
||||
WHERE prod_server_id=:p AND nonprod_server_id=:n"""),
|
||||
{"p": prod_id, "n": nonprod_id}).fetchone()
|
||||
if existing:
|
||||
db.execute(text("""UPDATE server_correspondance SET source='manual',
|
||||
environment_code=:env, note=:note, updated_at=NOW() WHERE id=:id"""),
|
||||
{"env": env_code, "note": note, "id": existing.id})
|
||||
else:
|
||||
db.execute(text("""INSERT INTO server_correspondance (prod_server_id,
|
||||
nonprod_server_id, environment_code, source, note, created_by)
|
||||
VALUES (:p, :n, :env, 'manual', :note, :uid)"""),
|
||||
{"p": prod_id, "n": nonprod_id, "env": env_code, "note": note, "uid": user_id})
|
||||
db.commit()
|
||||
|
||||
|
||||
def delete_link(db, corr_id):
|
||||
db.execute(text("DELETE FROM server_correspondance WHERE id=:id"), {"id": corr_id})
|
||||
db.commit()
|
||||
|
||||
|
||||
# ─── Patch validation ───
|
||||
|
||||
def create_validation_entry(db, server_id, campaign_id=None, campaign_type="manual"):
|
||||
"""Crée une entrée 'en_attente' après patching."""
|
||||
db.execute(text("""INSERT INTO patch_validation (server_id, campaign_id, campaign_type,
|
||||
patch_date, status) VALUES (:sid, :cid, :ct, NOW(), 'en_attente')"""),
|
||||
{"sid": server_id, "cid": campaign_id, "ct": campaign_type})
|
||||
db.commit()
|
||||
|
||||
|
||||
def mark_validation(db, validation_ids, status, validator_contact_id, validator_name,
|
||||
forced_reason, notes, user_id):
|
||||
"""Marque N validations. status dans (validated_ok, validated_ko, forced)."""
|
||||
placeholders = ",".join(str(i) for i in validation_ids if str(i).isdigit())
|
||||
if not placeholders:
|
||||
return 0
|
||||
db.execute(text(f"""UPDATE patch_validation SET
|
||||
status=:s, validated_by_contact_id=:cid, validated_by_name=:n,
|
||||
validated_at=NOW(), marked_by_user_id=:uid,
|
||||
forced_reason=:fr, notes=:nt, updated_at=NOW()
|
||||
WHERE id IN ({placeholders})"""),
|
||||
{"s": status, "cid": validator_contact_id, "n": validator_name,
|
||||
"uid": user_id, "fr": forced_reason, "nt": notes})
|
||||
db.commit()
|
||||
return len(placeholders.split(","))
|
||||
|
||||
|
||||
def get_pending_validations(db, env="", campaign_id=None, status="en_attente", limit=500):
|
||||
"""Liste les validations filtrées."""
|
||||
where = ["1=1"]
|
||||
params = {}
|
||||
if status:
|
||||
where.append("pv.status = :st"); params["st"] = status
|
||||
if campaign_id:
|
||||
where.append("pv.campaign_id = :cid"); params["cid"] = campaign_id
|
||||
if env:
|
||||
where.append("e.name = :env"); params["env"] = env
|
||||
wc = " AND ".join(where)
|
||||
return db.execute(text(f"""
|
||||
SELECT pv.id, pv.server_id, s.hostname, s.application_name,
|
||||
e.name as env_name, d.name as domain_name,
|
||||
pv.campaign_id, pv.campaign_type, pv.patch_date, pv.status,
|
||||
pv.validated_by_name, pv.validated_at,
|
||||
pv.forced_reason, pv.notes,
|
||||
EXTRACT(day FROM NOW() - pv.patch_date) as days_pending
|
||||
FROM patch_validation pv
|
||||
JOIN servers s ON pv.server_id = s.id
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN domains d ON de.domain_id = d.id
|
||||
WHERE {wc}
|
||||
ORDER BY pv.patch_date DESC
|
||||
LIMIT {int(limit)}
|
||||
"""), params).fetchall()
|
||||
|
||||
|
||||
def get_validation_history(db, server_id):
|
||||
return db.execute(text("""
|
||||
SELECT pv.id, pv.campaign_id, pv.campaign_type, pv.patch_date, pv.status,
|
||||
pv.validated_by_name, pv.validated_at, pv.forced_reason, pv.notes,
|
||||
u.display_name as marked_by
|
||||
FROM patch_validation pv
|
||||
LEFT JOIN users u ON pv.marked_by_user_id = u.id
|
||||
WHERE pv.server_id = :sid
|
||||
ORDER BY pv.patch_date DESC
|
||||
"""), {"sid": server_id}).fetchall()
|
||||
|
||||
|
||||
def can_patch_prod(db, prod_server_id):
|
||||
"""Retourne (bool, list_of_pending_hostnames) : peut-on patcher le prod ?
|
||||
OK si tous les hors-prod liés ont validated_ok ou forced sur leur dernier patching."""
|
||||
corrs = db.execute(text("""SELECT ns.id, ns.hostname,
|
||||
(SELECT pv.status FROM patch_validation pv WHERE pv.server_id = ns.id
|
||||
ORDER BY pv.patch_date DESC LIMIT 1) as last_status
|
||||
FROM server_correspondance sc JOIN servers ns ON sc.nonprod_server_id = ns.id
|
||||
WHERE sc.prod_server_id = :pid"""), {"pid": prod_server_id}).fetchall()
|
||||
if not corrs:
|
||||
return True, [] # pas de hors-prod = OK (ou selon règle, à ajuster)
|
||||
blockers = [c.hostname for c in corrs if c.last_status not in ("validated_ok", "forced")]
|
||||
return (len(blockers) == 0), blockers
|
||||
@ -72,7 +72,7 @@ def delete_server_config(db, config_id):
|
||||
|
||||
|
||||
def get_eligible_servers(db):
|
||||
"""Serveurs Linux en_production, patch_os_owner=secops"""
|
||||
"""Serveurs Linux production, patch_os_owner=secops"""
|
||||
return db.execute(text("""
|
||||
SELECT s.id, s.hostname, s.os_family, s.os_version, s.machine_type,
|
||||
s.tier, s.etat, s.patch_excludes, s.is_flux_libre, s.is_podman,
|
||||
@ -86,7 +86,7 @@ def get_eligible_servers(db):
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id
|
||||
WHERE s.os_family = 'linux'
|
||||
AND s.etat = 'en_production'
|
||||
AND s.etat = 'production'
|
||||
AND s.patch_os_owner = 'secops'
|
||||
ORDER BY e.display_order, d.display_order, s.hostname
|
||||
""")).fetchall()
|
||||
@ -140,25 +140,26 @@ def create_run(db, year, week_number, label, user_id, server_ids, notes=""):
|
||||
"""), {"y": year, "w": week_number, "l": label, "uid": user_id, "n": notes}).fetchone()
|
||||
run_id = row.id
|
||||
|
||||
# Lire les reboot packages globaux (source: app_secrets)
|
||||
from .secrets_service import get_secret
|
||||
reboot_pkgs = get_secret(db, "patching_reboot_packages") or DEFAULT_GENERAL_EXCLUDES
|
||||
|
||||
for sid in server_ids:
|
||||
srv = db.execute(text("""
|
||||
SELECT s.id, e.name as env_name,
|
||||
COALESCE(qc.general_excludes, '') as ge,
|
||||
COALESCE(qc.specific_excludes, '') as se
|
||||
SELECT s.id, e.name as env_name, COALESCE(s.patch_excludes, '') as pe
|
||||
FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id
|
||||
WHERE s.id = :sid
|
||||
"""), {"sid": sid}).fetchone()
|
||||
if not srv:
|
||||
continue
|
||||
branch = "prod" if srv.env_name and "production" in srv.env_name.lower() else "hprod"
|
||||
ge = srv.ge if srv.ge else DEFAULT_GENERAL_EXCLUDES
|
||||
# QuickWin : reboot globaux + exclusions iTop du serveur
|
||||
db.execute(text("""
|
||||
INSERT INTO quickwin_entries (run_id, server_id, branch, general_excludes, specific_excludes)
|
||||
VALUES (:rid, :sid, :br, :ge, :se)
|
||||
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": ge, "se": srv.se})
|
||||
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": reboot_pkgs, "se": srv.pe})
|
||||
|
||||
db.commit()
|
||||
return run_id
|
||||
@ -183,7 +184,7 @@ def get_available_servers(db, run_id, search="", domains=None, envs=None, zones=
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN zones z ON s.zone_id = z.id
|
||||
WHERE s.os_family = 'linux'
|
||||
AND s.etat = 'en_production'
|
||||
AND s.etat = 'production'
|
||||
AND s.patch_os_owner = 'secops'
|
||||
AND s.id NOT IN (SELECT server_id FROM quickwin_entries WHERE run_id = :rid)
|
||||
ORDER BY d.name, e.name, s.hostname
|
||||
@ -210,7 +211,7 @@ def get_available_filters(db, run_id):
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN zones z ON s.zone_id = z.id
|
||||
WHERE s.os_family = 'linux'
|
||||
AND s.etat = 'en_production'
|
||||
AND s.etat = 'production'
|
||||
AND s.patch_os_owner = 'secops'
|
||||
AND s.id NOT IN (SELECT server_id FROM quickwin_entries WHERE run_id = :rid)
|
||||
"""), {"rid": run_id}).fetchall()
|
||||
@ -241,29 +242,28 @@ def add_entries_to_run(db, run_id, server_ids, user=None):
|
||||
), {"rid": run_id}).fetchall())
|
||||
|
||||
by = user.get("display_name", user.get("username", "")) if user else ""
|
||||
from .secrets_service import get_secret
|
||||
reboot_pkgs = get_secret(db, "patching_reboot_packages") or DEFAULT_GENERAL_EXCLUDES
|
||||
|
||||
added = 0
|
||||
hostnames = []
|
||||
for sid in server_ids:
|
||||
if sid in existing:
|
||||
continue
|
||||
srv = db.execute(text("""
|
||||
SELECT s.id, s.hostname, e.name as env_name,
|
||||
COALESCE(qc.general_excludes, '') as ge,
|
||||
COALESCE(qc.specific_excludes, '') as se
|
||||
SELECT s.id, s.hostname, e.name as env_name, COALESCE(s.patch_excludes, '') as pe
|
||||
FROM servers s
|
||||
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
|
||||
LEFT JOIN environments e ON de.environment_id = e.id
|
||||
LEFT JOIN quickwin_server_config qc ON qc.server_id = s.id
|
||||
WHERE s.id = :sid
|
||||
"""), {"sid": sid}).fetchone()
|
||||
if not srv:
|
||||
continue
|
||||
branch = "prod" if srv.env_name and "production" in srv.env_name.lower() else "hprod"
|
||||
ge = srv.ge if srv.ge else DEFAULT_GENERAL_EXCLUDES
|
||||
db.execute(text("""
|
||||
INSERT INTO quickwin_entries (run_id, server_id, branch, general_excludes, specific_excludes)
|
||||
VALUES (:rid, :sid, :br, :ge, :se)
|
||||
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": ge, "se": srv.se})
|
||||
"""), {"rid": run_id, "sid": sid, "br": branch, "ge": reboot_pkgs, "se": srv.pe})
|
||||
added += 1
|
||||
hostnames.append(srv.hostname)
|
||||
if added:
|
||||
@ -395,6 +395,23 @@ def update_entry_status(db, entry_id, status, patch_output="", packages_count=0,
|
||||
"pp": packages, "rb": reboot_required, "n": notes})
|
||||
db.commit()
|
||||
|
||||
# Création auto d'une entrée patch_validation (en_attente) pour les serveurs patchés
|
||||
if status == "patched":
|
||||
row = db.execute(text("SELECT server_id, run_id FROM quickwin_entries WHERE id=:id"),
|
||||
{"id": entry_id}).fetchone()
|
||||
if row:
|
||||
# Éviter les doublons (même run + même server dans la dernière heure)
|
||||
existing = db.execute(text("""SELECT id FROM patch_validation
|
||||
WHERE server_id=:sid AND campaign_id=:cid AND campaign_type='quickwin'
|
||||
AND patch_date >= NOW() - INTERVAL '1 hour'"""),
|
||||
{"sid": row.server_id, "cid": row.run_id}).fetchone()
|
||||
if not existing:
|
||||
db.execute(text("""INSERT INTO patch_validation (server_id, campaign_id,
|
||||
campaign_type, patch_date, status)
|
||||
VALUES (:sid, :cid, 'quickwin', NOW(), 'en_attente')"""),
|
||||
{"sid": row.server_id, "cid": row.run_id})
|
||||
db.commit()
|
||||
|
||||
|
||||
def update_entry_field(db, entry_id, field, value):
|
||||
"""Mise a jour d'un champ unique (pour inline edit)"""
|
||||
@ -417,6 +434,31 @@ def can_start_prod(db, run_id):
|
||||
return pending.cnt == 0
|
||||
|
||||
|
||||
def check_prod_validations(db, run_id):
|
||||
"""Vérifie que chaque prod du run a ses non-prod liés validés (via server_correspondance + patch_validation).
|
||||
Retourne (ok, blockers) où blockers = liste [{prod_hostname, nonprod_hostname, status}].
|
||||
Ignore les prods sans non-prod lié (OK par défaut)."""
|
||||
rows = db.execute(text("""
|
||||
SELECT qe.id as entry_id, ps.id as prod_id, ps.hostname as prod_host
|
||||
FROM quickwin_entries qe JOIN servers ps ON qe.server_id = ps.id
|
||||
WHERE qe.run_id = :rid AND qe.branch = 'prod' AND qe.status NOT IN ('excluded','skipped','patched')
|
||||
"""), {"rid": run_id}).fetchall()
|
||||
|
||||
blockers = []
|
||||
for r in rows:
|
||||
corrs = db.execute(text("""SELECT ns.hostname,
|
||||
(SELECT pv.status FROM patch_validation pv WHERE pv.server_id = ns.id
|
||||
ORDER BY pv.patch_date DESC LIMIT 1) as last_status
|
||||
FROM server_correspondance sc JOIN servers ns ON sc.nonprod_server_id = ns.id
|
||||
WHERE sc.prod_server_id = :pid"""), {"pid": r.prod_id}).fetchall()
|
||||
for c in corrs:
|
||||
if c.last_status not in ("validated_ok", "forced"):
|
||||
blockers.append({"prod_hostname": r.prod_host,
|
||||
"nonprod_hostname": c.hostname,
|
||||
"status": c.last_status or "aucun_patching"})
|
||||
return (len(blockers) == 0), blockers
|
||||
|
||||
|
||||
def get_run_stats(db, run_id):
|
||||
return db.execute(text("""
|
||||
SELECT
|
||||
|
||||
225
app/templates/patching_config_exclusions.html
Normal file
225
app/templates/patching_config_exclusions.html
Normal file
@ -0,0 +1,225 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Patching — Config exclusions{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-cyber-accent">Config exclusions — par serveur</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">Exclusions de packages lors du <code>yum update</code>. Stockées dans iTop (champ <code>patch_excludes</code>) et poussées en temps réel.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/quickwin/config" class="btn-sm bg-cyber-border text-gray-300 px-3 py-2">Packages reboot (QuickWin)</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if msg %}
|
||||
<div class="mb-3 p-2 rounded text-sm bg-green-900/30 text-cyber-green">{{ msg }}</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-accent">{{ stats.total_servers }}</div><div class="text-xs text-gray-500">Serveurs total</div></div>
|
||||
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-green">{{ stats.with_excludes }}</div><div class="text-xs text-gray-500">Avec exclusions</div></div>
|
||||
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-yellow">{{ stats.total_servers - stats.with_excludes }}</div><div class="text-xs text-gray-500">Sans exclusions</div></div>
|
||||
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-blue">{{ total }}</div><div class="text-xs text-gray-500">Filtrés</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="card p-3 mb-4">
|
||||
<form method="GET" class="flex gap-2 items-center flex-wrap">
|
||||
<input type="text" name="search" value="{{ filters.search }}" placeholder="Hostname..." class="text-xs py-1 px-2" style="width:200px">
|
||||
<select name="domain" class="text-xs py-1 px-2" style="width:150px">
|
||||
<option value="">Tous domaines</option>
|
||||
{% for d in domains %}<option value="{{ d.code }}" {% if filters.domain == d.code %}selected{% endif %}>{{ d.name }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="env" class="text-xs py-1 px-2" style="width:130px">
|
||||
<option value="">Tous envs</option>
|
||||
{% for e in envs %}<option value="{{ e.code }}" {% if filters.env == e.code %}selected{% endif %}>{{ e.name }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="zone" class="text-xs py-1 px-2" style="width:100px">
|
||||
<option value="">Toutes zones</option>
|
||||
{% for z in zones %}<option value="{{ z }}" {% if filters.zone == z %}selected{% endif %}>{{ z }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="tier" class="text-xs py-1 px-2" style="width:90px">
|
||||
<option value="">Tier</option>
|
||||
{% for t in ['tier0','tier1','tier2','tier3'] %}<option value="{{ t }}" {% if filters.tier == t %}selected{% endif %}>{{ t }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="os" class="text-xs py-1 px-2" style="width:100px">
|
||||
<option value="">Tous OS</option>
|
||||
<option value="linux" {% if filters.os == 'linux' %}selected{% endif %}>Linux</option>
|
||||
<option value="windows" {% if filters.os == 'windows' %}selected{% endif %}>Windows</option>
|
||||
</select>
|
||||
<select name="application" class="text-xs py-1 px-2" style="width:220px">
|
||||
<option value="">Toutes solutions applicatives</option>
|
||||
{% for a in applications %}<option value="{{ a.application_name }}" {% if filters.application == a.application_name %}selected{% endif %}>{{ a.application_name }} ({{ a.c }})</option>{% endfor %}
|
||||
</select>
|
||||
<select name="has_excludes" class="text-xs py-1 px-2" style="width:140px">
|
||||
<option value="">Tous</option>
|
||||
<option value="yes" {% if filters.has_excludes == 'yes' %}selected{% endif %}>Avec exclusions</option>
|
||||
<option value="no" {% if filters.has_excludes == 'no' %}selected{% endif %}>Sans exclusions</option>
|
||||
</select>
|
||||
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
|
||||
<a href="/patching/config-exclusions" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk actions -->
|
||||
<div id="bulk-bar" class="card p-3 mb-2" style="display:none">
|
||||
<div class="flex gap-2 items-center flex-wrap mb-2">
|
||||
<span class="text-xs text-gray-400"><b id="bulk-count">0</b> sélectionné(s)</span>
|
||||
<span class="text-xs text-gray-500 font-bold ml-2">Exclusions :</span>
|
||||
<input type="text" id="bulk-pattern" placeholder="pattern (ex: oracle*, nginx*)" class="text-xs py-1 px-2" style="width:220px">
|
||||
<button onclick="bulkAction('add')" class="btn-sm bg-cyber-green text-black">Ajouter</button>
|
||||
<button onclick="bulkAction('remove')" class="btn-sm bg-red-900/40 text-cyber-red">Retirer</button>
|
||||
<button onclick="bulkAction('replace')" class="btn-sm bg-cyber-border text-cyber-accent">Remplacer tout</button>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center flex-wrap">
|
||||
<span class="text-xs text-gray-500 font-bold">Solution applicative :</span>
|
||||
<select id="bulk-app" class="text-xs py-1 px-2" style="max-width:260px">
|
||||
<option value="">-- Aucune (désassocier) --</option>
|
||||
{% for a in all_apps %}<option value="{{ a.id }}">{{ a.nom_court }}</option>{% endfor %}
|
||||
</select>
|
||||
<button onclick="bulkChangeApp()" class="btn-sm bg-cyber-blue text-black">Appliquer à tous les sélectionnés</button>
|
||||
</div>
|
||||
<div id="bulk-result" class="text-xs text-gray-400 mt-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card overflow-x-auto">
|
||||
<table class="w-full table-cyber text-xs">
|
||||
<thead><tr>
|
||||
<th class="p-2 w-8"><input type="checkbox" id="check-all" onchange="toggleAll(this)"></th>
|
||||
<th class="text-left p-2">Hostname</th>
|
||||
<th class="text-left p-2">Solution applicative</th>
|
||||
<th class="p-2">Domaine</th>
|
||||
<th class="p-2">Env</th>
|
||||
<th class="p-2">Zone</th>
|
||||
<th class="p-2">Tier</th>
|
||||
<th class="p-2">OS</th>
|
||||
<th class="text-left p-2" style="min-width:300px">Exclusions</th>
|
||||
<th class="p-2">Action</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for s in servers %}
|
||||
<tr id="row-{{ s.id }}" class="border-t border-cyber-border/30">
|
||||
<td class="p-2 text-center"><input type="checkbox" class="srv-check" value="{{ s.id }}" onchange="updateBulk()"></td>
|
||||
<td class="p-2 font-mono text-cyber-accent">{{ s.hostname }}</td>
|
||||
<td class="p-2 text-xs text-gray-300" title="{{ s.application_name or '' }}">{{ (s.application_name or '-')[:40] }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ s.domain_name or '-' }}</td>
|
||||
<td class="p-2 text-center">{{ s.env_name or '-' }}</td>
|
||||
<td class="p-2 text-center">{{ s.zone_name or '-' }}</td>
|
||||
<td class="p-2 text-center">{{ s.tier or '-' }}</td>
|
||||
<td class="p-2 text-center">{{ s.os_family or '-' }}</td>
|
||||
<td class="p-2">
|
||||
<textarea id="excl-{{ s.id }}" rows="2" class="w-full font-mono text-xs" placeholder="ex: oracle* tomcat* (un ou plusieurs patterns séparés par espace ou retour ligne)" style="resize:vertical;min-height:36px">{{ s.patch_excludes or '' }}</textarea>
|
||||
</td>
|
||||
<td class="p-2 text-center">
|
||||
<button onclick="saveExcl({{ s.id }})" class="btn-sm bg-cyber-border text-cyber-accent">Sauver</button>
|
||||
<span id="status-{{ s.id }}" class="text-xs ml-1"></span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="flex justify-center gap-2 mt-4">
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
<a href="?page={{ p }}{% for k,v in filters.items() %}{% if v %}&{{ k }}={{ v }}{% endif %}{% endfor %}"
|
||||
class="btn-sm {% if p == page %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %} px-2 py-1">{{ p }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function toggleAll(cb) {
|
||||
document.querySelectorAll('.srv-check').forEach(c => c.checked = cb.checked);
|
||||
updateBulk();
|
||||
}
|
||||
|
||||
function updateBulk() {
|
||||
const ids = Array.from(document.querySelectorAll('.srv-check:checked')).map(c => parseInt(c.value));
|
||||
const bar = document.getElementById('bulk-bar');
|
||||
const count = document.getElementById('bulk-count');
|
||||
count.textContent = ids.length;
|
||||
bar.style.display = ids.length > 0 ? 'flex' : 'none';
|
||||
window._selectedIds = ids;
|
||||
}
|
||||
|
||||
function saveExcl(id) {
|
||||
const inp = document.getElementById('excl-' + id);
|
||||
const status = document.getElementById('status-' + id);
|
||||
status.textContent = '…'; status.className = 'text-xs ml-1 text-gray-400';
|
||||
const fd = new FormData();
|
||||
fd.append('patch_excludes', inp.value);
|
||||
fetch('/patching/config-exclusions/' + id + '/save', {method: 'POST', credentials: 'same-origin', body: fd})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) {
|
||||
status.textContent = d.itop && d.itop.pushed ? '✓ iTop' : '⚠ local';
|
||||
status.className = 'text-xs ml-1 ' + (d.itop && d.itop.pushed ? 'text-cyber-green' : 'text-cyber-yellow');
|
||||
status.title = d.itop ? d.itop.msg : '';
|
||||
} else {
|
||||
status.textContent = '✗';
|
||||
status.className = 'text-xs ml-1 text-cyber-red';
|
||||
status.title = d.msg || '';
|
||||
}
|
||||
})
|
||||
.catch(e => { status.textContent = '✗'; status.className = 'text-xs ml-1 text-cyber-red'; status.title = e.message; });
|
||||
}
|
||||
|
||||
function bulkChangeApp() {
|
||||
const ids = window._selectedIds || [];
|
||||
const app = document.getElementById('bulk-app').value;
|
||||
const appName = document.getElementById('bulk-app').selectedOptions[0].text;
|
||||
if (!ids.length) return alert('Aucun serveur sélectionné');
|
||||
if (!confirm('Changer solution applicative vers "' + appName + '" sur ' + ids.length + ' serveur(s) ?')) return;
|
||||
const res = document.getElementById('bulk-result');
|
||||
res.textContent = 'En cours...'; res.className = 'text-xs text-gray-400 mt-2';
|
||||
fetch('/patching/config-exclusions/bulk-application', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({server_ids: ids, application_id: app})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) {
|
||||
res.innerHTML = '✓ ' + d.updated + ' serveurs → ' + d.app_name + ' — iTop: <b class="text-cyber-green">' + d.itop_pushed + '</b> OK / <b class="text-cyber-red">' + d.itop_errors + '</b> KO';
|
||||
res.className = 'text-xs mt-2';
|
||||
setTimeout(() => location.reload(), 2000);
|
||||
} else {
|
||||
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs text-cyber-red mt-2';
|
||||
}
|
||||
})
|
||||
.catch(e => { res.textContent = '✗ ' + e.message; res.className = 'text-xs text-cyber-red mt-2'; });
|
||||
}
|
||||
|
||||
function bulkAction(action) {
|
||||
const ids = window._selectedIds || [];
|
||||
const pattern = document.getElementById('bulk-pattern').value.trim();
|
||||
if (!ids.length) return alert('Aucun serveur sélectionné');
|
||||
if (action !== 'replace' && !pattern) return alert('Saisir un pattern');
|
||||
const label = action === 'add' ? 'Ajouter' : action === 'remove' ? 'Retirer' : 'Remplacer tout par';
|
||||
if (!confirm(label + ' "' + pattern + '" sur ' + ids.length + ' serveur(s) ?')) return;
|
||||
const res = document.getElementById('bulk-result');
|
||||
res.textContent = 'En cours...'; res.className = 'text-xs text-gray-400 ml-2';
|
||||
fetch('/patching/config-exclusions/bulk', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({server_ids: ids, pattern: pattern, action: action})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) {
|
||||
res.innerHTML = '✓ ' + d.updated + ' serveurs mis à jour — iTop: <b class="text-cyber-green">' + d.itop_pushed + '</b> OK / <b class="text-cyber-red">' + d.itop_errors + '</b> KO';
|
||||
res.className = 'text-xs ml-2';
|
||||
setTimeout(() => location.reload(), 2000);
|
||||
} else {
|
||||
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs text-cyber-red ml-2';
|
||||
}
|
||||
})
|
||||
.catch(e => { res.textContent = '✗ ' + e.message; res.className = 'text-xs text-cyber-red ml-2'; });
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
302
app/templates/patching_correspondance.html
Normal file
302
app/templates/patching_correspondance.html
Normal file
@ -0,0 +1,302 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Builder correspondance prod ↔ hors-prod{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-cyber-accent">Builder correspondance Prod ↔ Hors-Prod</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">Filtrer les serveurs, les désigner comme <b class="text-cyber-green">Prod</b> ou <b class="text-cyber-yellow">Non-Prod</b>, puis générer les liens en masse.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/patching/validations" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">Validations</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-blue">{{ stats.total_links }}</div><div class="text-xs text-gray-500">Liens existants (toutes apps)</div></div>
|
||||
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-accent">{{ stats.filtered }}</div><div class="text-xs text-gray-500">Serveurs filtrés</div></div>
|
||||
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-green" id="selected-prod-count">0</div><div class="text-xs text-gray-500">Marqués PROD</div></div>
|
||||
<div class="card p-3 text-center" style="flex:1"><div class="text-2xl font-bold text-cyber-yellow" id="selected-nonprod-count">0</div><div class="text-xs text-gray-500">Marqués NON-PROD</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="card p-3 mb-4">
|
||||
<form method="GET" class="flex gap-2 items-center flex-wrap">
|
||||
<input type="text" name="search" value="{{ search }}" placeholder="Hostname..." class="text-xs py-1 px-2" style="width:180px">
|
||||
<select name="application" class="text-xs py-1 px-2" style="max-width:260px">
|
||||
<option value="">Toutes applications</option>
|
||||
{% for a in applications %}<option value="{{ a.application_name }}" {% if application == a.application_name %}selected{% endif %}>{{ a.application_name }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="domain" class="text-xs py-1 px-2" style="width:160px">
|
||||
<option value="">Tous domaines</option>
|
||||
{% for d in domains %}<option value="{{ d }}" {% if domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="env" class="text-xs py-1 px-2" style="width:140px">
|
||||
<option value="">Tous envs</option>
|
||||
{% for e in envs %}<option value="{{ e }}" {% if env == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
|
||||
<a href="/patching/correspondance" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if can_edit %}
|
||||
<!-- Barre actions bulk -->
|
||||
<div class="card p-3 mb-2" id="bulk-bar" style="display:none">
|
||||
<div class="flex gap-2 items-center flex-wrap mb-2">
|
||||
<span class="text-xs text-gray-400"><b id="bulk-count">0</b> sélectionné(s)</span>
|
||||
</div>
|
||||
<!-- Section 1: Normaliser iTop (env + app) -->
|
||||
<div class="flex gap-2 items-center flex-wrap mb-2" style="border-left:3px solid #00d4ff;padding-left:8px">
|
||||
<span class="text-xs text-cyber-accent font-bold">Normaliser iTop :</span>
|
||||
<select id="bulk-env" class="text-xs py-1 px-2" style="width:180px">
|
||||
<option value="">-- Changer env vers --</option>
|
||||
{% for e in envs %}<option value="{{ e }}">{{ e }}</option>{% endfor %}
|
||||
</select>
|
||||
<button onclick="bulkChangeEnv()" class="btn-sm bg-cyber-blue text-black">Appliquer env</button>
|
||||
<span class="text-gray-600">|</span>
|
||||
<select id="bulk-app" class="text-xs py-1 px-2" style="max-width:260px">
|
||||
<option value="">-- Changer solution app. vers --</option>
|
||||
<option value="__none__">Aucune (désassocier)</option>
|
||||
{% for a in all_apps %}<option value="{{ a.id }}">{{ a.nom_court }}</option>{% endfor %}
|
||||
</select>
|
||||
<button onclick="bulkChangeApp()" class="btn-sm bg-cyber-blue text-black">Appliquer app.</button>
|
||||
</div>
|
||||
<!-- Section 2: Correspondance -->
|
||||
<div class="flex gap-2 items-center flex-wrap" style="border-left:3px solid #00ff88;padding-left:8px">
|
||||
<span class="text-xs text-cyber-green font-bold">Marquer pour correspondance :</span>
|
||||
<button onclick="markSelected('prod')" class="btn-sm bg-cyber-green text-black">Marquer PROD</button>
|
||||
<button onclick="markSelected('nonprod')" class="btn-sm bg-cyber-yellow text-black">Marquer NON-PROD</button>
|
||||
<button onclick="markSelected('none')" class="btn-sm bg-cyber-border text-gray-300">Démarquer</button>
|
||||
</div>
|
||||
<div id="bulk-result" class="text-xs mt-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Générer correspondances -->
|
||||
<div class="card p-4 mb-4" style="border-color:#00d4ff55">
|
||||
<div class="flex gap-3 items-center">
|
||||
<div style="flex:1">
|
||||
<b class="text-cyber-accent">Générer correspondances</b>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
<span class="text-cyber-green" id="preview-prod">0 prod</span> ×
|
||||
<span class="text-cyber-yellow" id="preview-nonprod">0 non-prod</span> =
|
||||
<b class="text-cyber-accent" id="preview-links">0 liens</b>
|
||||
</p>
|
||||
</div>
|
||||
<button onclick="generateCorrespondances()" class="btn-primary px-4 py-2 text-sm" id="btn-generate" disabled>Créer les correspondances</button>
|
||||
</div>
|
||||
<div id="gen-result" class="text-xs mt-2"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Tableau -->
|
||||
<div class="card overflow-x-auto">
|
||||
<table class="w-full table-cyber text-xs">
|
||||
<thead><tr>
|
||||
{% if can_edit %}<th class="p-2 w-8"><input type="checkbox" onchange="toggleAll(this)"></th>{% endif %}
|
||||
<th class="p-2 text-left">Hostname</th>
|
||||
<th class="p-2">Env</th>
|
||||
<th class="p-2 text-left">Application</th>
|
||||
<th class="p-2">Domaine</th>
|
||||
<th class="p-2">Zone</th>
|
||||
<th class="p-2">Liens existants</th>
|
||||
{% if can_edit %}<th class="p-2">Rôle</th>{% endif %}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for s in servers %}
|
||||
<tr class="border-t border-cyber-border/30" id="row-{{ s.id }}" data-env="{{ s.env_name or '' }}">
|
||||
{% if can_edit %}<td class="p-2 text-center"><input type="checkbox" class="srv-check" value="{{ s.id }}" onchange="updateBulk()"></td>{% endif %}
|
||||
<td class="p-2 font-mono text-cyber-accent">{{ s.hostname }}</td>
|
||||
<td class="p-2 text-center">
|
||||
{% if s.env_name == 'Production' %}<span class="badge badge-green">{{ s.env_name }}</span>
|
||||
{% elif s.env_name %}<span class="badge badge-yellow">{{ s.env_name }}</span>
|
||||
{% else %}<span class="text-gray-600">-</span>{% endif %}
|
||||
</td>
|
||||
<td class="p-2 text-xs text-gray-300" title="{{ s.application_name or '' }}">{{ (s.application_name or '-')[:35] }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ s.domain_name or '-' }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ s.zone_name or '-' }}</td>
|
||||
<td class="p-2 text-center">
|
||||
{% if s.n_as_prod %}<span class="badge badge-green" style="font-size:9px" title="Liés comme prod">{{ s.n_as_prod }}P</span>{% endif %}
|
||||
{% if s.n_as_nonprod %}<span class="badge badge-yellow" style="font-size:9px" title="Liés comme non-prod">{{ s.n_as_nonprod }}N</span>{% endif %}
|
||||
{% if not s.n_as_prod and not s.n_as_nonprod %}<span class="text-gray-600">-</span>{% endif %}
|
||||
</td>
|
||||
{% if can_edit %}
|
||||
<td class="p-2 text-center">
|
||||
<span id="role-{{ s.id }}" class="badge badge-gray" style="font-size:9px">—</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not servers %}
|
||||
<tr><td colspan="8" class="p-6 text-center text-gray-500">Aucun serveur pour ces filtres</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if can_edit %}
|
||||
<script>
|
||||
const markedProd = new Set();
|
||||
const markedNonProd = new Set();
|
||||
|
||||
function toggleAll(cb) {
|
||||
document.querySelectorAll('.srv-check').forEach(c => c.checked = cb.checked);
|
||||
updateBulk();
|
||||
}
|
||||
|
||||
function updateBulk() {
|
||||
const checked = Array.from(document.querySelectorAll('.srv-check:checked')).map(c => parseInt(c.value));
|
||||
const bar = document.getElementById('bulk-bar');
|
||||
bar.style.display = checked.length > 0 ? 'block' : 'none';
|
||||
document.getElementById('bulk-count').textContent = checked.length;
|
||||
window._selectedIds = checked;
|
||||
}
|
||||
|
||||
function markSelected(role) {
|
||||
const ids = window._selectedIds || [];
|
||||
if (!ids.length) return;
|
||||
// Contrôle de cohérence
|
||||
const warnings = [];
|
||||
ids.forEach(id => {
|
||||
const row = document.getElementById('row-' + id);
|
||||
const env = row ? (row.dataset.env || '') : '';
|
||||
if (role === 'prod' && env !== 'Production') {
|
||||
warnings.push('⚠ ' + row.querySelector('.font-mono').textContent.trim() + ' (env: ' + (env || 'aucun') + ') n\'est pas en Production');
|
||||
} else if (role === 'nonprod' && env === 'Production') {
|
||||
warnings.push('⚠ ' + row.querySelector('.font-mono').textContent.trim() + ' est en Production, pas hors-prod');
|
||||
}
|
||||
});
|
||||
if (warnings.length > 0) {
|
||||
if (!confirm('Incohérence détectée :\n\n' + warnings.join('\n') +
|
||||
'\n\nVoulez-vous continuer quand même ?\n(Recommandé : corriger d\'abord l\'environnement via "Normaliser iTop")')) return;
|
||||
}
|
||||
|
||||
ids.forEach(id => {
|
||||
const badge = document.getElementById('role-' + id);
|
||||
const row = document.getElementById('row-' + id);
|
||||
if (role === 'prod') {
|
||||
markedProd.add(id); markedNonProd.delete(id);
|
||||
badge.className = 'badge badge-green';
|
||||
badge.textContent = 'PROD';
|
||||
row.style.background = 'rgba(0, 255, 136, 0.05)';
|
||||
} else if (role === 'nonprod') {
|
||||
markedNonProd.add(id); markedProd.delete(id);
|
||||
badge.className = 'badge badge-yellow';
|
||||
badge.textContent = 'NON-PROD';
|
||||
row.style.background = 'rgba(255, 204, 0, 0.05)';
|
||||
} else {
|
||||
markedProd.delete(id); markedNonProd.delete(id);
|
||||
badge.className = 'badge badge-gray'; badge.style.fontSize = '9px';
|
||||
badge.textContent = '—';
|
||||
row.style.background = '';
|
||||
}
|
||||
});
|
||||
updateCounters();
|
||||
// Décocher
|
||||
document.querySelectorAll('.srv-check').forEach(c => c.checked = false);
|
||||
document.querySelector('thead input[type=checkbox]').checked = false;
|
||||
updateBulk();
|
||||
}
|
||||
|
||||
function updateCounters() {
|
||||
const np = markedProd.size, nn = markedNonProd.size;
|
||||
document.getElementById('selected-prod-count').textContent = np;
|
||||
document.getElementById('selected-nonprod-count').textContent = nn;
|
||||
document.getElementById('preview-prod').textContent = np + ' prod';
|
||||
document.getElementById('preview-nonprod').textContent = nn + ' non-prod';
|
||||
document.getElementById('preview-links').textContent = (np * nn) + ' liens';
|
||||
document.getElementById('btn-generate').disabled = (np === 0 || nn === 0);
|
||||
}
|
||||
|
||||
function bulkChangeEnv() {
|
||||
const ids = window._selectedIds || [];
|
||||
const env = document.getElementById('bulk-env').value;
|
||||
if (!ids.length) return alert('Aucun serveur sélectionné');
|
||||
if (!env) return alert('Choisir un environnement');
|
||||
if (!confirm('Changer l\'environnement vers "' + env + '" sur ' + ids.length + ' serveur(s) ?\n(PatchCenter + push iTop)')) return;
|
||||
const res = document.getElementById('bulk-result');
|
||||
res.textContent = 'Changement env en cours...'; res.className = 'text-xs mt-2 text-gray-400';
|
||||
fetch('/patching/correspondance/bulk-env', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({server_ids: ids, env_name: env})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) {
|
||||
res.innerHTML = '✓ ' + d.updated + ' → ' + d.env_name + ' — iTop: <b class="text-cyber-green">' + d.itop_pushed + '</b> OK / <b class="text-cyber-red">' + d.itop_errors + '</b> KO';
|
||||
res.className = 'text-xs mt-2';
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs mt-2 text-cyber-red';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bulkChangeApp() {
|
||||
const ids = window._selectedIds || [];
|
||||
let app = document.getElementById('bulk-app').value;
|
||||
if (!ids.length) return alert('Aucun serveur sélectionné');
|
||||
if (!app) return alert('Choisir une application');
|
||||
const appText = document.getElementById('bulk-app').selectedOptions[0].text;
|
||||
if (!confirm('Changer solution applicative vers "' + appText + '" sur ' + ids.length + ' serveur(s) ?\n(PatchCenter + push iTop)')) return;
|
||||
if (app === '__none__') app = '';
|
||||
const res = document.getElementById('bulk-result');
|
||||
res.textContent = 'Changement app en cours...'; res.className = 'text-xs mt-2 text-gray-400';
|
||||
fetch('/patching/correspondance/bulk-application', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({server_ids: ids, application_id: app})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) {
|
||||
res.innerHTML = '✓ ' + d.updated + ' → ' + d.app_name + ' — iTop: <b class="text-cyber-green">' + d.itop_pushed + '</b> OK / <b class="text-cyber-red">' + d.itop_errors + '</b> KO';
|
||||
res.className = 'text-xs mt-2';
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
res.textContent = '✗ ' + (d.msg || 'Erreur'); res.className = 'text-xs mt-2 text-cyber-red';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function generateCorrespondances() {
|
||||
if (markedProd.size === 0 || markedNonProd.size === 0) return;
|
||||
const n = markedProd.size * markedNonProd.size;
|
||||
if (!confirm('Créer ' + n + ' correspondances ?')) return;
|
||||
|
||||
// Récupérer les env labels des non-prod depuis le data-env de chaque row
|
||||
const envLabels = {};
|
||||
markedNonProd.forEach(id => {
|
||||
const row = document.getElementById('row-' + id);
|
||||
if (row) envLabels[id] = row.dataset.env || '';
|
||||
});
|
||||
|
||||
const res = document.getElementById('gen-result');
|
||||
res.textContent = 'En cours...'; res.className = 'text-xs mt-2 text-gray-400';
|
||||
|
||||
fetch('/patching/correspondance/bulk-create', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
prod_ids: Array.from(markedProd),
|
||||
nonprod_ids: Array.from(markedNonProd),
|
||||
env_labels: envLabels,
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) {
|
||||
res.innerHTML = '✓ ' + d.created + ' liens créés, ' + d.skipped + ' déjà existants';
|
||||
res.className = 'text-xs mt-2 text-cyber-green';
|
||||
setTimeout(() => location.reload(), 2000);
|
||||
} else {
|
||||
res.textContent = '✗ ' + (d.msg || 'Erreur');
|
||||
res.className = 'text-xs mt-2 text-cyber-red';
|
||||
}
|
||||
})
|
||||
.catch(e => { res.textContent = '✗ ' + e.message; res.className = 'text-xs mt-2 text-cyber-red'; });
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
179
app/templates/patching_validations.html
Normal file
179
app/templates/patching_validations.html
Normal file
@ -0,0 +1,179 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Validations post-patching{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-cyber-accent">Validations post-patching</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">Serveurs patchés en attente de validation par les responsables applicatifs.</p>
|
||||
</div>
|
||||
<a href="/patching/correspondance" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">Correspondance</a>
|
||||
</div>
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<a href="?status=en_attente" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
|
||||
<div class="text-2xl font-bold text-cyber-yellow">{{ stats.en_attente }}</div>
|
||||
<div class="text-xs text-gray-500">En attente</div>
|
||||
</a>
|
||||
<a href="?status=validated_ok" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
|
||||
<div class="text-2xl font-bold text-cyber-green">{{ stats.validated_ok }}</div>
|
||||
<div class="text-xs text-gray-500">Validés OK</div>
|
||||
</a>
|
||||
<a href="?status=validated_ko" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
|
||||
<div class="text-2xl font-bold text-cyber-red">{{ stats.validated_ko }}</div>
|
||||
<div class="text-xs text-gray-500">KO</div>
|
||||
</a>
|
||||
<a href="?status=forced" class="card p-3 text-center hover:border-cyber-accent" style="flex:1">
|
||||
<div class="text-2xl font-bold text-cyber-blue">{{ stats.forced }}</div>
|
||||
<div class="text-xs text-gray-500">Forcés</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="card p-3 mb-4">
|
||||
<form method="GET" class="flex gap-2 items-center flex-wrap">
|
||||
<select name="status" class="text-xs py-1 px-2">
|
||||
<option value="en_attente" {% if status == 'en_attente' %}selected{% endif %}>En attente</option>
|
||||
<option value="validated_ok" {% if status == 'validated_ok' %}selected{% endif %}>Validés OK</option>
|
||||
<option value="validated_ko" {% if status == 'validated_ko' %}selected{% endif %}>Validés KO</option>
|
||||
<option value="forced" {% if status == 'forced' %}selected{% endif %}>Forcés</option>
|
||||
</select>
|
||||
<select name="env" class="text-xs py-1 px-2">
|
||||
<option value="">Tous environnements</option>
|
||||
{% for e in envs %}<option value="{{ e }}" {% if env == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
|
||||
</select>
|
||||
{% if campaign_id %}<input type="hidden" name="campaign_id" value="{{ campaign_id }}">{% endif %}
|
||||
<button type="submit" class="btn-primary px-3 py-1 text-xs">Filtrer</button>
|
||||
<a href="/patching/validations" class="text-xs text-gray-500 hover:text-cyber-accent">Reset</a>
|
||||
{% if campaign_id %}<span class="text-xs text-cyber-accent">Campagne #{{ campaign_id }}</span>{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk bar -->
|
||||
<div id="bulk-bar" class="card p-3 mb-2 flex gap-2 items-center flex-wrap" style="display:none">
|
||||
<span class="text-xs text-gray-400"><b id="bulk-count">0</b> sélectionné(s)</span>
|
||||
<button onclick="openValidateModal('validated_ok')" class="btn-sm bg-cyber-green text-black">Marquer OK</button>
|
||||
<button onclick="openValidateModal('validated_ko')" class="btn-sm bg-red-900/40 text-cyber-red">Marquer KO</button>
|
||||
{% if can_force %}<button onclick="openValidateModal('forced')" class="btn-sm bg-cyber-yellow text-black">Forcer</button>{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Tableau -->
|
||||
<div class="card overflow-x-auto">
|
||||
<table class="w-full table-cyber text-xs">
|
||||
<thead><tr>
|
||||
<th class="p-2 w-8"><input type="checkbox" id="check-all" onchange="toggleAll(this)"></th>
|
||||
<th class="p-2 text-left">Hostname</th>
|
||||
<th class="p-2 text-left">Application</th>
|
||||
<th class="p-2">Env</th>
|
||||
<th class="p-2">Domaine</th>
|
||||
<th class="p-2">Patched</th>
|
||||
<th class="p-2">Jours</th>
|
||||
<th class="p-2">Statut</th>
|
||||
<th class="p-2">Validé par</th>
|
||||
<th class="p-2">Action</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for v in validations %}
|
||||
<tr class="border-t border-cyber-border/30">
|
||||
<td class="p-2 text-center"><input type="checkbox" class="val-check" value="{{ v.id }}" onchange="updateBulk()"></td>
|
||||
<td class="p-2 font-mono text-cyber-accent">{{ v.hostname }}</td>
|
||||
<td class="p-2 text-xs text-gray-300">{{ (v.application_name or '-')[:30] }}</td>
|
||||
<td class="p-2 text-center">{{ v.env_name or '-' }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ v.domain_name or '-' }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{% if v.patch_date %}{{ v.patch_date.strftime('%Y-%m-%d %H:%M') }}{% endif %}</td>
|
||||
<td class="p-2 text-center {% if v.days_pending and v.days_pending > 7 %}text-cyber-red font-bold{% endif %}">{{ v.days_pending|int if v.days_pending else '-' }}</td>
|
||||
<td class="p-2 text-center">
|
||||
{% if v.status == 'en_attente' %}<span class="badge badge-yellow">En attente</span>
|
||||
{% elif v.status == 'validated_ok' %}<span class="badge badge-green">✓ OK</span>
|
||||
{% elif v.status == 'validated_ko' %}<span class="badge badge-red">✗ KO</span>
|
||||
{% elif v.status == 'forced' %}<span class="badge badge-yellow" title="{{ v.forced_reason }}">Forcé</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-2 text-xs text-gray-300">
|
||||
{% if v.validated_by_name %}{{ v.validated_by_name }}{% if v.validated_at %}<div class="text-gray-500" style="font-size:10px">{{ v.validated_at.strftime('%Y-%m-%d') }}</div>{% endif %}
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="p-2 text-center">
|
||||
<a href="/patching/validations/history/{{ v.server_id }}" class="text-xs text-cyber-accent hover:underline">Historique</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not validations %}
|
||||
<tr><td colspan="10" class="p-6 text-center text-gray-500">Aucune validation dans ce filtre</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modal validation -->
|
||||
<div id="validate-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;justify-content:center;align-items:center">
|
||||
<div class="card p-5" style="width:420px;max-width:95vw">
|
||||
<h3 class="text-sm font-bold text-cyber-accent mb-3" id="validate-title">Valider</h3>
|
||||
<div id="validator-zone">
|
||||
<label class="text-xs text-gray-500 block mb-1">Validé par (contact iTop obligatoire)</label>
|
||||
<select id="val-contact-id" class="w-full text-xs">
|
||||
<option value="">-- Sélectionner --</option>
|
||||
{% for c in contacts %}<option value="{{ c.id }}">{{ c.name }} ({{ c.team or c.role }})</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div id="forced-zone" style="display:none" class="mt-3">
|
||||
<label class="text-xs text-gray-500 block mb-1">Raison du forçage (obligatoire)</label>
|
||||
<textarea id="val-reason" rows="3" class="w-full text-xs"></textarea>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="text-xs text-gray-500 block mb-1">Notes (optionnel)</label>
|
||||
<textarea id="val-notes" rows="2" class="w-full text-xs"></textarea>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4 justify-end">
|
||||
<button onclick="document.getElementById('validate-modal').style.display='none'" class="btn-sm bg-cyber-border text-gray-300">Annuler</button>
|
||||
<button onclick="submitValidate()" class="btn-primary px-3 py-1 text-xs">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let _valStatus = null;
|
||||
function toggleAll(cb) {
|
||||
document.querySelectorAll('.val-check').forEach(c => c.checked = cb.checked);
|
||||
updateBulk();
|
||||
}
|
||||
function updateBulk() {
|
||||
const ids = Array.from(document.querySelectorAll('.val-check:checked')).map(c => parseInt(c.value));
|
||||
const bar = document.getElementById('bulk-bar');
|
||||
bar.style.display = ids.length > 0 ? 'flex' : 'none';
|
||||
document.getElementById('bulk-count').textContent = ids.length;
|
||||
window._selectedValIds = ids;
|
||||
}
|
||||
function openValidateModal(status) {
|
||||
_valStatus = status;
|
||||
const titles = {'validated_ok':'Marquer OK', 'validated_ko':'Marquer KO', 'forced':'Forcer'};
|
||||
document.getElementById('validate-title').textContent = titles[status] + ' — ' + (window._selectedValIds || []).length + ' serveur(s)';
|
||||
document.getElementById('forced-zone').style.display = (status === 'forced') ? 'block' : 'none';
|
||||
document.getElementById('validator-zone').style.display = (status === 'forced') ? 'none' : 'block';
|
||||
document.getElementById('val-reason').value = '';
|
||||
document.getElementById('val-notes').value = '';
|
||||
document.getElementById('validate-modal').style.display = 'flex';
|
||||
}
|
||||
function submitValidate() {
|
||||
const ids = window._selectedValIds || [];
|
||||
if (!ids.length) return alert('Aucun sélectionné');
|
||||
const payload = {
|
||||
validation_ids: ids,
|
||||
status: _valStatus,
|
||||
contact_id: document.getElementById('val-contact-id').value || null,
|
||||
forced_reason: document.getElementById('val-reason').value,
|
||||
notes: document.getElementById('val-notes').value,
|
||||
};
|
||||
fetch('/patching/validations/mark', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.ok) { alert(d.updated + ' marqué(s)'); location.reload(); }
|
||||
else alert('Erreur: ' + (d.msg || ''));
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
48
app/templates/patching_validations_history.html
Normal file
48
app/templates/patching_validations_history.html
Normal file
@ -0,0 +1,48 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Historique validations — {{ server.hostname }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<a href="/patching/validations" class="text-xs text-gray-500 hover:text-gray-300">← Validations</a>
|
||||
<h2 class="text-xl font-bold text-cyber-accent">Historique — <span class="font-mono">{{ server.hostname }}</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card overflow-x-auto">
|
||||
<table class="w-full table-cyber text-xs">
|
||||
<thead><tr>
|
||||
<th class="p-2">Patched</th>
|
||||
<th class="p-2">Campagne</th>
|
||||
<th class="p-2">Statut</th>
|
||||
<th class="p-2">Validé par</th>
|
||||
<th class="p-2">Le</th>
|
||||
<th class="p-2 text-left">Raison forcée</th>
|
||||
<th class="p-2 text-left">Notes</th>
|
||||
<th class="p-2">Marqué par</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for h in history %}
|
||||
<tr class="border-t border-cyber-border/30">
|
||||
<td class="p-2 text-center">{{ h.patch_date.strftime('%Y-%m-%d %H:%M') if h.patch_date }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ h.campaign_type or '-' }} #{{ h.campaign_id or '' }}</td>
|
||||
<td class="p-2 text-center">
|
||||
{% if h.status == 'en_attente' %}<span class="badge badge-yellow">En attente</span>
|
||||
{% elif h.status == 'validated_ok' %}<span class="badge badge-green">OK</span>
|
||||
{% elif h.status == 'validated_ko' %}<span class="badge badge-red">KO</span>
|
||||
{% elif h.status == 'forced' %}<span class="badge badge-yellow">Forcé</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-2 text-center">{{ h.validated_by_name or '—' }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ h.validated_at.strftime('%Y-%m-%d %H:%M') if h.validated_at else '—' }}</td>
|
||||
<td class="p-2 text-gray-300">{{ h.forced_reason or '' }}</td>
|
||||
<td class="p-2 text-gray-300">{{ h.notes or '' }}</td>
|
||||
<td class="p-2 text-center text-gray-400">{{ h.marked_by or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not history %}
|
||||
<tr><td colspan="8" class="p-6 text-center text-gray-500">Aucun historique</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,206 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}QuickWin Config{% endblock %}
|
||||
|
||||
{% macro qs(p) -%}
|
||||
?page={{ p }}&per_page={{ per_page }}&search={{ filters.search or '' }}&env={{ filters.env or '' }}&domain={{ filters.domain or '' }}&zone={{ filters.zone or '' }}
|
||||
{%- endmacro %}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}QuickWin — Packages avec reboot{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<a href="/quickwin" class="text-xs text-gray-500 hover:text-gray-300">← Retour QuickWin</a>
|
||||
<h1 class="text-xl font-bold" style="color:#00d4ff">Exclusions par serveur</h1>
|
||||
<p class="text-xs text-gray-500">Tous les serveurs Linux en_production / secops — exclusions générales par défaut pré-remplies — pas de reboot nécessaire</p>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-sm text-gray-400">{{ total_count }} serveur(s)</span>
|
||||
<button onclick="document.getElementById('bulkModal').style.display='flex'" class="btn-primary" style="padding:6px 16px;font-size:0.85rem">Modifier en masse</button>
|
||||
<h2 class="text-xl font-bold text-cyber-accent">Packages nécessitant un reboot</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">Liste globale utilisée par QuickWin pour exclure ces packages du <code>yum update</code>.</p>
|
||||
</div>
|
||||
<a href="/quickwin" class="btn-sm bg-cyber-border text-cyber-accent px-4 py-2">← Retour QuickWin</a>
|
||||
</div>
|
||||
|
||||
{% if msg %}
|
||||
<div style="background:#1a5a2e;color:#8f8;padding:8px 16px;border-radius:6px;margin-bottom:12px;font-size:0.85rem">
|
||||
{% if 'saved' in msg %}Configuration sauvegardée{% elif 'deleted' in msg %}Exclusions spécifiques retirées{% elif 'added' in msg %}{{ msg.split('_')[1] }} serveur(s) mis à jour{% elif 'bulk' in msg %}Mise à jour groupée OK{% else %}{{ msg }}{% endif %}
|
||||
</div>
|
||||
{% if msg == 'saved' %}
|
||||
<div class="mb-3 p-2 rounded bg-green-900/30 text-cyber-green text-sm">Liste des reboot packages sauvegardée.</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Filtre -->
|
||||
<form method="GET" class="card mb-4" style="padding:10px 16px;display:flex;gap:12px;align-items:center">
|
||||
<input type="text" name="search" value="{{ filters.search or '' }}" placeholder="Recherche serveur..." style="width:200px">
|
||||
<select name="env" onchange="this.form.submit()" style="width:140px">
|
||||
<option value="">Tous env.</option>
|
||||
{% set envs = all_configs|map(attribute='environnement')|select('string')|unique|sort %}
|
||||
{% for e in envs %}<option value="{{ e }}" {% if filters.env == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="domain" onchange="this.form.submit()" style="width:140px">
|
||||
<option value="">Tous domaines</option>
|
||||
{% set doms = all_configs|map(attribute='domaine')|select('string')|unique|sort %}
|
||||
{% for d in doms %}<option value="{{ d }}" {% if filters.domain == d %}selected{% endif %}>{{ d }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="zone" onchange="this.form.submit()" style="width:100px">
|
||||
<option value="">Zone</option>
|
||||
{% set zones = all_configs|map(attribute='zone')|select('string')|unique|sort %}
|
||||
{% for z in zones %}<option value="{{ z }}" {% if filters.zone == z %}selected{% endif %}>{{ z }}</option>{% endfor %}
|
||||
</select>
|
||||
<select name="per_page" onchange="this.form.submit()" style="width:140px">
|
||||
<option value="">Affichage / page</option>
|
||||
{% for n in [14, 25, 50, 100] %}<option value="{{ n }}" {% if per_page == n %}selected{% endif %}>{{ n }} par page</option>{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn-primary" style="padding:4px 14px;font-size:0.8rem">Filtrer</button>
|
||||
<a href="/quickwin/config" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
|
||||
<span class="text-xs text-gray-500">{{ total_count }} serveur(s)</span>
|
||||
</form>
|
||||
<!-- Info -->
|
||||
<div class="card p-3 mb-4 text-xs text-gray-400" style="background:#111827">
|
||||
<b class="text-cyber-accent">Fonctionnement :</b>
|
||||
<ul class="list-disc ml-5 mt-2 space-y-1">
|
||||
<li>Les <b>campagnes QuickWin</b> excluent ces packages + les exclusions par serveur (iTop / <a href="/patching/config-exclusions" class="text-cyber-accent hover:underline">Config exclusions</a>).</li>
|
||||
<li>Les <b>campagnes standard avec reboot</b> n'utilisent PAS cette liste — uniquement les exclusions par serveur.</li>
|
||||
<li>Format : patterns yum séparés par espace (ex: <code>kernel* glibc* systemd*</code>).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Cartouche detail serveur -->
|
||||
<div id="srvDetail" class="card mb-4" style="display:none;border-left:3px solid #00d4ff;padding:12px 16px">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 style="color:#00d4ff;font-weight:bold;font-size:0.95rem" id="detailName"></h3>
|
||||
<button onclick="document.getElementById('srvDetail').style.display='none'" class="text-gray-500 hover:text-gray-300" style="font-size:1.2rem">×</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Formulaire -->
|
||||
<div class="card p-4">
|
||||
<form method="POST" action="/quickwin/config/save" class="space-y-3">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 mb-1 font-bold">Exclusions générales (OS / reboot)</div>
|
||||
<pre id="detailGeneral" style="font-size:0.7rem;color:#ffcc00;white-space:pre-wrap;margin:0"></pre>
|
||||
<label class="text-xs text-gray-500 block mb-1">Patterns de packages à exclure (séparés par espace)</label>
|
||||
<textarea name="reboot_packages" rows="6" class="w-full font-mono text-xs" style="min-height:120px">{{ reboot_packages }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 mb-1 font-bold">Exclusions spécifiques (applicatifs — hors périmètre secops)</div>
|
||||
<pre id="detailSpecific" style="font-size:0.7rem;color:#ff8800;white-space:pre-wrap;margin:0"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tableau serveurs -->
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table class="table-cyber w-full" id="srvTable">
|
||||
<thead><tr>
|
||||
<th class="px-2 py-2" style="width:30px"><input type="checkbox" id="checkAll" onchange="toggleAll(this)"></th>
|
||||
<th class="px-2 py-2">Serveur</th>
|
||||
<th class="px-2 py-2">Domaine</th>
|
||||
<th class="px-2 py-2">Env</th>
|
||||
<th class="px-2 py-2">Zone</th>
|
||||
<th class="px-2 py-2">Tier</th>
|
||||
<th class="px-2 py-2">Exclusions générales</th>
|
||||
<th class="px-2 py-2">Exclusions spécifiques</th>
|
||||
<th class="px-2 py-2">Notes</th>
|
||||
<th class="px-2 py-2" style="width:60px">Save</th>
|
||||
<th class="px-2 py-2" style="width:60px">Cmd</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for s in all_servers %}
|
||||
<tr>
|
||||
<td class="px-2 py-2"><input type="checkbox" class="srv-check" value="{{ s.server_id }}"></td>
|
||||
<td class="px-2 py-2 font-bold" style="color:#00d4ff;cursor:pointer" onclick="showDetail('{{ s.hostname }}', this)">{{ s.hostname }}</td>
|
||||
<td class="px-2 py-2 text-gray-400 text-xs">{{ s.domaine or '?' }}</td>
|
||||
<td class="px-2 py-2 text-gray-400 text-xs">{{ s.environnement or '?' }}</td>
|
||||
<td class="px-2 py-2 text-center"><span class="badge {% if s.zone == 'DMZ' %}badge-red{% else %}badge-blue{% endif %}">{{ s.zone or 'LAN' }}</span></td>
|
||||
<td class="px-2 py-2 text-gray-400 text-xs">{{ s.tier }}</td>
|
||||
<td class="px-2 py-2">
|
||||
<form method="post" action="/quickwin/config/save" class="inline-form" style="display:flex;gap:4px;align-items:center">
|
||||
<input type="hidden" name="server_id" value="{{ s.server_id }}">
|
||||
<input type="text" name="general_excludes" value="{{ s.general_excludes }}"
|
||||
style="width:200px;font-size:0.7rem;padding:2px 6px" title="{{ s.general_excludes }}">
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<input type="text" name="specific_excludes" value="{{ s.specific_excludes }}"
|
||||
style="width:150px;font-size:0.7rem;padding:2px 6px" placeholder="sdcss* custom*...">
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<input type="text" name="notes" value="{{ s.notes }}"
|
||||
style="width:80px;font-size:0.7rem;padding:2px 6px" placeholder="...">
|
||||
<button type="submit" class="btn-sm" style="background:#1e3a5f;color:#00d4ff;font-size:0.65rem">OK</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<button type="button" class="btn-sm" style="background:#1a3a1a;color:#00ff88;font-size:0.6rem;white-space:nowrap" onclick="showDryRun('{{ s.hostname }}', this)">Dry Run</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not all_servers %}<tr><td colspan="11" class="px-2 py-8 text-center text-gray-500">Aucun serveur trouvé</td></tr>{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex justify-between items-center mt-4 text-sm text-gray-500">
|
||||
<span>Page {{ page }} / {{ total_pages }} — {{ total_count }} serveurs</span>
|
||||
<div class="flex gap-2">
|
||||
{% if page > 1 %}<a href="{{ qs(page - 1) }}" class="btn-sm bg-cyber-border text-gray-300">Précédent</a>{% endif %}
|
||||
{% if page < total_pages %}<a href="{{ qs(page + 1) }}" class="btn-sm bg-cyber-border text-gray-300">Suivant</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk modal -->
|
||||
<div id="bulkModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)this.style.display='none'">
|
||||
<div class="card" style="width:550px;max-width:90vw;padding:24px">
|
||||
<h3 style="color:#00d4ff;font-weight:bold;margin-bottom:12px">Modification groupée</h3>
|
||||
<p class="text-xs text-gray-500 mb-3">Cochez les serveurs dans le tableau, puis appliquez les exclusions.</p>
|
||||
<form method="post" action="/quickwin/config/bulk-add">
|
||||
<input type="hidden" name="server_ids" id="bulkIds">
|
||||
<div class="mb-3">
|
||||
<label class="text-xs text-gray-400 block mb-1">Exclusions générales</label>
|
||||
<textarea name="general_excludes" rows="3" style="width:100%;font-size:0.75rem">{{ default_excludes }}</textarea>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" class="btn-sm" style="background:#333;color:#ccc;padding:6px 16px" onclick="document.getElementById('bulkModal').style.display='none'">Annuler</button>
|
||||
<button type="submit" class="btn-primary" style="padding:6px 20px" onclick="collectIds()">Appliquer</button>
|
||||
<div class="flex gap-2 items-center">
|
||||
<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>
|
||||
<button type="button" onclick="document.querySelector('textarea[name=reboot_packages]').value = {{ default_packages|tojson }}" class="btn-sm bg-cyber-border text-gray-300 px-3 py-2">Réinitialiser aux valeurs par défaut</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dry Run modal -->
|
||||
<div id="dryRunModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.7);z-index:100;align-items:center;justify-content:center" onclick="if(event.target===this)this.style.display='none'">
|
||||
<div class="card" style="width:700px;max-width:90vw;padding:24px">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 style="color:#00ff88;font-weight:bold" id="dryRunTitle">Dry Run</h3>
|
||||
<button id="copyBtn" onclick="copyDryRun()" class="btn-sm" style="background:#1e3a5f;color:#00d4ff;font-size:0.75rem;padding:4px 12px">Copier</button>
|
||||
</div>
|
||||
<pre id="dryRunCmd" style="background:#0a0e17;border:1px solid #1e3a5f;border-radius:6px;padding:12px;font-size:0.75rem;color:#00ff88;white-space:pre-wrap;word-break:break-all;max-height:400px;overflow-y:auto"></pre>
|
||||
<div class="flex justify-end mt-3">
|
||||
<button type="button" class="btn-sm" style="background:#333;color:#ccc;padding:6px 16px" onclick="document.getElementById('dryRunModal').style.display='none'">Fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Aperçu de la commande QuickWin -->
|
||||
<div class="card p-4 mt-4">
|
||||
<h3 class="text-sm font-bold text-cyber-accent mb-2">Aperçu commande QuickWin générée</h3>
|
||||
<pre class="text-xs font-mono text-cyber-green bg-cyber-bg p-3 rounded overflow-x-auto" style="white-space:pre-wrap">yum update -y \
|
||||
{% for pkg in reboot_packages.split() %} --exclude={{ pkg }} \
|
||||
{% endfor %} <exclusions iTop du serveur></pre>
|
||||
<p class="text-xs text-gray-500 mt-2">Les <code><exclusions iTop du serveur></code> sont ajoutées depuis le champ <code>patch_excludes</code> de chaque serveur, gérable via <a href="/patching/config-exclusions" class="text-cyber-accent hover:underline">Config exclusions</a>.</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showDetail(hostname, td) {
|
||||
const tr = td.closest('tr');
|
||||
const ge = tr.querySelector('input[name=general_excludes]').value.trim();
|
||||
const se = tr.querySelector('input[name=specific_excludes]').value.trim();
|
||||
document.getElementById('detailName').textContent = hostname;
|
||||
document.getElementById('detailGeneral').textContent = ge ? ge.split(/\s+/).join('\n') : '(aucune)';
|
||||
document.getElementById('detailSpecific').textContent = se ? se.split(/\s+/).join('\n') : '(aucune)';
|
||||
const panel = document.getElementById('srvDetail');
|
||||
panel.style.display = 'block';
|
||||
panel.scrollIntoView({behavior: 'smooth', block: 'nearest'});
|
||||
}
|
||||
function showDryRun(hostname, btn) {
|
||||
const tr = btn.closest('tr');
|
||||
const ge = tr.querySelector('input[name=general_excludes]').value.trim();
|
||||
const se = tr.querySelector('input[name=specific_excludes]').value.trim();
|
||||
const all = (ge + ' ' + se).trim().split(/\s+/).filter(x => x);
|
||||
const excludes = all.map(e => '--exclude=' + e).join(' \\\n ');
|
||||
const cmd = 'yum update -y \\\n ' + excludes;
|
||||
document.getElementById('dryRunTitle').textContent = 'Dry Run — ' + hostname;
|
||||
document.getElementById('dryRunCmd').textContent = cmd;
|
||||
document.getElementById('dryRunModal').style.display = 'flex';
|
||||
}
|
||||
function copyDryRun() {
|
||||
const text = document.getElementById('dryRunCmd').textContent;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const btn = document.getElementById('copyBtn');
|
||||
btn.textContent = 'Copi\u00e9 !';
|
||||
setTimeout(() => btn.textContent = 'Copier', 1500);
|
||||
});
|
||||
}
|
||||
function toggleAll(cb) {
|
||||
document.querySelectorAll('.srv-check').forEach(c => c.checked = cb.checked);
|
||||
}
|
||||
function collectIds() {
|
||||
const ids = Array.from(document.querySelectorAll('.srv-check:checked')).map(c => c.value);
|
||||
document.getElementById('bulkIds').value = ids.join(',');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<a href="/quickwin/{{ run.id }}/correspondance" class="btn-sm" style="background:#1e3a5f;color:#a78bfa;padding:4px 14px;text-decoration:none">Correspondance</a>
|
||||
<a href="/patching/validations?campaign_id={{ run.id }}" class="btn-sm" style="background:#1e3a5f;color:#00ff88;padding:4px 14px;text-decoration:none">Validations</a>
|
||||
<a href="/quickwin/{{ run.id }}/logs" class="btn-sm" style="background:#1e3a5f;color:#94a3b8;padding:4px 14px;text-decoration:none">Logs</a>
|
||||
<form method="post" action="/quickwin/{{ run.id }}/delete" onsubmit="return confirm('Supprimer cette campagne ?')">
|
||||
<button class="btn-sm btn-danger" style="padding:4px 12px">Supprimer</button>
|
||||
@ -232,6 +233,33 @@
|
||||
{% if run.status == 'patching' %}
|
||||
|
||||
{% if prod_ok and stats.prod_total > 0 %}
|
||||
{# Alerte validations hors-prod non validées avant de patcher prod #}
|
||||
{% if not validations_ok %}
|
||||
<div class="card mb-4" style="border-left:3px solid #ff3366;padding:16px;background:#5a1a1a22">
|
||||
<h3 style="color:#ff3366;font-weight:bold;margin-bottom:8px">⚠ Validations hors-prod requises avant la production</h3>
|
||||
<p class="text-xs text-gray-300 mb-2">Les non-prod suivants ne sont pas validés. Marquer leur validation dans <a href="/patching/validations?status=en_attente" class="text-cyber-accent hover:underline">/patching/validations</a> avant de patcher les prods correspondants.</p>
|
||||
<div class="text-xs" style="max-height:180px;overflow-y:auto">
|
||||
<table class="w-full table-cyber">
|
||||
<thead><tr><th class="text-left p-1">Prod</th><th class="text-left p-1">Hors-prod bloquant</th><th class="p-1">Statut</th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in validations_blockers %}
|
||||
<tr class="border-t border-cyber-border/30">
|
||||
<td class="p-1 font-mono text-cyber-green">{{ b.prod_hostname }}</td>
|
||||
<td class="p-1 font-mono text-cyber-yellow">{{ b.nonprod_hostname }}</td>
|
||||
<td class="p-1 text-center">
|
||||
{% if b.status == 'en_attente' %}<span class="badge badge-yellow">En attente</span>
|
||||
{% elif b.status == 'validated_ko' %}<span class="badge badge-red">KO</span>
|
||||
{% elif b.status == 'aucun_patching' %}<span class="badge badge-gray">Pas de patching</span>
|
||||
{% else %}<span class="badge badge-gray">{{ b.status }}</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">{{ validations_blockers|length }} bloquant(s) — vous pouvez continuer mais il est recommandé d'obtenir les validations d'abord.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Prereq + Snapshot Prod (si pas encore faits) -->
|
||||
<div class="card mb-4" style="border-left:3px solid #ff8800;padding:16px">
|
||||
<h3 style="color:#ff8800;font-weight:bold;margin-bottom:8px">Préparation Production</h3>
|
||||
|
||||
41
migrate_correspondance.sql
Normal file
41
migrate_correspondance.sql
Normal file
@ -0,0 +1,41 @@
|
||||
-- Table de correspondance prod ↔ hors-prod
|
||||
CREATE TABLE IF NOT EXISTS server_correspondance (
|
||||
id SERIAL PRIMARY KEY,
|
||||
prod_server_id INTEGER REFERENCES servers(id) ON DELETE CASCADE,
|
||||
nonprod_server_id INTEGER REFERENCES servers(id) ON DELETE CASCADE,
|
||||
environment_code VARCHAR(50),
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'auto',
|
||||
note TEXT,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
CONSTRAINT server_correspondance_source_check CHECK (source IN ('auto','manual','exception')),
|
||||
CONSTRAINT server_correspondance_uniq UNIQUE(prod_server_id, nonprod_server_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_corr_prod ON server_correspondance(prod_server_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_corr_nonprod ON server_correspondance(nonprod_server_id);
|
||||
|
||||
-- Table des validations post-patching
|
||||
CREATE TABLE IF NOT EXISTS patch_validation (
|
||||
id SERIAL PRIMARY KEY,
|
||||
server_id INTEGER REFERENCES servers(id) ON DELETE CASCADE,
|
||||
campaign_id INTEGER,
|
||||
campaign_type VARCHAR(30),
|
||||
patch_date TIMESTAMP DEFAULT NOW(),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'en_attente',
|
||||
validated_by_contact_id INTEGER REFERENCES contacts(id),
|
||||
validated_by_name VARCHAR(200),
|
||||
validated_at TIMESTAMP,
|
||||
marked_by_user_id INTEGER REFERENCES users(id),
|
||||
forced_reason TEXT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
CONSTRAINT patch_validation_status_check CHECK (status IN ('en_attente','validated_ok','validated_ko','forced'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pv_server ON patch_validation(server_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pv_campaign ON patch_validation(campaign_id, campaign_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_pv_status ON patch_validation(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_pv_patch_date ON patch_validation(patch_date DESC);
|
||||
|
||||
SELECT 'schema créé' as msg;
|
||||
Loading…
Reference in New Issue
Block a user