patchcenter/app/services/prereq_service.py
Khalid MOUTAOUAKIL 8277653c43 PatchCenter v2.0 — Initial commit
Modules: Dashboard, Serveurs, Campagnes, Planning, Specifiques, Settings, Users
Stack: FastAPI + Jinja2 + HTMX + Alpine.js + TailwindCSS + PostgreSQL
Features: Qualys sync, prereqs auto, planning annuel, server specifics, role-based access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:00:12 +02:00

292 lines
10 KiB
Python

"""Service prerequis — verification automatique des serveurs d'une campagne
Check basique (TCP) + check approfondi (SSH) si accessible"""
import socket
import paramiko
import os
from sqlalchemy import text
# Seuils espace disque (Mo)
DISK_ROOT_MIN_MB = 1200 # 1.2 Go minimum sur /
DISK_VAR_MIN_MB = 800 # 800 Mo minimum sur /var ou /var/log
# SSH config (pour check approfondi)
SSH_KEY = "/opt/patchcenter/keys/id_rsa_cybglobal.pem" # Copier la cle ici
SSH_USER = "cybsecope"
SSH_TIMEOUT = 10
DNS_SUFFIXES = ["", ".sanef.groupe", ".sanef-rec.fr", ".sanef.fr"]
def check_prereqs_campaign(db, campaign_id):
"""Verifie les prereqs de tous les serveurs pending d'une campagne."""
sessions = db.execute(text("""
SELECT ps.id, s.hostname, s.os_family, s.etat, s.licence_support,
s.machine_type, s.satellite_host, s.ssh_method,
d.code as domain_code, z.name as zone
FROM patch_sessions ps
JOIN servers s ON ps.server_id = s.id
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE ps.campaign_id = :cid AND ps.status = 'pending'
"""), {"cid": campaign_id}).fetchall()
checked = 0
for s in sessions:
result = _check_server(s)
_save_result(db, s.id, result)
checked += 1
auto_excluded = _auto_exclude(db, campaign_id)
db.commit()
return checked, auto_excluded
def _resolve_host(hostname):
"""Trouve un FQDN resolvable et joignable sur port 22"""
for suffix in DNS_SUFFIXES:
target = hostname + suffix
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
r = sock.connect_ex((target, 22))
sock.close()
if r == 0:
return target
except (socket.gaierror, socket.timeout, OSError):
continue
return None
def _ssh_connect(target):
"""Tente une connexion SSH par cle. Retourne le client ou None."""
if not os.path.exists(SSH_KEY):
return None
for loader in [paramiko.RSAKey.from_private_key_file, paramiko.Ed25519Key.from_private_key_file]:
try:
key = loader(SSH_KEY)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(target, port=22, username=SSH_USER, pkey=key,
timeout=SSH_TIMEOUT, look_for_keys=False, allow_agent=False)
return client
except Exception:
continue
return None
def _ssh_cmd(client, cmd, timeout=8):
"""Execute une commande SSH et retourne stdout"""
try:
stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout)
return stdout.read().decode("utf-8", errors="replace").strip()
except Exception:
return ""
def _check_disk(client):
"""Verifie l'espace disque via SSH. Retourne (root_mb, var_mb, ok)"""
output = _ssh_cmd(client, "df -BM --output=target,avail 2>/dev/null | grep -E '^/ |^/var' | head -5")
root_mb = None
var_mb = None
for line in output.split("\n"):
parts = line.split()
if len(parts) >= 2:
mount = parts[0]
try:
avail = int(parts[1].replace("M", ""))
except ValueError:
continue
if mount == "/":
root_mb = avail
elif mount in ("/var", "/var/log"):
var_mb = avail if var_mb is None or avail < (var_mb or 9999) else var_mb
# Si /var pas monte separement, il est dans /
if var_mb is None and root_mb is not None:
var_mb = root_mb
ok = True
if root_mb is not None and root_mb < DISK_ROOT_MIN_MB:
ok = False
if var_mb is not None and var_mb < DISK_VAR_MIN_MB:
ok = False
return root_mb, var_mb, ok
def _check_satellite_ssh(client):
"""Verifie la connectivite Satellite via SSH"""
output = _ssh_cmd(client, "subscription-manager identity 2>/dev/null | grep -i 'org' || echo 'not_registered'")
if "not_registered" in output or not output:
return "ko"
return "ok"
def _check_server(s):
"""Verifie un serveur. Check basique + approfondi si SSH OK."""
result = {
"ssh": "pending", "satellite": "pending", "rollback": None,
"disk_root_mb": None, "disk_var_mb": None, "disk_ok": None,
"eligible": True, "exclude_reason": None, "exclude_detail": None,
}
# 1. Eligibilite de base
if s.licence_support == 'eol':
result["eligible"] = False
result["exclude_reason"] = "eol"
result["exclude_detail"] = "Licence EOL — serveur non supporte"
return result
if s.etat != 'en_production':
result["eligible"] = False
result["exclude_reason"] = "non_patchable"
result["exclude_detail"] = f"Etat: {s.etat}"
return result
# 2. Connectivite TCP port 22
target = _resolve_host(s.hostname)
if not target:
result["ssh"] = "ko"
result["eligible"] = False
result["exclude_reason"] = "creneau_inadequat"
result["exclude_detail"] = f"Port 22 injoignable ({s.hostname})"
return result
result["ssh"] = "ok"
# 3. Rollback
if s.machine_type == 'vm':
result["rollback"] = "snapshot"
else:
result["rollback"] = "na"
# 4. Check approfondi via SSH (si cle dispo)
client = _ssh_connect(target)
if client:
try:
# Espace disque
root_mb, var_mb, disk_ok = _check_disk(client)
result["disk_root_mb"] = root_mb
result["disk_var_mb"] = var_mb
result["disk_ok"] = disk_ok
if not disk_ok:
detail_parts = []
if root_mb is not None and root_mb < DISK_ROOT_MIN_MB:
detail_parts.append(f"/ = {root_mb}Mo (min {DISK_ROOT_MIN_MB}Mo)")
if var_mb is not None and var_mb < DISK_VAR_MIN_MB:
detail_parts.append(f"/var = {var_mb}Mo (min {DISK_VAR_MIN_MB}Mo)")
result["eligible"] = False
result["exclude_reason"] = "creneau_inadequat"
result["exclude_detail"] = "Espace disque insuffisant: " + ", ".join(detail_parts)
# Satellite reel (Linux)
if s.os_family == 'linux':
result["satellite"] = _check_satellite_ssh(client)
else:
result["satellite"] = "na"
finally:
try:
client.close()
except Exception:
pass
else:
# Pas de connexion SSH approfondie — fallback sur les donnees en base
if s.os_family == 'linux':
result["satellite"] = "ok" if s.satellite_host else "ko"
else:
result["satellite"] = "na"
result["disk_ok"] = None # Non verifie
return result
def _save_result(db, session_id, result):
"""Sauvegarde les resultats prereqs"""
all_ok = (result["eligible"]
and result["ssh"] == "ok"
and result.get("disk_ok") is not False)
db.execute(text("""
UPDATE patch_sessions SET
prereq_ssh = :ssh, prereq_satellite = :sat,
rollback_method = COALESCE(rollback_method, :rb),
prereq_disk_root = :dr, prereq_disk_log = :dv,
prereq_disk_root_mb = :drm, prereq_disk_var_mb = :dvm,
prereq_disk_ok = :dok,
prereq_validated = :valid, prereq_date = now()
WHERE id = :id
"""), {
"id": session_id, "ssh": result["ssh"], "sat": result["satellite"],
"rb": result["rollback"],
"dr": result["disk_root_mb"], "dv": result["disk_var_mb"],
"drm": result["disk_root_mb"], "dvm": result["disk_var_mb"],
"dok": result["disk_ok"],
"valid": all_ok,
})
def _auto_exclude(db, campaign_id):
"""Exclut les serveurs non eligibles apres verification"""
non_eligible = db.execute(text("""
SELECT ps.id, s.hostname, s.licence_support, s.etat,
ps.prereq_ssh, ps.prereq_disk_ok
FROM patch_sessions ps
JOIN servers s ON ps.server_id = s.id
WHERE ps.campaign_id = :cid AND ps.status = 'pending'
AND (s.licence_support = 'eol'
OR s.etat != 'en_production'
OR ps.prereq_ssh = 'ko'
OR ps.prereq_disk_ok = false)
"""), {"cid": campaign_id}).fetchall()
count = 0
for s in non_eligible:
if s.licence_support == 'eol':
reason, detail = "eol", "Licence EOL — auto-exclu"
elif s.etat != 'en_production':
reason, detail = "non_patchable", f"Etat {s.etat} — auto-exclu"
elif s.prereq_disk_ok is False:
reason, detail = "creneau_inadequat", "Espace disque insuffisant — auto-exclu"
else:
reason, detail = "creneau_inadequat", "SSH injoignable — auto-exclu"
db.execute(text("""
UPDATE patch_sessions SET
status = 'excluded', exclusion_reason = :r,
exclusion_detail = :d, excluded_by = 'system', excluded_at = now()
WHERE id = :id
"""), {"id": s.id, "r": reason, "d": detail})
count += 1
if count > 0:
total = db.execute(text(
"SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status NOT IN ('excluded','cancelled')"
), {"cid": campaign_id}).scalar()
db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"),
{"c": total, "cid": campaign_id})
return count
def check_single_prereq(db, session_id):
"""Verifie les prereqs d'un seul serveur"""
s = db.execute(text("""
SELECT ps.id, s.hostname, s.os_family, s.etat, s.licence_support,
s.machine_type, s.satellite_host, s.ssh_method,
d.code as domain_code, z.name as zone
FROM patch_sessions ps
JOIN servers s ON ps.server_id = s.id
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE ps.id = :sid
"""), {"sid": session_id}).fetchone()
if not s:
return None
result = _check_server(s)
_save_result(db, session_id, result)
db.commit()
return result