Aligne la colonne servers.etat sur les valeurs iTop exactes au lieu des codes lowercase internes. Impact: - servers.etat stocke: Production, Implémentation, Stock, Obsolète, EOL, prêt, tests, Nouveau, A récupérer, Cassé, Cédé, En panne, Perdu, Recyclé, Occasion, A détruire, Volé - Remplace tous les 'production'/'obsolete'/'stock'/'eol'/'implementation' en WHERE/comparisons par les labels iTop verbatim (~10 fichiers) - Templates badges/filtres: valeurs + labels iTop - itop_service: maintient mapping iTop API internal code <-> DB label - import_sanef_*: norm_etat retourne la valeur iTop verbatim ou None (plus de fallback silencieux sur 'production') Ajoute: - tools/import_etat_itop.py : migration lowercase -> iTop + re-import CSV - tools/import_environnement.py : fix dry-run pour ADD COLUMN idempotent Supprime: - tools/fix_etat_extend.py (obsolete par import_etat_itop.py)
443 lines
19 KiB
Python
443 lines
19 KiB
Python
"""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
|