Ctrl K
ComunidadGuías…

app/morning.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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)