patchcenter/app/routers/quickwin.py
Admin MPCZ a706e240ca 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>
2026-04-12 18:51:30 +02:00

973 lines
43 KiB
Python

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