patchcenter/app/services/correspondance_service.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

443 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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