app/morning.py
Rama main · réplica de lectura del repositorio original.
"""Rapport matinal narratif — « la nuit s'est bien passée, voici pourquoi ».
Contrairement à /status (exhaustif) et /ops (exploitation), /matin choisit
les 5-8 lignes qui comptent après la nuit : verdicts vzdump/backups via
Healthchecks, saturation LVM avec tendance, alertes actives. Aucun LLM :
les chiffres viennent des API live (PVE + Healthchecks), la mise en
récit est déterministe.
"""
from __future__ import annotations
import os
import time
import httpx
from . import actions as actions_mod
HC_API_URL = os.environ.get("VEKTOR_HC_API_URL", "")
HC_READ_TOKEN = os.environ.get("VEKTOR_HC_READ_TOKEN", "")
PVE_API_URL = os.environ.get("PVE_API_URL", "https://PVE_HOST:8006")
PVE_API_TOKEN = os.environ.get("PVE_API_TOKEN", "")
PVE_VERIFY_SSL = os.environ.get("PVE_VERIFY_SSL", "false").lower() != "true"
PROM_URL = os.environ.get("VEKTOR_PROM_URL", "http://100.70.222.73:9090")
async def _hc_checks() -> list[dict] | None:
if not (HC_API_URL and HC_READ_TOKEN):
return None
try:
async with httpx.AsyncClient(timeout=8) as client:
response = await client.get(
f"{HC_API_URL}/api/v3/checks/",
headers={"X-Api-Key": HC_READ_TOKEN},
)
except httpx.HTTPError:
return None
if response.status_code != 200:
return None
try:
return response.json().get("checks") or []
except ValueError:
return None
async def _pve_json(path: str):
if not PVE_API_TOKEN:
return None
try:
async with httpx.AsyncClient(
timeout=40, verify=PVE_VERIFY_SSL,
headers={"Authorization": f"PVEAPIToken={PVE_API_TOKEN}"},
) as client:
response = await client.get(f"{PVE_API_URL}/api2/json{path}")
except httpx.HTTPError:
return None
if response.status_code != 200:
return None
return response.json().get("data")
def _parse_textfile(raw: str) -> tuple[dict[str, float], dict[str, float]]:
"""Extrait (pools_thin, vg_free) d'un .prom du textfile collector."""
pools: dict[str, float] = {}
vg_free: dict[str, float] = {}
for raw_line in raw.splitlines():
if raw_line.startswith("lvm_thin_data_percent{"):
try:
pool = raw_line.split('pool="')[1].split('"')[0]
pools[pool] = float(raw_line.rsplit(" ", 1)[1])
except (IndexError, ValueError):
continue
elif raw_line.startswith("lvm_vg_free_bytes{"):
try:
vg = raw_line.split('vg="')[1].split('"')[0]
vg_free[vg] = float(raw_line.rsplit(" ", 1)[1])
except (IndexError, ValueError):
continue
return pools, vg_free
async def _prom_query(query: str) -> float | None:
"""Une seule valeur depuis l'API Prometheus du VPS (None si KO)."""
try:
async with httpx.AsyncClient(timeout=8) as client:
response = await client.get(
f"{PROM_URL}/api/v1/query", params={"query": query}
)
except httpx.HTTPError:
return None
if response.status_code != 200:
return None
try:
result = response.json().get("data", {}).get("result", [])
if not result:
return None
return float(result[0]["value"][1])
except (ValueError, KeyError, IndexError):
return None
async def _vps_ram_lines() -> list[str]:
"""RAM VPS (dispo + swap) via node_exporter du VPS, avec tendance 24h.
Seuils alignés sur la règle RAM-GUARD : alerte push < 5G dispo (le modèle
LLM réserve ~5G lors de ses inférences, MC a Xmx10G)."""
avail = await _prom_query(
'node_memory_MemAvailable_bytes{job="vps-node-exporter"}'
)
if avail is None:
return []
swap_used = await _prom_query(
'node_memory_SwapTotal_bytes{job="vps-node-exporter"}'
' - node_memory_SwapFree_bytes{job="vps-node-exporter"}'
)
avail_min = await _prom_query(
'min_over_time(node_memory_MemAvailable_bytes'
'{job="vps-node-exporter"}[24h])'
)
avail_g = avail / 2**30
if avail_g < 5:
line = f"🟡 RAM VPS : {avail_g:.1f}G dispo — sous le seuil d'alerte (5G), surveille les gros process"
elif avail_g < 8:
line = f"🟡 RAM VPS : {avail_g:.1f}G dispo — marge modérée"
else:
line = f"🟢 RAM VPS : {avail_g:.1f}G dispo"
if swap_used is not None:
line += f", swap {swap_used / 2**30:.1f}G utilisés"
if avail_min is not None:
line += f" (min 24h : {avail_min / 2**30:.1f}G)"
return [line]
async def _lvm_lines() -> list[str]:
"""Thin pools + VG libres via node_exporter (textfile lvm_thin.prom)."""
lines: list[str] = []
try:
async with httpx.AsyncClient(timeout=8) as client:
response = await client.get("http://192.168.1.60:9100/metrics")
if response.status_code != 200:
return lines
pools, vg_free = _parse_textfile(response.text)
except (httpx.HTTPError, ValueError):
return lines
for pool, value in sorted(pools.items()):
if value >= 90:
lines.append(f"🔴 {pool} : {value:.0f}% — action requise (fstrim, purge, extension)")
elif value >= 85:
lines.append(f"🟡 {pool} : {value:.0f}% — au seuil d'alerte, à surveiller")
elif value >= 70:
lines.append(f"🟢 {pool} : {value:.0f}% — confortable, trim mensuel en place")
for vg, free in vg_free.items():
if free < 1024**3:
lines.append(f"🟡 VG {vg} : {(free / 2**30):.0f}G libres (extension impossible tant que ça ne bouge pas)")
return lines
async def _cis_line() -> list[str]:
"""Score CIS-L1 PegaProx via le canal d'actions (lecture seule, état et
détection de régression côté PVE). Silencieux si indisponible — mais
une RÉGRESSION est remontée en tête du rapport par morning_report."""
try:
raw = await actions_mod._execute("pegaprox_cis")
except Exception:
return False, []
lines: list[str] = []
regression = False
for raw_line in (raw or "").splitlines():
raw_line = raw_line.strip()
if "|" not in raw_line:
continue
tag, text = raw_line.split("|", 1)
if tag == "RED":
lines.append(text)
regression = True
elif tag == "OK" and text:
lines.append(text)
return regression, lines
async def morning_report() -> str:
"""Le récit du matin, chiffré et hiérarchisé."""
lines: list[str] = ["🌅 **Bonjour — la nuit en résumé**"]
# 1. Verdict de la nuit via Healthchecks (source de vérité)
checks = await _hc_checks()
down: list[str] = []
late: list[str] = []
backups_ok = None
if checks is None:
lines.append("🩺 Monitoring : injoignable — passe par /monitoring pour le détail")
else:
for check in checks:
name = str(check.get("name", "?"))
status = str(check.get("status", ""))
if status == "down":
down.append(name)
elif status == "late":
late.append(name)
if name in ("vzdump-backup", "backup-s3"):
backups_ok = backups_ok is not False and status in ("up", "late")
if not down and not late:
lines.append(f"🩺 Les {len(checks)} checks de la nuit sont verts (backup, coffre, sonde, API)")
else:
if down:
lines.append(f"🔴 Checks DOWN : {', '.join(down)}")
if late:
lines.append(f"🟡 En retard : {', '.join(late)}")
if backups_ok:
lines.append("💾 La chaîne vzdump + miroir GDrive a tourné")
# 2. Stockage PVE : thin pools au-dessus de 70%
lvm = await _lvm_lines()
if lvm:
lines.append("🗄️ Stockage PVE :")
lines.extend(f" {line}" for line in lvm)
# 2bis. RAM VPS : dispo + swap + tendance 24h (node_exporter VPS)
vps_ram = await _vps_ram_lines()
if vps_ram:
lines.append("")
lines.extend(vps_ram)
# 3. Compliance : score CIS-L1 PegaProx (régression = ligne + action)
cis_regression, cis_lines = await _cis_line()
if cis_lines:
if not cis_regression:
lines.append("")
lines.extend(cis_lines)
# 4. Conclusion adaptive
# 3bis. Une régression CIS passe AVANT la conclusion : impossible à rater.
if cis_regression:
lines.append("👉 Ouvre PegaProx (https://pegaprox.mayoraz-net.ch) : la section hardening détaille les contrôles en échec.")
if not down:
lines.append("✅ Rien à faire ce matin.")
else:
lines.append("👉 Commence par les checks DOWN ci-dessus, je peux diagnostiquer si tu me demandes.")
return "\n".join(lines)