Files
omnex/control-plane/api/internal/alerts/watcher.go
T
Xor290 2f6e5a953d
ci-api / test (push) Successful in 28m2s
chore: update
2026-08-08 18:27:52 +02:00

189 lines
6.0 KiB
Go

package alerts
import (
"context"
"fmt"
"log"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
metricsclient "k8s.io/metrics/pkg/client/clientset/versioned"
)
const pollInterval = 30 * time.Second
// Raisons de conteneur considérées comme des pannes (hors CrashLoopBackOff
// initial normal au tout premier démarrage d'un pod fraîchement créé — non
// distingué ici, le bruit au provisioning est acceptable).
var badWaitingReasons = map[string]bool{
"CrashLoopBackOff": true,
"ImagePullBackOff": true,
"ErrImagePull": true,
"CreateContainerConfigError": true,
"CreateContainerError": true,
"InvalidImageName": true,
"RunContainerError": true,
}
type podKey struct{ namespace, pod string }
// Watcher scrute périodiquement les pods des namespaces actifs et notifie
// tout changement d'état (sain -> panne, panne -> rétabli), ainsi que les
// dépassements de seuil CPU/mémoire (voir resources.go) et de remplissage
// des volumes (PVC). Les destinataires (recipients) sont résolus à chaque
// tick où une notification est nécessaire — chaque admin configure son
// propre webhook Discord / bot Telegram dans son profil (voir
// internal/profile), rien n'est figé au démarrage.
type Watcher struct {
k8sClient *kubernetes.Clientset
metricsClient *metricsclient.Clientset // optionnel : nil désactive les alertes CPU/mémoire (le reste du watcher continue de fonctionner)
recipients func(ctx context.Context) ([]Notifier, error)
namespaces func() ([]string, error)
state map[podKey]string // dernière raison de panne connue ("" = sain)
resourceHigh map[podKey]int // ticks consécutifs au-dessus du seuil CPU/mémoire
pvcHigh map[pvcKey]int // ticks consécutifs au-dessus du seuil de remplissage PVC
}
func NewWatcher(k8sClient *kubernetes.Clientset, metricsClient *metricsclient.Clientset, recipients func(ctx context.Context) ([]Notifier, error), namespaces func() ([]string, error)) *Watcher {
return &Watcher{
k8sClient: k8sClient,
metricsClient: metricsClient,
recipients: recipients,
namespaces: namespaces,
state: make(map[podKey]string),
resourceHigh: make(map[podKey]int),
pvcHigh: make(map[pvcKey]int),
}
}
// Run boucle jusqu'à annulation du contexte. À lancer dans une goroutine.
func (w *Watcher) Run(ctx context.Context) {
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
w.tick(ctx)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (w *Watcher) tick(ctx context.Context) {
nsList, err := w.namespaces()
if err != nil {
log.Printf("alerts: liste des démos actives indisponible: %v", err)
return
}
nsSet := make(map[string]bool, len(nsList))
for _, ns := range nsList {
nsSet[ns] = true
}
// Les destinataires ne sont chargés (requête DB) que si une notification
// s'avère nécessaire ce tick-ci, et une seule fois par tick.
var notifier Notifier
notifierLoaded := false
loadNotifier := func() Notifier {
if notifierLoaded {
return notifier
}
notifierLoaded = true
list, err := w.recipients(ctx)
if err != nil {
log.Printf("alerts: chargement des destinataires échoué: %v", err)
return nil
}
if len(list) == 0 {
return nil
}
notifier = MultiNotifier(list)
return notifier
}
seen := make(map[podKey]bool, len(w.state))
seenHigh := make(map[podKey]bool, len(w.resourceHigh))
nodeNames := make(map[string]bool) // nœuds hébergeant un pod Running d'un namespace actif (voir checkPVCUsage)
for _, ns := range nsList {
pods, err := w.k8sClient.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{})
if err != nil {
log.Printf("alerts: liste des pods de %s indisponible: %v", ns, err)
continue
}
for _, pod := range pods.Items {
key := podKey{namespace: ns, pod: pod.Name}
seen[key] = true
reason := problemReason(pod)
prev, known := w.state[key]
if reason != prev {
if known {
w.notify(ctx, loadNotifier(), key, prev, reason)
} else if reason != "" {
// Premier passage sur ce pod : on ne notifie que s'il est
// déjà en panne (sinon rien d'anormal à signaler).
w.notify(ctx, loadNotifier(), key, "", reason)
}
w.state[key] = reason
}
if pod.Status.Phase == corev1.PodRunning && pod.Spec.NodeName != "" {
nodeNames[pod.Spec.NodeName] = true
}
}
w.checkResourceUsage(ctx, loadNotifier, ns, pods.Items, seenHigh)
}
// Pods disparus (démo supprimée, rollout) : on oublie leur état pour ne
// pas polluer la mémoire indéfiniment.
for key := range w.state {
if !seen[key] {
delete(w.state, key)
}
}
w.clearResourceRecovered(ctx, loadNotifier, seenHigh)
w.checkPVCUsage(ctx, loadNotifier, nsSet, nodeNames)
}
func (w *Watcher) notify(ctx context.Context, notifier Notifier, key podKey, prev, reason string) {
var msg string
switch {
case prev == "" && reason != "":
msg = fmt.Sprintf("🔴 [%s] pod %s en erreur : %s", key.namespace, key.pod, reason)
case prev != "" && reason == "":
msg = fmt.Sprintf("🟢 [%s] pod %s rétabli", key.namespace, key.pod)
default:
msg = fmt.Sprintf("🔴 [%s] pod %s toujours en erreur : %s", key.namespace, key.pod, reason)
}
w.send(ctx, notifier, msg)
}
// problemReason retourne une raison de panne non vide si le pod est dans un
// état anormal (phase Failed, ou conteneur en CrashLoopBackOff/ImagePullBackOff/
// OOMKilled...), "" si tout va bien.
func problemReason(pod corev1.Pod) string {
if pod.DeletionTimestamp != nil {
return "" // suppression en cours, pas une panne
}
if pod.Status.Phase == corev1.PodFailed {
if pod.Status.Reason != "" {
return pod.Status.Reason
}
return "Failed"
}
for _, cs := range pod.Status.ContainerStatuses {
if waiting := cs.State.Waiting; waiting != nil && badWaitingReasons[waiting.Reason] {
return waiting.Reason
}
if term := cs.State.Terminated; term != nil && term.Reason == "OOMKilled" {
return "OOMKilled"
}
}
return ""
}