feat(securite/ldap): cookie Secure, logs debug LDAPS, .gitignore durci
- auth.py: flag Secure + path=/ sur le cookie d'authentification - ldap_service.py: logging debug des connexions LDAPS vers logs/ldap_debug.log (jamais les mots de passe) - .gitignore: protege cles/certs TLS (ssl/, *.key, *.crt) + artefacts lourds (db/, sitepkgs.zip, *.bak, dump) - inclut aussi des modifs en cours: planning_import, patch_run_service, patching_iexec Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3c451156d5
commit
4590e89ff6
11
.gitignore
vendored
11
.gitignore
vendored
@ -15,3 +15,14 @@ __pycache__/
|
|||||||
import.log
|
import.log
|
||||||
|
|
||||||
backups/
|
backups/
|
||||||
|
|
||||||
|
# Cles/certificats TLS - ne jamais committer
|
||||||
|
ssl/
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
|
||||||
|
# Donnees lourdes / artefacts locaux - ne pas committer
|
||||||
|
db/
|
||||||
|
sitepkgs.zip
|
||||||
|
*.bak
|
||||||
|
patchcenter_db.sql
|
||||||
|
|||||||
@ -107,7 +107,7 @@ async def login(request: Request, username: str = Form(...), password: str = For
|
|||||||
modules = {r.module for r in perms}
|
modules = {r.module for r in perms}
|
||||||
redirect_url = "/quickwin" if modules == {"quickwin"} else "/dashboard"
|
redirect_url = "/quickwin" if modules == {"quickwin"} else "/dashboard"
|
||||||
response = RedirectResponse(url=redirect_url, status_code=303)
|
response = RedirectResponse(url=redirect_url, status_code=303)
|
||||||
response.set_cookie(key="access_token", value=token, httponly=True, samesite="lax", max_age=3600)
|
response.set_cookie(key="access_token", value=token, httponly=True, secure=True, samesite="lax", max_age=3600, path="/")
|
||||||
return response
|
return response
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@ -1453,6 +1453,31 @@ async def pct_prevenance_send(request: Request, db=Depends(get_db)):
|
|||||||
return row, None
|
return row, None
|
||||||
|
|
||||||
|
|
||||||
|
def _common_iexec_row_check(row_id, db, user, perms):
|
||||||
|
"""Validation commune des endpoints d'exécution iexec (dry-run, yum, capture,
|
||||||
|
reboot, status…). Retourne (row, None) si OK, sinon (None, JSONResponse).
|
||||||
|
La row expose hostname, asset_name et effective_excludes (via v_servers),
|
||||||
|
attributs utilisés par tous les appelants."""
|
||||||
|
if not (can_view(perms, "planning") or can_view(perms, "campaigns")):
|
||||||
|
return None, JSONResponse({"ok": False, "detail": "Permission refusée"}, status_code=403)
|
||||||
|
row = db.execute(text("""
|
||||||
|
SELECT r.id, r.asset_name, r.os, r.is_eligible, r.server_id,
|
||||||
|
s.hostname, vs.effective_excludes
|
||||||
|
FROM patch_planning_import_rows r
|
||||||
|
LEFT JOIN servers s ON s.id = r.server_id
|
||||||
|
LEFT JOIN v_servers vs ON vs.id = r.server_id
|
||||||
|
WHERE r.id = :id
|
||||||
|
"""), {"id": row_id}).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None, JSONResponse({"ok": False, "detail": "Ligne introuvable"}, status_code=404)
|
||||||
|
if not row.is_eligible:
|
||||||
|
return None, JSONResponse({"ok": False, "detail": "Ligne non éligible"}, status_code=400)
|
||||||
|
hostname = (row.hostname or row.asset_name or "").strip()
|
||||||
|
if not hostname:
|
||||||
|
return None, JSONResponse({"ok": False, "detail": "Pas de hostname"}, status_code=400)
|
||||||
|
return row, None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/patching/iexec/yum-dryrun/{row_id}")
|
@router.post("/patching/iexec/yum-dryrun/{row_id}")
|
||||||
async def iexec_yum_dryrun(request: Request, row_id: int, db=Depends(get_db)):
|
async def iexec_yum_dryrun(request: Request, row_id: int, db=Depends(get_db)):
|
||||||
"""Step 3 — pré-vol : `sudo -n yum update --assumeno --exclude=...`."""
|
"""Step 3 — pré-vol : `sudo -n yum update --assumeno --exclude=...`."""
|
||||||
|
|||||||
@ -8,18 +8,57 @@ Configuration via settings (clés) :
|
|||||||
- ldap_bind_pwd : mot de passe (stocké chiffré via secrets_service)
|
- ldap_bind_pwd : mot de passe (stocké chiffré via secrets_service)
|
||||||
- ldap_user_filter : filtre utilisateur (ex: (sAMAccountName={username}))
|
- ldap_user_filter : filtre utilisateur (ex: (sAMAccountName={username}))
|
||||||
- ldap_tls : "true" / "false"
|
- ldap_tls : "true" / "false"
|
||||||
|
|
||||||
|
Debug : journalise le détail des connexions LDAPS dans logs/ldap_debug.log
|
||||||
|
(niveau DEBUG). Les mots de passe ne sont JAMAIS écrits dans les logs.
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
# --- Logger dédié -> logs/ldap_debug.log (DEBUG) -------------------------------
|
||||||
|
# Chemin : .../app/services/ldap_service.py -> remonte de 3 niveaux = racine projet
|
||||||
|
_LOG_DIR = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
|
"logs",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("patchcenter.ldap")
|
||||||
|
if not log.handlers:
|
||||||
|
try:
|
||||||
|
os.makedirs(_LOG_DIR, exist_ok=True)
|
||||||
|
_handler = RotatingFileHandler(
|
||||||
|
os.path.join(_LOG_DIR, "ldap_debug.log"),
|
||||||
|
maxBytes=2_000_000, backupCount=5, encoding="utf-8",
|
||||||
|
)
|
||||||
|
_handler.setFormatter(logging.Formatter(
|
||||||
|
"%(asctime)s %(levelname)-7s [ldap] %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
))
|
||||||
|
log.addHandler(_handler)
|
||||||
|
log.setLevel(logging.DEBUG)
|
||||||
|
log.propagate = False
|
||||||
|
except Exception as _e: # pragma: no cover - ne jamais casser l'app pour un log
|
||||||
|
logging.getLogger(__name__).warning("Impossible d'initialiser ldap_debug.log: %s", _e)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from ldap3 import Server, Connection, ALL, NTLM, SIMPLE, Tls
|
from ldap3 import Server, Connection, ALL, NTLM, SIMPLE, Tls
|
||||||
import ssl
|
import ssl
|
||||||
LDAP_OK = True
|
LDAP_OK = True
|
||||||
|
# Active la journalisation protocolaire ldap3 vers le meme fichier.
|
||||||
|
# NOTE: ldap3 masque les donnees sensibles (mots de passe) par defaut.
|
||||||
|
try:
|
||||||
|
from ldap3.utils.log import set_library_log_detail_level, EXTENDED
|
||||||
|
set_library_log_detail_level(EXTENDED)
|
||||||
|
_l3 = logging.getLogger("ldap3")
|
||||||
|
_l3.setLevel(logging.DEBUG)
|
||||||
|
if log.handlers and not _l3.handlers:
|
||||||
|
_l3.addHandler(log.handlers[0])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except ImportError:
|
except ImportError:
|
||||||
LDAP_OK = False
|
LDAP_OK = False
|
||||||
|
log.warning("Module ldap3 non installe : authentification LDAP indisponible")
|
||||||
|
|
||||||
|
|
||||||
def _get_config(db):
|
def _get_config(db):
|
||||||
@ -57,17 +96,26 @@ def authenticate(db, username, password):
|
|||||||
return {"ok": False, "msg": "Module ldap3 non installé"}
|
return {"ok": False, "msg": "Module ldap3 non installé"}
|
||||||
|
|
||||||
cfg = _get_config(db)
|
cfg = _get_config(db)
|
||||||
|
use_ssl = cfg["server"].startswith("ldaps://")
|
||||||
|
log.debug(
|
||||||
|
"AUTH demande user=%s | server=%s ssl=%s tls=%s base_dn=%s bind_dn=%s filter=%s required_group=%s",
|
||||||
|
username, cfg["server"], use_ssl, cfg["tls"], cfg["base_dn"], cfg["bind_dn"],
|
||||||
|
cfg["user_filter"], cfg["required_group"] or "(aucun)",
|
||||||
|
)
|
||||||
if not cfg["enabled"]:
|
if not cfg["enabled"]:
|
||||||
|
log.debug("AUTH user=%s -> LDAP desactive", username)
|
||||||
return {"ok": False, "msg": "LDAP désactivé"}
|
return {"ok": False, "msg": "LDAP désactivé"}
|
||||||
if not cfg["server"] or not cfg["base_dn"]:
|
if not cfg["server"] or not cfg["base_dn"]:
|
||||||
|
log.debug("AUTH user=%s -> LDAP non configure (server/base_dn manquant)", username)
|
||||||
return {"ok": False, "msg": "LDAP non configuré"}
|
return {"ok": False, "msg": "LDAP non configuré"}
|
||||||
|
|
||||||
# 1. Bind service account pour chercher le DN de l'utilisateur
|
# 1. Bind service account pour chercher le DN de l'utilisateur
|
||||||
try:
|
try:
|
||||||
server = Server(cfg["server"], get_info=ALL, use_ssl=cfg["server"].startswith("ldaps://"))
|
server = Server(cfg["server"], get_info=ALL, use_ssl=use_ssl)
|
||||||
conn = Connection(server, user=cfg["bind_dn"], password=cfg["bind_pwd"], auto_bind=True)
|
conn = Connection(server, user=cfg["bind_dn"], password=cfg["bind_pwd"], auto_bind=True)
|
||||||
|
log.debug("AUTH user=%s -> bind compte de service OK (result=%s)", username, getattr(conn, "result", None))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"LDAP bind failed: {e}")
|
log.error("AUTH user=%s -> bind compte de service ECHEC: %s", username, e)
|
||||||
return {"ok": False, "msg": f"Connexion LDAP échouée: {e}"}
|
return {"ok": False, "msg": f"Connexion LDAP échouée: {e}"}
|
||||||
|
|
||||||
# 2. Recherche de l'utilisateur
|
# 2. Recherche de l'utilisateur
|
||||||
@ -75,11 +123,15 @@ def authenticate(db, username, password):
|
|||||||
try:
|
try:
|
||||||
conn.search(cfg["base_dn"], user_filter,
|
conn.search(cfg["base_dn"], user_filter,
|
||||||
attributes=[cfg["email_attr"], cfg["name_attr"], "distinguishedName", "memberOf"])
|
attributes=[cfg["email_attr"], cfg["name_attr"], "distinguishedName", "memberOf"])
|
||||||
|
log.debug("AUTH user=%s -> recherche filter=%s base=%s entries=%d",
|
||||||
|
username, user_filter, cfg["base_dn"], len(conn.entries))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
log.error("AUTH user=%s -> recherche ECHEC: %s", username, e)
|
||||||
conn.unbind()
|
conn.unbind()
|
||||||
return {"ok": False, "msg": f"Recherche LDAP échouée: {e}"}
|
return {"ok": False, "msg": f"Recherche LDAP échouée: {e}"}
|
||||||
|
|
||||||
if not conn.entries:
|
if not conn.entries:
|
||||||
|
log.debug("AUTH user=%s -> utilisateur introuvable", username)
|
||||||
conn.unbind()
|
conn.unbind()
|
||||||
return {"ok": False, "msg": "Utilisateur introuvable dans LDAP"}
|
return {"ok": False, "msg": "Utilisateur introuvable dans LDAP"}
|
||||||
|
|
||||||
@ -89,6 +141,7 @@ def authenticate(db, username, password):
|
|||||||
name = str(getattr(entry, cfg["name_attr"], "")) or username
|
name = str(getattr(entry, cfg["name_attr"], "")) or username
|
||||||
groups = list(entry.memberOf.values) if hasattr(entry, "memberOf") and entry.memberOf else []
|
groups = list(entry.memberOf.values) if hasattr(entry, "memberOf") and entry.memberOf else []
|
||||||
conn.unbind()
|
conn.unbind()
|
||||||
|
log.debug("AUTH user=%s -> trouve dn=%s email=%s nb_groupes=%d", username, user_dn, email, len(groups))
|
||||||
|
|
||||||
# 3. Verification appartenance groupe (si configure)
|
# 3. Verification appartenance groupe (si configure)
|
||||||
required = (cfg.get("required_group") or "").strip()
|
required = (cfg.get("required_group") or "").strip()
|
||||||
@ -96,16 +149,20 @@ def authenticate(db, username, password):
|
|||||||
# Match insensible a la casse + espaces normalises
|
# Match insensible a la casse + espaces normalises
|
||||||
norm_required = required.lower().replace(" ", "")
|
norm_required = required.lower().replace(" ", "")
|
||||||
is_member = any(norm_required == g.lower().replace(" ", "") for g in groups)
|
is_member = any(norm_required == g.lower().replace(" ", "") for g in groups)
|
||||||
|
log.debug("AUTH user=%s -> controle groupe requis=%s membre=%s", username, required, is_member)
|
||||||
if not is_member:
|
if not is_member:
|
||||||
return {"ok": False, "msg": f"Acces refuse: utilisateur non membre du groupe requis"}
|
return {"ok": False, "msg": f"Acces refuse: utilisateur non membre du groupe requis"}
|
||||||
|
|
||||||
# 4. Bind avec les credentials fournis
|
# 4. Bind avec les credentials fournis
|
||||||
try:
|
try:
|
||||||
user_conn = Connection(server, user=user_dn, password=password, auto_bind=True)
|
user_conn = Connection(server, user=user_dn, password=password, auto_bind=True)
|
||||||
|
log.debug("AUTH user=%s -> bind utilisateur OK (result=%s)", username, getattr(user_conn, "result", None))
|
||||||
user_conn.unbind()
|
user_conn.unbind()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
log.warning("AUTH user=%s -> bind utilisateur ECHEC (mot de passe incorrect ?): %s", username, e)
|
||||||
return {"ok": False, "msg": "Mot de passe incorrect"}
|
return {"ok": False, "msg": "Mot de passe incorrect"}
|
||||||
|
|
||||||
|
log.info("AUTH SUCCES user=%s dn=%s", username, user_dn)
|
||||||
return {"ok": True, "dn": user_dn, "email": email, "name": name,
|
return {"ok": True, "dn": user_dn, "email": email, "name": name,
|
||||||
"groups": groups, "default_role": cfg.get("default_role", "operator")}
|
"groups": groups, "default_role": cfg.get("default_role", "operator")}
|
||||||
|
|
||||||
@ -117,10 +174,14 @@ def test_connection(db):
|
|||||||
cfg = _get_config(db)
|
cfg = _get_config(db)
|
||||||
if not cfg["server"]:
|
if not cfg["server"]:
|
||||||
return {"ok": False, "msg": "Serveur non configuré"}
|
return {"ok": False, "msg": "Serveur non configuré"}
|
||||||
|
use_ssl = cfg["server"].startswith("ldaps://")
|
||||||
|
log.debug("TEST connexion -> server=%s ssl=%s bind_dn=%s", cfg["server"], use_ssl, cfg["bind_dn"])
|
||||||
try:
|
try:
|
||||||
server = Server(cfg["server"], get_info=ALL, use_ssl=cfg["server"].startswith("ldaps://"))
|
server = Server(cfg["server"], get_info=ALL, use_ssl=use_ssl)
|
||||||
conn = Connection(server, user=cfg["bind_dn"], password=cfg["bind_pwd"], auto_bind=True)
|
conn = Connection(server, user=cfg["bind_dn"], password=cfg["bind_pwd"], auto_bind=True)
|
||||||
|
log.info("TEST connexion -> OK (result=%s)", getattr(conn, "result", None))
|
||||||
conn.unbind()
|
conn.unbind()
|
||||||
return {"ok": True, "msg": "Connexion réussie"}
|
return {"ok": True, "msg": "Connexion réussie"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
log.error("TEST connexion -> ECHEC: %s", e)
|
||||||
return {"ok": False, "msg": str(e)[:200]}
|
return {"ok": False, "msg": str(e)[:200]}
|
||||||
|
|||||||
@ -9,10 +9,11 @@ import base64
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any, List
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
from .realtime_audit_service import _resolve, _connect, PARAMIKO_OK
|
from .realtime_audit_service import _resolve, _connect, _candidate_targets, PARAMIKO_OK
|
||||||
|
|
||||||
log = logging.getLogger("patchcenter.patch_run")
|
log = logging.getLogger("patchcenter.patch_run")
|
||||||
|
|
||||||
@ -20,6 +21,14 @@ log = logging.getLogger("patchcenter.patch_run")
|
|||||||
TIMEOUT_DRYRUN = 60
|
TIMEOUT_DRYRUN = 60
|
||||||
TIMEOUT_UPDATE = 1800 # 30 min — yum update peut être long
|
TIMEOUT_UPDATE = 1800 # 30 min — yum update peut être long
|
||||||
|
|
||||||
|
# Streaming SSE : battement de cœur pendant les phases silencieuses de yum
|
||||||
|
# (refresh metadata / résolution de deps n'émettent rien sur stdout pendant
|
||||||
|
# plusieurs secondes → sans heartbeat, un proxy idle coupe la connexion).
|
||||||
|
HEARTBEAT_SECS = 10
|
||||||
|
# Durée max d'un stream avant abandon (le dry-run reste borné, l'update est long).
|
||||||
|
STREAM_MAX_DRYRUN = 300
|
||||||
|
STREAM_MAX_UPDATE = TIMEOUT_UPDATE
|
||||||
|
|
||||||
# Whitelist caractères autorisés dans un nom de paquet (anti-injection shell)
|
# Whitelist caractères autorisés dans un nom de paquet (anti-injection shell)
|
||||||
EXCLUDE_RE = re.compile(r"^[A-Za-z0-9._*\-/+]+$")
|
EXCLUDE_RE = re.compile(r"^[A-Za-z0-9._*\-/+]+$")
|
||||||
|
|
||||||
@ -55,15 +64,31 @@ def _exec(client, cmd: str, timeout: int) -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _open_ssh(hostname: str):
|
def _open_ssh(hostname: str):
|
||||||
target = _resolve(hostname)
|
"""Ouvre une session SSH en réutilisant la logique du check pré-patching :
|
||||||
if not target:
|
on itère les candidats DNS (_candidate_targets) et on tente _connect sur
|
||||||
return None, None, "DNS résolution impossible"
|
chacun. _connect gère PSMP / clé / password — il NE faut PAS filtrer sur
|
||||||
|
un TCP/22 direct (cf. _resolve), sinon tout hôte PSMP (port 22 non joignable
|
||||||
|
en direct) échoue avec un faux 'DNS résolution impossible'."""
|
||||||
if not PARAMIKO_OK:
|
if not PARAMIKO_OK:
|
||||||
return None, target, "paramiko non disponible côté serveur PatchCenter"
|
return None, None, "paramiko non disponible côté serveur PatchCenter"
|
||||||
client = _connect(target, hostname)
|
candidates = _candidate_targets(hostname)
|
||||||
if not client:
|
if not candidates:
|
||||||
return None, target, "Connexion SSH échouée"
|
return None, None, "Aucun candidat DNS pour cet hôte"
|
||||||
return client, target, None
|
errors: List[str] = []
|
||||||
|
tried: List[str] = []
|
||||||
|
for cand in candidates:
|
||||||
|
tried.append(cand)
|
||||||
|
errs: List[str] = []
|
||||||
|
try:
|
||||||
|
client = _connect(cand, hostname, errors=errs)
|
||||||
|
except Exception as e:
|
||||||
|
errs.append(f"{type(e).__name__}: {e}")
|
||||||
|
client = None
|
||||||
|
if client:
|
||||||
|
return client, cand, None
|
||||||
|
errors.append(f"{cand}: " + (" | ".join(errs) if errs else "échec"))
|
||||||
|
detail = "Connexion SSH échouée — " + " ; ".join(errors[-3:])
|
||||||
|
return None, (tried[0] if tried else None), detail
|
||||||
|
|
||||||
|
|
||||||
def extract_problem_packages(stdout: str) -> List[str]:
|
def extract_problem_packages(stdout: str) -> List[str]:
|
||||||
@ -180,14 +205,57 @@ def yum_stream_lines(hostname: str, excludes_raw, mode: str):
|
|||||||
yield {"type": "cmd", "cmd": cmd, "target": target,
|
yield {"type": "cmd", "cmd": cmd, "target": target,
|
||||||
"excludes": excludes, "hostname": hostname}
|
"excludes": excludes, "hostname": hostname}
|
||||||
full_lines: List[str] = []
|
full_lines: List[str] = []
|
||||||
|
max_secs = STREAM_MAX_UPDATE if mode == "update" else STREAM_MAX_DRYRUN
|
||||||
try:
|
try:
|
||||||
|
# Keepalive transport : évite que la session SSH meure pendant un long
|
||||||
|
# silence de yum (résolution de deps, téléchargements).
|
||||||
|
try:
|
||||||
|
client.get_transport().set_keepalive(HEARTBEAT_SECS)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
stdin, stdout, stderr = client.exec_command(cmd, get_pty=False)
|
stdin, stdout, stderr = client.exec_command(cmd, get_pty=False)
|
||||||
# Lecture ligne par ligne ; yum bufferise peu son stdout sur opérations longues
|
chan = stdout.channel
|
||||||
for line in iter(stdout.readline, ""):
|
# Lecture non bloquante : recv() rend la main toutes les secondes pour
|
||||||
ln = line.rstrip("\n")
|
# qu'on puisse émettre un heartbeat même si yum n'a rien écrit.
|
||||||
|
chan.settimeout(1.0)
|
||||||
|
buf = ""
|
||||||
|
start = time.monotonic()
|
||||||
|
last_beat = start
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
chunk = chan.recv(8192)
|
||||||
|
except socket.timeout:
|
||||||
|
chunk = None
|
||||||
|
if chunk:
|
||||||
|
buf += chunk.decode("utf-8", "replace")
|
||||||
|
while "\n" in buf:
|
||||||
|
ln, buf = buf.split("\n", 1)
|
||||||
|
ln = ln.rstrip("\r")
|
||||||
|
full_lines.append(ln)
|
||||||
|
yield {"type": "line", "data": ln}
|
||||||
|
last_beat = time.monotonic()
|
||||||
|
continue
|
||||||
|
if chunk == b"": # EOF : la commande distante est terminée
|
||||||
|
break
|
||||||
|
# chunk is None → timeout de lecture (yum silencieux)
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - start > max_secs:
|
||||||
|
yield {"type": "error",
|
||||||
|
"msg": f"timeout stream ({max_secs}s) — commande interrompue côté PatchCenter"}
|
||||||
|
try:
|
||||||
|
chan.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
if now - last_beat >= HEARTBEAT_SECS:
|
||||||
|
last_beat = now
|
||||||
|
yield {"type": "ping"}
|
||||||
|
# Flush d'une éventuelle dernière ligne sans \n final
|
||||||
|
if buf:
|
||||||
|
ln = buf.rstrip("\r")
|
||||||
full_lines.append(ln)
|
full_lines.append(ln)
|
||||||
yield {"type": "line", "data": ln}
|
yield {"type": "line", "data": ln}
|
||||||
rc = stdout.channel.recv_exit_status()
|
rc = chan.recv_exit_status()
|
||||||
# Détection de paquets problématiques si KO
|
# Détection de paquets problématiques si KO
|
||||||
problems: List[str] = []
|
problems: List[str] = []
|
||||||
if mode == "update" and rc != 0:
|
if mode == "update" and rc != 0:
|
||||||
|
|||||||
@ -391,7 +391,11 @@ function toggleDetails(){
|
|||||||
ev.onmessage = (m) => {
|
ev.onmessage = (m) => {
|
||||||
let j;
|
let j;
|
||||||
try { j = JSON.parse(m.data); } catch(e) { return; }
|
try { j = JSON.parse(m.data); } catch(e) { return; }
|
||||||
if (j.type === 'cmd') {
|
if (j.type === 'ping') {
|
||||||
|
// Heartbeat serveur pendant les phases silencieuses de yum :
|
||||||
|
// garde la connexion SSE vivante, rien à afficher.
|
||||||
|
return;
|
||||||
|
} else if (j.type === 'cmd') {
|
||||||
appendTerm(' # host : ' + (j.hostname||'') + ' (' + (j.target||'') + ')\n');
|
appendTerm(' # host : ' + (j.hostname||'') + ' (' + (j.target||'') + ')\n');
|
||||||
appendTerm(' # cmd : ' + (j.cmd||'') + '\n');
|
appendTerm(' # cmd : ' + (j.cmd||'') + '\n');
|
||||||
appendTerm(' # excludes (' + (j.excludes||[]).length + ')\n');
|
appendTerm(' # excludes (' + (j.excludes||[]).length + ')\n');
|
||||||
@ -498,6 +502,8 @@ function toggleDetails(){
|
|||||||
const ckOk = trs.filter(tr => tr._checkData && tr._checkData.overall === 'ok').length;
|
const ckOk = trs.filter(tr => tr._checkData && tr._checkData.overall === 'ok').length;
|
||||||
const snapAttempted = trs.some(tr => tr._snapData);
|
const snapAttempted = trs.some(tr => tr._snapData);
|
||||||
const snapOk = trs.filter(tr => tr._snapData && tr._snapData.ok).length;
|
const snapOk = trs.filter(tr => tr._snapData && tr._snapData.ok).length;
|
||||||
|
// snapEff : snapshot OK *ou* échec forcé (override) — autorise la suite sans snapshot
|
||||||
|
const snapEff = trs.filter(tr => tr._snapData && (tr._snapData.ok || tr._snapData.override)).length;
|
||||||
const dryAttempted = trs.some(tr => tr._dryData);
|
const dryAttempted = trs.some(tr => tr._dryData);
|
||||||
const dryOk = trs.filter(tr => tr._dryData && tr._dryData.ok).length;
|
const dryOk = trs.filter(tr => tr._dryData && tr._dryData.ok).length;
|
||||||
const preAttempted = trs.some(tr => tr._preData);
|
const preAttempted = trs.some(tr => tr._preData);
|
||||||
@ -528,7 +534,7 @@ function toggleDetails(){
|
|||||||
const snapState = deriveState(ckOk, snapAttempted, snapOk);
|
const snapState = deriveState(ckOk, snapAttempted, snapOk);
|
||||||
setBtnState(btnStep2, snapState); setStepState('snap', snapState);
|
setBtnState(btnStep2, snapState); setStepState('snap', snapState);
|
||||||
|
|
||||||
const dryState = deriveState(snapOk, dryAttempted, dryOk);
|
const dryState = deriveState(snapEff, dryAttempted, dryOk);
|
||||||
setBtnState(btnDryrun, dryState); setStepState('dry', dryState);
|
setBtnState(btnDryrun, dryState); setStepState('dry', dryState);
|
||||||
|
|
||||||
const preState = deriveState(dryOk, preAttempted, preOk);
|
const preState = deriveState(dryOk, preAttempted, preOk);
|
||||||
@ -587,12 +593,23 @@ function toggleDetails(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
summary.innerHTML += ' · Snapshot : ✓ ' + okCount + ' / ✗ ' + koCount;
|
summary.innerHTML += ' · Snapshot : ✓ ' + okCount + ' / ✗ ' + koCount;
|
||||||
|
// Échec snapshot : déblocage direct de l'étape suivante (sans confirmation)
|
||||||
|
if (koCount > 0) {
|
||||||
|
const failed = okTrs.filter(tr => tr._snapData && !tr._snapData.ok);
|
||||||
|
for (const tr of failed) {
|
||||||
|
tr._snapData.override = true;
|
||||||
|
const cell = tr.querySelector('.cell-snap');
|
||||||
|
if (cell) cell.innerHTML += ' <span class="text-cyber-yellow text-[10px]">(forcé)</span>';
|
||||||
|
const c3 = tr.querySelector('td:nth-child(3)');
|
||||||
|
termLine('⚠', 'snapshot en échec — poursuite forcée sans snapshot : ' + (c3 ? c3.textContent.trim() : tr.dataset.rowId));
|
||||||
|
}
|
||||||
|
}
|
||||||
refreshStepButtons();
|
refreshStepButtons();
|
||||||
});
|
});
|
||||||
|
|
||||||
btnDryrun.addEventListener('click', async () => {
|
btnDryrun.addEventListener('click', async () => {
|
||||||
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
|
const trs = Array.from(tbody.querySelectorAll('tr[data-row-id]'));
|
||||||
const targets = trs.filter(tr => tr._snapData && tr._snapData.ok);
|
const targets = trs.filter(tr => tr._snapData && (tr._snapData.ok || tr._snapData.override));
|
||||||
if (!targets.length) { alert('Aucun serveur avec snapshot OK'); return; }
|
if (!targets.length) { alert('Aucun serveur avec snapshot OK'); return; }
|
||||||
if (!confirm('Lancer dry-run yum (simulation) sur ' + targets.length + ' serveur(s) ?\nLog en temps réel dans le terminal.')) return;
|
if (!confirm('Lancer dry-run yum (simulation) sur ' + targets.length + ' serveur(s) ?\nLog en temps réel dans le terminal.')) return;
|
||||||
setBtnState(btnDryrun, 'running'); setStepState('dry', 'running');
|
setBtnState(btnDryrun, 'running'); setStepState('dry', 'running');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user