"""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','Obsolète','EOL') 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','Obsolète','EOL')"] 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','Obsolète','EOL')"] 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','Obsolète','EOL') 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