chore: update
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
// Package alerts : surveillance des pods des démos + notification (Discord,
|
||||
// Telegram) quand une plateforme tombe en erreur ou se rétablit.
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Notifier envoie un message d'alerte vers un canal externe.
|
||||
type Notifier interface {
|
||||
Notify(ctx context.Context, message string) error
|
||||
}
|
||||
|
||||
// MultiNotifier diffuse vers plusieurs canaux ; une erreur sur l'un
|
||||
// n'empêche pas la notification des autres.
|
||||
type MultiNotifier []Notifier
|
||||
|
||||
func (m MultiNotifier) Notify(ctx context.Context, message string) error {
|
||||
var errs []error
|
||||
for _, n := range m {
|
||||
if err := n.Notify(ctx, message); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// DiscordNotifier envoie le message sur un webhook Discord.
|
||||
type DiscordNotifier struct {
|
||||
WebhookURL string
|
||||
}
|
||||
|
||||
func (d *DiscordNotifier) Notify(ctx context.Context, message string) error {
|
||||
body, err := json.Marshal(map[string]string{"content": message})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.WebhookURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("discord: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("discord: statut %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TelegramNotifier envoie le message via un bot Telegram (sendMessage).
|
||||
type TelegramNotifier struct {
|
||||
BotToken string
|
||||
ChatID string
|
||||
}
|
||||
|
||||
func (t *TelegramNotifier) Notify(ctx context.Context, message string) error {
|
||||
endpoint := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", t.BotToken)
|
||||
body, err := json.Marshal(map[string]string{"chat_id": t.ChatID, "text": message})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("telegram: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("telegram: statut %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/omnex/control-plane/api/internal/auth"
|
||||
)
|
||||
|
||||
// GormRecipients construit, à partir de la table des utilisateurs, la liste
|
||||
// des notifiers actifs : chaque admin ayant renseigné un webhook Discord
|
||||
// et/ou un bot Telegram (dans son profil) reçoit les alertes. Interrogée à
|
||||
// chaque notification nécessaire — un admin qui change ses réglages est pris
|
||||
// en compte sans redémarrage de l'API.
|
||||
func GormRecipients(gdb *gorm.DB) func(ctx context.Context) ([]Notifier, error) {
|
||||
return func(ctx context.Context) ([]Notifier, error) {
|
||||
var admins []auth.User
|
||||
if err := gdb.WithContext(ctx).Where("role = ?", auth.RoleAdmin).Find(&admins).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var notifiers []Notifier
|
||||
for _, a := range admins {
|
||||
if a.AlertDiscordWebhookURL != nil && *a.AlertDiscordWebhookURL != "" {
|
||||
notifiers = append(notifiers, &DiscordNotifier{WebhookURL: *a.AlertDiscordWebhookURL})
|
||||
}
|
||||
if a.AlertTelegramBotToken != nil && *a.AlertTelegramBotToken != "" &&
|
||||
a.AlertTelegramChatID != nil && *a.AlertTelegramChatID != "" {
|
||||
notifiers = append(notifiers, &TelegramNotifier{
|
||||
BotToken: *a.AlertTelegramBotToken,
|
||||
ChatID: *a.AlertTelegramChatID,
|
||||
})
|
||||
}
|
||||
}
|
||||
return notifiers, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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"
|
||||
)
|
||||
|
||||
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). 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
|
||||
recipients func(ctx context.Context) ([]Notifier, error)
|
||||
namespaces func() ([]string, error)
|
||||
state map[podKey]string // dernière raison de panne connue ("" = sain)
|
||||
}
|
||||
|
||||
func NewWatcher(k8sClient *kubernetes.Clientset, recipients func(ctx context.Context) ([]Notifier, error), namespaces func() ([]string, error)) *Watcher {
|
||||
return &Watcher{
|
||||
k8sClient: k8sClient,
|
||||
recipients: recipients,
|
||||
namespaces: namespaces,
|
||||
state: make(map[podKey]string),
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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))
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) notify(ctx context.Context, notifier Notifier, key podKey, prev, reason string) {
|
||||
if notifier == nil {
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
if err := notifier.Notify(ctx, msg); err != nil {
|
||||
log.Printf("alerts: notification échouée: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user