From 2f6e5a953de28bc0b3c0cdaa5455d0738ccd5edb Mon Sep 17 00:00:00 2001 From: Xor290 Date: Sat, 8 Aug 2026 18:27:52 +0200 Subject: [PATCH] chore: update --- control-plane/api/cmd/api/main.go | 46 +++- control-plane/api/cmd/loadtest/main.go | 153 ++++++++++++++ .../api/internal/alerts/resources.go | 199 ++++++++++++++++++ .../api/internal/alerts/resources_test.go | 90 ++++++++ control-plane/api/internal/alerts/watcher.go | 57 +++-- control-plane/api/internal/auth/jwt_test.go | 89 ++++++++ .../api/internal/auth/password_test.go | 62 ++++++ .../api/internal/config/config_test.go | 102 +++++++++ .../api/internal/demos/helm_provisioner.go | 12 +- control-plane/api/internal/demos/models.go | 5 +- .../api/internal/httpx/security_test.go | 142 +++++++++++++ deploy/cert-manager/cluster-issuer.yml | 12 +- deploy/cert-manager/wildcard-certificate.yml | 6 +- .../ingressroute/templates/ingressroute.yaml | 2 +- deploy/chart-gestion/ingressroute/values.yaml | 4 +- 15 files changed, 939 insertions(+), 42 deletions(-) create mode 100644 control-plane/api/cmd/loadtest/main.go create mode 100644 control-plane/api/internal/alerts/resources.go create mode 100644 control-plane/api/internal/alerts/resources_test.go create mode 100644 control-plane/api/internal/auth/jwt_test.go create mode 100644 control-plane/api/internal/auth/password_test.go create mode 100644 control-plane/api/internal/config/config_test.go create mode 100644 control-plane/api/internal/httpx/security_test.go diff --git a/control-plane/api/cmd/api/main.go b/control-plane/api/cmd/api/main.go index 266a019..25bef60 100644 --- a/control-plane/api/cmd/api/main.go +++ b/control-plane/api/cmd/api/main.go @@ -3,9 +3,14 @@ package main import ( "context" + "errors" "log" + "net/http" "os" + "os/signal" "strings" + "syscall" + "time" "github.com/google/uuid" "github.com/redis/go-redis/v9" @@ -22,6 +27,13 @@ import ( "github.com/omnex/control-plane/api/internal/sub" ) +// shutdownGracePeriod : délai laissé aux requêtes HTTP en cours pour se +// terminer après réception de SIGTERM/SIGINT (ex. redéploiement Docker +// Compose) avant l'arrêt forcé. Ne couvre pas les provisionnings de démo en +// arrière-plan (goroutines fire-and-forget, voir demos.Service.Create) — +// seules les requêtes HTTP en vol sont drainées. +const shutdownGracePeriod = 15 * time.Second + // memUsers : store utilisateurs en mémoire (fallback dev sans base). type memUsers map[string]auth.User @@ -147,12 +159,12 @@ func main() { TTL: demos.TTL, }) - // Alerting : surveille les pods des démos actives, notifie Discord et/ou - // Telegram de chaque admin ayant configuré son propre canal (voir profil - // admin, /profile/alerts). Désactivé en mode mémoire (pas de réglages - // persistés à interroger). + // Alerting : surveille les pods des démos actives (état + seuils + // CPU/mémoire/PVC), notifie Discord et/ou Telegram de chaque admin ayant + // configuré son propre canal (voir profil admin, /profile/alerts). + // Désactivé en mode mémoire (pas de réglages persistés à interroger). if alertRecipients != nil { - watcher := alerts.NewWatcher(k8sClient, alertRecipients, func() ([]string, error) { + watcher := alerts.NewWatcher(k8sClient, metricsClient, alertRecipients, func() ([]string, error) { all, err := demoSvc.List() if err != nil { return nil, err @@ -182,9 +194,27 @@ func main() { } r := router.New(deps) - log.Printf("Omnex API sur %s (env=%s)", cfg.Addr, cfg.Env) - if err := r.Run(cfg.Addr); err != nil { - log.Fatal(err) + srv := &http.Server{Addr: cfg.Addr, Handler: r} + + go func() { + log.Printf("Omnex API sur %s (env=%s)", cfg.Addr, cfg.Env) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("serveur HTTP: %v", err) + } + }() + + // Arrêt propre : sur SIGTERM/SIGINT, cesse d'accepter de nouvelles + // connexions et laisse shutdownGracePeriod aux requêtes en cours pour se + // terminer (évite de couper une requête en vol lors d'un redéploiement). + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT) + <-stop + log.Printf("signal d'arrêt reçu, extinction en cours (jusqu'à %s)...", shutdownGracePeriod) + + ctx, cancel := context.WithTimeout(context.Background(), shutdownGracePeriod) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Printf("extinction forcée après délai: %v", err) } } diff --git a/control-plane/api/cmd/loadtest/main.go b/control-plane/api/cmd/loadtest/main.go new file mode 100644 index 0000000..00d44bc --- /dev/null +++ b/control-plane/api/cmd/loadtest/main.go @@ -0,0 +1,153 @@ +// cmd/loadtest : script de charge maison pour l'API control-plane (aucun +// outil externe type k6/locust — un seul binaire Go qui mesure latence et +// taux d'erreur sous charge concurrente sur un endpoint authentifié). +// +// Usage : +// +// go run ./cmd/loadtest --url https://control-plane.example \ +// --username admin --password *** \ +// --endpoint /api/v1/demos --concurrency 20 --duration 30s +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "sort" + "sync" + "sync/atomic" + "time" +) + +func main() { + baseURL := flag.String("url", "http://localhost:8080", "URL de base de l'API") + username := flag.String("username", "", "identifiant admin (pour /auth/login)") + password := flag.String("password", "", "mot de passe admin") + endpoint := flag.String("endpoint", "/api/v1/demos", "endpoint GET à charger (authentifié)") + concurrency := flag.Int("concurrency", 10, "nombre de workers concurrents") + duration := flag.Duration("duration", 30*time.Second, "durée du test") + flag.Parse() + + if *username == "" || *password == "" { + log.Fatal("--username et --password requis (compte admin)") + } + + token, err := login(*baseURL, *username, *password) + if err != nil { + log.Fatalf("login: %v", err) + } + log.Printf("connecté — %d workers pendant %s sur %s%s", *concurrency, *duration, *baseURL, *endpoint) + + var ( + mu sync.Mutex + latencies []time.Duration + okCount int64 + errCount int64 + ) + + ctx, cancel := context.WithTimeout(context.Background(), *duration) + defer cancel() + + client := &http.Client{Timeout: 10 * time.Second} + var wg sync.WaitGroup + for i := 0; i < *concurrency; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-ctx.Done(): + return + default: + } + + start := time.Now() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, *baseURL+*endpoint, nil) + if err != nil { + atomic.AddInt64(&errCount, 1) + continue + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := client.Do(req) + elapsed := time.Since(start) + if err != nil { + atomic.AddInt64(&errCount, 1) + continue + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + + if resp.StatusCode >= 400 { + atomic.AddInt64(&errCount, 1) + continue + } + atomic.AddInt64(&okCount, 1) + mu.Lock() + latencies = append(latencies, elapsed) + mu.Unlock() + } + }() + } + wg.Wait() + + report(latencies, okCount, errCount, *duration) +} + +func login(baseURL, username, password string) (string, error) { + body, err := json.Marshal(map[string]string{"username": username, "password": password}) + if err != nil { + return "", err + } + resp, err := http.Post(baseURL+"/api/v1/auth/login", "application/json", bytes.NewReader(body)) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("statut %d: %s", resp.StatusCode, b) + } + var out struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + return out.Token, nil +} + +func report(latencies []time.Duration, ok, errs int64, duration time.Duration) { + sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] }) + + percentile := func(p float64) time.Duration { + if len(latencies) == 0 { + return 0 + } + idx := int(float64(len(latencies)-1) * p) + return latencies[idx] + } + + total := ok + errs + var errRate float64 + if total > 0 { + errRate = 100 * float64(errs) / float64(total) + } + + fmt.Println() + fmt.Println("=== Résultat ===") + fmt.Printf("Durée : %s\n", duration) + fmt.Printf("Requêtes : %d (%.1f req/s)\n", total, float64(total)/duration.Seconds()) + fmt.Printf("Succès / Erreurs : %d / %d (%.2f%% d'erreurs)\n", ok, errs, errRate) + if len(latencies) > 0 { + fmt.Printf("Latence p50/p95/p99 : %s / %s / %s\n", percentile(0.50), percentile(0.95), percentile(0.99)) + fmt.Printf("Latence min/max : %s / %s\n", latencies[0], latencies[len(latencies)-1]) + } else { + fmt.Println("Aucune requête réussie — pas de statistiques de latence.") + } +} diff --git a/control-plane/api/internal/alerts/resources.go b/control-plane/api/internal/alerts/resources.go new file mode 100644 index 0000000..7d9affb --- /dev/null +++ b/control-plane/api/internal/alerts/resources.go @@ -0,0 +1,199 @@ +package alerts + +import ( + "context" + "encoding/json" + "fmt" + "log" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + // Seuils d'alerte, en % de la limite (CPU/mémoire) ou de la capacité (PVC). + resourceHighThresholdPct = 90 + pvcFullThresholdPct = 85 + // Nombre de ticks consécutifs au-dessus du seuil avant notification + // (pollInterval = 30s, soit ~90s) : absorbe les pics transitoires (déploiement + // en cours, backup Velero, migration...) sans notifier à tort. + consecutiveTicksBeforeAlert = 3 +) + +// send envoie un message si un notifier est disponible, journalise en cas d'échec. +func (w *Watcher) send(ctx context.Context, notifier Notifier, msg string) { + if notifier == nil { + return + } + if err := notifier.Notify(ctx, msg); err != nil { + log.Printf("alerts: notification échouée: %v", err) + } +} + +// podResourcePercents calcule le % d'usage CPU/mémoire d'un pod par rapport +// aux limites déclarées dans son spec. ok=false si un conteneur n'a pas de +// limite définie (pourcentage non significatif dans ce cas — on ne peut pas +// dire à quel point il est "plein"). +func podResourcePercents(pod corev1.Pod, cpuMilli, memBytes int64) (cpuPct, memPct int, ok bool) { + var cpuLimit, memLimit int64 + for _, c := range pod.Spec.Containers { + cl := c.Resources.Limits.Cpu() + ml := c.Resources.Limits.Memory() + if cl.IsZero() || ml.IsZero() { + return 0, 0, false + } + cpuLimit += cl.MilliValue() + memLimit += ml.Value() + } + if cpuLimit == 0 || memLimit == 0 { + return 0, 0, false + } + return int(cpuMilli * 100 / cpuLimit), int(memBytes * 100 / memLimit), true +} + +// checkResourceUsage alerte si un pod dépasse resourceHighThresholdPct de CPU +// ou de mémoire pendant consecutiveTicksBeforeAlert ticks d'affilée. seenHigh +// est alimenté pour la purge de récupération en fin de tick (voir +// clearResourceRecovered). +func (w *Watcher) checkResourceUsage(ctx context.Context, loadNotifier func() Notifier, ns string, pods []corev1.Pod, seenHigh map[podKey]bool) { + if w.metricsClient == nil { + return + } + list, err := w.metricsClient.MetricsV1beta1().PodMetricses(ns).List(ctx, metav1.ListOptions{}) + if err != nil { + return // metrics-server temporairement indisponible : pas une panne à notifier + } + usage := make(map[string][2]int64, len(list.Items)) // pod -> [cpuMilli, memBytes] + for _, m := range list.Items { + var cpu, mem int64 + for _, c := range m.Containers { + cpu += c.Usage.Cpu().MilliValue() + mem += c.Usage.Memory().Value() + } + usage[m.Name] = [2]int64{cpu, mem} + } + + for _, pod := range pods { + u, ok := usage[pod.Name] + if !ok { + continue + } + cpuPct, memPct, ok := podResourcePercents(pod, u[0], u[1]) + if !ok || (cpuPct < resourceHighThresholdPct && memPct < resourceHighThresholdPct) { + continue + } + key := podKey{namespace: ns, pod: pod.Name} + seenHigh[key] = true + w.resourceHigh[key]++ + if w.resourceHigh[key] == consecutiveTicksBeforeAlert { + w.send(ctx, loadNotifier(), fmt.Sprintf( + "🟠 [%s] pod %s : usage élevé — CPU %d%%, mémoire %d%% (limite)", ns, pod.Name, cpuPct, memPct)) + } + } +} + +// clearResourceRecovered notifie le retour sous le seuil CPU/mémoire et purge +// l'état des pods qui ne sont plus au-dessus du seuil ce tick-ci. +func (w *Watcher) clearResourceRecovered(ctx context.Context, loadNotifier func() Notifier, seenHigh map[podKey]bool) { + for key, count := range w.resourceHigh { + if seenHigh[key] { + continue + } + if count >= consecutiveTicksBeforeAlert { + w.send(ctx, loadNotifier(), fmt.Sprintf( + "🟢 [%s] pod %s : usage CPU/mémoire revenu à la normale", key.namespace, key.pod)) + } + delete(w.resourceHigh, key) + } +} + +type pvcKey struct{ namespace, pvc string } + +// kubeletSummary : sous-ensemble de l'API kubelet /stats/summary (voir +// k8s.io/kubelet/pkg/apis/stats/v1alpha1.Summary sur +// github.com/kubernetes/kubelet) utilisé pour lire l'usage réel des volumes +// (PVC) montés sur un nœud — aucune métrique de ce type n'est exposée par +// metrics-server, qui ne couvre que CPU/mémoire. Nécessite la permission +// "get" sur le sous-type "nodes/proxy" pour le compte utilisé par l'API +// (aucun ClusterRole scoping n'existe dans ce repo à ce jour — vérifier si +// un jour le control-plane tourne avec un ServiceAccount restreint). +type kubeletSummary struct { + Pods []struct { + PodRef struct { + Namespace string `json:"namespace"` + } `json:"podRef"` + Volume []struct { + CapacityBytes *uint64 `json:"capacityBytes,omitempty"` + UsedBytes *uint64 `json:"usedBytes,omitempty"` + PVCRef *struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + } `json:"pvcRef,omitempty"` + } `json:"volume,omitempty"` + } `json:"pods"` +} + +// fetchNodeSummary récupère les stats kubelet d'un nœud via le proxy API +// server (équivalent de `kubectl get --raw /api/v1/nodes//proxy/stats/summary`). +func (w *Watcher) fetchNodeSummary(ctx context.Context, nodeName string) (*kubeletSummary, error) { + raw, err := w.k8sClient.CoreV1().RESTClient().Get(). + Resource("nodes"). + Name(nodeName). + SubResource("proxy"). + Suffix("stats/summary"). + Do(ctx).Raw() + if err != nil { + return nil, err + } + var summary kubeletSummary + if err := json.Unmarshal(raw, &summary); err != nil { + return nil, err + } + return &summary, nil +} + +// checkPVCUsage alerte si un volume (PVC) d'un namespace actif dépasse +// pvcFullThresholdPct de remplissage pendant consecutiveTicksBeforeAlert +// ticks. nodeNames : nœuds hébergeant au moins un pod Running d'un namespace +// actif (voir tick) — on n'interroge que ceux-là, pas tout le cluster. +func (w *Watcher) checkPVCUsage(ctx context.Context, loadNotifier func() Notifier, nsSet map[string]bool, nodeNames map[string]bool) { + seenHigh := make(map[pvcKey]bool) + for node := range nodeNames { + summary, err := w.fetchNodeSummary(ctx, node) + if err != nil { + log.Printf("alerts: stats kubelet indisponibles pour le nœud %s: %v", node, err) + continue + } + for _, pod := range summary.Pods { + if !nsSet[pod.PodRef.Namespace] { + continue + } + for _, vol := range pod.Volume { + if vol.PVCRef == nil || vol.CapacityBytes == nil || vol.UsedBytes == nil || *vol.CapacityBytes == 0 { + continue + } + pct := int(*vol.UsedBytes * 100 / *vol.CapacityBytes) + if pct < pvcFullThresholdPct { + continue + } + key := pvcKey{namespace: vol.PVCRef.Namespace, pvc: vol.PVCRef.Name} + seenHigh[key] = true + w.pvcHigh[key]++ + if w.pvcHigh[key] == consecutiveTicksBeforeAlert { + w.send(ctx, loadNotifier(), fmt.Sprintf( + "🟠 [%s] volume %s rempli à %d%%", key.namespace, key.pvc, pct)) + } + } + } + } + for key, count := range w.pvcHigh { + if seenHigh[key] { + continue + } + if count >= consecutiveTicksBeforeAlert { + w.send(ctx, loadNotifier(), fmt.Sprintf( + "🟢 [%s] volume %s repassé sous %d%% de remplissage", key.namespace, key.pvc, pvcFullThresholdPct)) + } + delete(w.pvcHigh, key) + } +} diff --git a/control-plane/api/internal/alerts/resources_test.go b/control-plane/api/internal/alerts/resources_test.go new file mode 100644 index 0000000..a22f812 --- /dev/null +++ b/control-plane/api/internal/alerts/resources_test.go @@ -0,0 +1,90 @@ +package alerts + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" +) + +func podWithLimits(cpuLimit, memLimit string) corev1.Pod { + return corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse(cpuLimit), + corev1.ResourceMemory: resource.MustParse(memLimit), + }, + }, + }, + }, + }, + } +} + +func TestPodResourcePercents(t *testing.T) { + pod := podWithLimits("500m", "256Mi") + + cpuPct, memPct, ok := podResourcePercents(pod, 450, 256*1024*1024) + if !ok { + t.Fatal("ok = false, want true") + } + if cpuPct != 90 { + t.Errorf("cpuPct = %d, want 90", cpuPct) + } + if memPct != 100 { + t.Errorf("memPct = %d, want 100", memPct) + } +} + +func TestPodResourcePercents_NoLimitsDefined(t *testing.T) { + pod := corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{}}, + }, + } + + _, _, ok := podResourcePercents(pod, 100, 100) + if ok { + t.Error("ok = true, want false when container has no resource limits") + } +} + +func TestPodResourcePercents_MultiContainerSumsLimits(t *testing.T) { + pod := corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("250m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + }, + }, + { + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("250m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + }, + }, + }, + }, + } + + // Limite totale : 500m CPU, 256Mi mémoire. + cpuPct, memPct, ok := podResourcePercents(pod, 250, 128*1024*1024) + if !ok { + t.Fatal("ok = false, want true") + } + if cpuPct != 50 { + t.Errorf("cpuPct = %d, want 50", cpuPct) + } + if memPct != 50 { + t.Errorf("memPct = %d, want 50", memPct) + } +} diff --git a/control-plane/api/internal/alerts/watcher.go b/control-plane/api/internal/alerts/watcher.go index 42b7a97..420f121 100644 --- a/control-plane/api/internal/alerts/watcher.go +++ b/control-plane/api/internal/alerts/watcher.go @@ -9,6 +9,7 @@ import ( 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 @@ -29,23 +30,31 @@ var badWaitingReasons = map[string]bool{ 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. +// 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 - recipients func(ctx context.Context) ([]Notifier, error) - namespaces func() ([]string, error) - state map[podKey]string // dernière raison de panne connue ("" = sain) + 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, recipients func(ctx context.Context) ([]Notifier, error), namespaces func() ([]string, error)) *Watcher { +func NewWatcher(k8sClient *kubernetes.Clientset, metricsClient *metricsclient.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), + k8sClient: k8sClient, + metricsClient: metricsClient, + recipients: recipients, + namespaces: namespaces, + state: make(map[podKey]string), + resourceHigh: make(map[podKey]int), + pvcHigh: make(map[pvcKey]int), } } @@ -69,6 +78,10 @@ func (w *Watcher) tick(ctx context.Context) { 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. @@ -92,6 +105,9 @@ func (w *Watcher) tick(ctx context.Context) { } 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 { @@ -114,7 +130,13 @@ func (w *Watcher) tick(ctx context.Context) { } 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 @@ -124,12 +146,11 @@ func (w *Watcher) tick(ctx context.Context) { 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) { - if notifier == nil { - return - } var msg string switch { case prev == "" && reason != "": @@ -139,9 +160,7 @@ func (w *Watcher) notify(ctx context.Context, notifier Notifier, key podKey, pre 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) - } + w.send(ctx, notifier, msg) } // problemReason retourne une raison de panne non vide si le pod est dans un diff --git a/control-plane/api/internal/auth/jwt_test.go b/control-plane/api/internal/auth/jwt_test.go new file mode 100644 index 0000000..83c7b94 --- /dev/null +++ b/control-plane/api/internal/auth/jwt_test.go @@ -0,0 +1,89 @@ +package auth + +import ( + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func TestIssuer_IssueVerify_Roundtrip(t *testing.T) { + issuer := NewIssuer([]byte("test-secret-32-bytes-minimum!!!"), time.Hour) + + token, err := issuer.Issue("user-1", "alice", RoleAdmin, "session-abc") + if err != nil { + t.Fatalf("Issue: %v", err) + } + + claims, err := issuer.Verify(token) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if claims.Subject != "user-1" { + t.Errorf("Subject = %q, want %q", claims.Subject, "user-1") + } + if claims.Username != "alice" { + t.Errorf("Username = %q, want %q", claims.Username, "alice") + } + if claims.Role != RoleAdmin { + t.Errorf("Role = %q, want %q", claims.Role, RoleAdmin) + } + if claims.SessionID != "session-abc" { + t.Errorf("SessionID = %q, want %q", claims.SessionID, "session-abc") + } +} + +func TestIssuer_Verify_ExpiredToken(t *testing.T) { + issuer := NewIssuer([]byte("test-secret-32-bytes-minimum!!!"), -time.Hour) + + token, err := issuer.Issue("user-1", "alice", RoleClient, "session-abc") + if err != nil { + t.Fatalf("Issue: %v", err) + } + + if _, err := issuer.Verify(token); err != ErrInvalidToken { + t.Errorf("Verify(expired) error = %v, want ErrInvalidToken", err) + } +} + +func TestIssuer_Verify_WrongSecret(t *testing.T) { + issuer := NewIssuer([]byte("test-secret-32-bytes-minimum!!!"), time.Hour) + token, err := issuer.Issue("user-1", "alice", RoleClient, "session-abc") + if err != nil { + t.Fatalf("Issue: %v", err) + } + + other := NewIssuer([]byte("different-secret-32-bytes-min!!"), time.Hour) + if _, err := other.Verify(token); err != ErrInvalidToken { + t.Errorf("Verify(wrong secret) error = %v, want ErrInvalidToken", err) + } +} + +func TestIssuer_Verify_RejectsNoneAlgorithm(t *testing.T) { + issuer := NewIssuer([]byte("test-secret-32-bytes-minimum!!!"), time.Hour) + + claims := Claims{ + Role: RoleAdmin, + Username: "attacker", + SessionID: "forged", + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "user-1", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + } + forged, err := jwt.NewWithClaims(jwt.SigningMethodNone, claims).SignedString(jwt.UnsafeAllowNoneSignatureType) + if err != nil { + t.Fatalf("forge none-alg token: %v", err) + } + + if _, err := issuer.Verify(forged); err != ErrInvalidToken { + t.Errorf("Verify(none-alg token) error = %v, want ErrInvalidToken (alg-confusion protection)", err) + } +} + +func TestIssuer_Verify_MalformedToken(t *testing.T) { + issuer := NewIssuer([]byte("test-secret-32-bytes-minimum!!!"), time.Hour) + if _, err := issuer.Verify("not.a.token"); err != ErrInvalidToken { + t.Errorf("Verify(malformed) error = %v, want ErrInvalidToken", err) + } +} diff --git a/control-plane/api/internal/auth/password_test.go b/control-plane/api/internal/auth/password_test.go new file mode 100644 index 0000000..0da1578 --- /dev/null +++ b/control-plane/api/internal/auth/password_test.go @@ -0,0 +1,62 @@ +package auth + +import "testing" + +func TestHashPassword_VerifyPassword_Roundtrip(t *testing.T) { + hash, err := HashPassword("correct horse battery staple") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + + ok, err := VerifyPassword("correct horse battery staple", hash) + if err != nil { + t.Fatalf("VerifyPassword: %v", err) + } + if !ok { + t.Error("VerifyPassword = false, want true for correct password") + } +} + +func TestVerifyPassword_WrongPassword(t *testing.T) { + hash, err := HashPassword("correct horse battery staple") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + + ok, err := VerifyPassword("wrong password", hash) + if err != nil { + t.Fatalf("VerifyPassword: %v", err) + } + if ok { + t.Error("VerifyPassword = true, want false for wrong password") + } +} + +func TestHashPassword_UniqueSaltPerCall(t *testing.T) { + hash1, err := HashPassword("same password") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + hash2, err := HashPassword("same password") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if hash1 == hash2 { + t.Error("HashPassword produced identical hashes for two calls — salt is not random") + } +} + +func TestVerifyPassword_InvalidHashFormat(t *testing.T) { + cases := []string{ + "", + "not-a-hash", + "$argon2id$v=19$m=65536,t=1,p=4$onlyfiveparts", + "$bcrypt$v=19$m=65536,t=1,p=4$c2FsdA$aGFzaA", + } + for _, encoded := range cases { + _, err := VerifyPassword("anything", encoded) + if err != ErrInvalidHash { + t.Errorf("VerifyPassword(%q) error = %v, want ErrInvalidHash", encoded, err) + } + } +} diff --git a/control-plane/api/internal/config/config_test.go b/control-plane/api/internal/config/config_test.go new file mode 100644 index 0000000..5f74bb0 --- /dev/null +++ b/control-plane/api/internal/config/config_test.go @@ -0,0 +1,102 @@ +package config + +import "testing" + +func TestLoad_MissingJWTSecret(t *testing.T) { + t.Setenv("OMNEX_JWT_SECRET", "") + if _, err := Load(); err == nil { + t.Error("Load() error = nil, want error for missing OMNEX_JWT_SECRET") + } +} + +func TestLoad_JWTSecretTooShort(t *testing.T) { + t.Setenv("OMNEX_JWT_SECRET", "short") + if _, err := Load(); err == nil { + t.Error("Load() error = nil, want error for JWT secret < 32 bytes") + } +} + +func TestLoad_DevDefaults(t *testing.T) { + t.Setenv("OMNEX_JWT_SECRET", "12345678901234567890123456789012") + t.Setenv("OMNEX_ENV", "") + t.Setenv("OMNEX_DATABASE_URL", "") + t.Setenv("OMNEX_REDIS_URL", "") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v, want nil", err) + } + if cfg.Env != "dev" { + t.Errorf("Env = %q, want %q", cfg.Env, "dev") + } + if cfg.Addr != ":8080" { + t.Errorf("Addr = %q, want %q", cfg.Addr, ":8080") + } + if cfg.Secure() { + t.Error("Secure() = true in dev, want false") + } +} + +func TestLoad_ProdRequiresDatabaseAndRedis(t *testing.T) { + t.Setenv("OMNEX_JWT_SECRET", "12345678901234567890123456789012") + t.Setenv("OMNEX_ENV", "prod") + t.Setenv("OMNEX_DATABASE_URL", "") + t.Setenv("OMNEX_REDIS_URL", "") + + if _, err := Load(); err == nil { + t.Error("Load() error = nil, want error for prod without DATABASE_URL/REDIS_URL") + } +} + +func TestLoad_ProdRequiresRedisEvenWithDatabase(t *testing.T) { + t.Setenv("OMNEX_JWT_SECRET", "12345678901234567890123456789012") + t.Setenv("OMNEX_ENV", "prod") + t.Setenv("OMNEX_DATABASE_URL", "postgres://localhost/omnex") + t.Setenv("OMNEX_REDIS_URL", "") + + if _, err := Load(); err == nil { + t.Error("Load() error = nil, want error for prod without REDIS_URL") + } +} + +func TestLoad_ProdSucceedsWithAllRequired(t *testing.T) { + t.Setenv("OMNEX_JWT_SECRET", "12345678901234567890123456789012") + t.Setenv("OMNEX_ENV", "prod") + t.Setenv("OMNEX_DATABASE_URL", "postgres://localhost/omnex") + t.Setenv("OMNEX_REDIS_URL", "redis://localhost:6379/0") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v, want nil", err) + } + if !cfg.Secure() { + t.Error("Secure() = false in prod, want true") + } +} + +func TestSplitCSV(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"", nil}, + {"a", []string{"a"}}, + {"a,b,c", []string{"a", "b", "c"}}, + {"a,,c", []string{"a", "c"}}, + {"a,b,", []string{"a", "b"}}, + {",a,b", []string{"a", "b"}}, + } + for _, tc := range cases { + got := splitCSV(tc.in) + if len(got) != len(tc.want) { + t.Errorf("splitCSV(%q) = %v, want %v", tc.in, got, tc.want) + continue + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("splitCSV(%q) = %v, want %v", tc.in, got, tc.want) + break + } + } + } +} diff --git a/control-plane/api/internal/demos/helm_provisioner.go b/control-plane/api/internal/demos/helm_provisioner.go index 64ac647..2943339 100644 --- a/control-plane/api/internal/demos/helm_provisioner.go +++ b/control-plane/api/internal/demos/helm_provisioner.go @@ -691,6 +691,7 @@ func (h *HelmProvisioner) installChart(namespace, chartName string, values map[s "--namespace", namespace, "--values", valuesFile, "--wait", + "--atomic", "--timeout", "5m", } if err := h.runHelm(args...); err != nil { @@ -711,6 +712,7 @@ func (h *HelmProvisioner) upgradeInstallChart(namespace, release, chartName stri "--install", "--namespace", namespace, "--wait", + "--atomic", "--timeout", "5m", } if values != nil { @@ -1096,10 +1098,12 @@ var componentResourceLimits = map[string]struct { "frontend": {cpuMilli: 200, memMi: 128}, "postgresql": {cpuMilli: 1000, memMi: 1024}, "redis": {cpuMilli: 500, memMi: 512}, + "lbtelegram": {cpuMilli: 500, memMi: 256}, } -// componentKey identifie le composant (backend/frontend/postgresql/redis) à -// partir du nom de pod généré par Helm (ex: "demo-xxx-backend-...-6d59f4-abcde"). +// componentKey identifie le composant (backend/frontend/postgresql/redis/ +// lbtelegram) à partir du nom de pod généré par Helm (ex: +// "demo-xxx-backend-...-6d59f4-abcde"). func componentKey(podName string) string { switch { case strings.Contains(podName, "backend"): @@ -1110,6 +1114,8 @@ func componentKey(podName string) string { return "postgresql" case strings.Contains(podName, "redis"): return "redis" + case strings.Contains(podName, "lbtelegram"): + return "lbtelegram" default: return "" } @@ -1172,6 +1178,8 @@ func (h *HelmProvisioner) GetResourceState( state.DB = cs case "redis": state.DBM = cs + case "lbtelegram": + state.LB = cs } } diff --git a/control-plane/api/internal/demos/models.go b/control-plane/api/internal/demos/models.go index f552705..b5725d8 100644 --- a/control-plane/api/internal/demos/models.go +++ b/control-plane/api/internal/demos/models.go @@ -40,10 +40,13 @@ type ComponentState struct { MemoryLimitMi int64 `json:"memory_limit_mi"` // limite mémoire configurée, en Mi } -// ResourceState : état live des 4 composants d'une démo. +// ResourceState : état live des composants d'une démo. LB (load-balancer +// Telegram) est optionnel : Phase reste "" si le chart lbtelegram n'est pas +// installé pour cette démo (voir ProvisionConfig.LBTelegramEnabled). type ResourceState struct { API ComponentState `json:"api"` Web ComponentState `json:"web"` DB ComponentState `json:"db"` DBM ComponentState `json:"dbm"` + LB ComponentState `json:"lb"` } diff --git a/control-plane/api/internal/httpx/security_test.go b/control-plane/api/internal/httpx/security_test.go new file mode 100644 index 0000000..be9e509 --- /dev/null +++ b/control-plane/api/internal/httpx/security_test.go @@ -0,0 +1,142 @@ +package httpx + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func TestSecurityHeaders(t *testing.T) { + r := gin.New() + r.Use(SecurityHeaders()) + r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + r.ServeHTTP(w, req) + + want := map[string]string{ + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", + "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'", + "Strict-Transport-Security": "max-age=63072000; includeSubDomains", + } + for header, expected := range want { + if got := w.Header().Get(header); got != expected { + t.Errorf("header %s = %q, want %q", header, got, expected) + } + } +} + +func TestCORS_AllowedOrigin(t *testing.T) { + r := gin.New() + r.Use(CORS([]string{"https://omnex.example"})) + r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Origin", "https://omnex.example") + r.ServeHTTP(w, req) + + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://omnex.example" { + t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, "https://omnex.example") + } + if got := w.Header().Get("Vary"); got != "Origin" { + t.Errorf("Vary = %q, want %q", got, "Origin") + } +} + +func TestCORS_DisallowedOrigin(t *testing.T) { + r := gin.New() + r.Use(CORS([]string{"https://omnex.example"})) + r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Origin", "https://evil.example") + r.ServeHTTP(w, req) + + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("Access-Control-Allow-Origin = %q, want empty for disallowed origin", got) + } +} + +func TestCORS_PreflightOptions(t *testing.T) { + r := gin.New() + r.Use(CORS([]string{"https://omnex.example"})) + r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodOptions, "/", nil) + req.Header.Set("Origin", "https://omnex.example") + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Errorf("OPTIONS status = %d, want %d", w.Code, http.StatusNoContent) + } +} + +func TestRateLimit_AllowsWithinBurst(t *testing.T) { + r := gin.New() + r.Use(RateLimit(1, 3)) + r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) }) + + for i := 0; i < 3; i++ { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.1:1234" + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("request %d: status = %d, want %d", i, w.Code, http.StatusOK) + } + } +} + +func TestRateLimit_BlocksOverBurst(t *testing.T) { + r := gin.New() + r.Use(RateLimit(1, 2)) + r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) }) + + for i := 0; i < 2; i++ { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.2:1234" + r.ServeHTTP(w, req) + } + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.2:1234" + r.ServeHTTP(w, req) + if w.Code != http.StatusTooManyRequests { + t.Errorf("status = %d, want %d", w.Code, http.StatusTooManyRequests) + } +} + +func TestRateLimit_PerIPIsolation(t *testing.T) { + r := gin.New() + r.Use(RateLimit(1, 1)) + r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) }) + + // Épuise le burst pour l'IP A. + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.3:1234" + r.ServeHTTP(w, req) + + // L'IP B doit rester indépendante. + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/", nil) + req2.RemoteAddr = "10.0.0.4:1234" + r.ServeHTTP(w2, req2) + if w2.Code != http.StatusOK { + t.Errorf("IP B status = %d, want %d (isolation from IP A)", w2.Code, http.StatusOK) + } +} diff --git a/deploy/cert-manager/cluster-issuer.yml b/deploy/cert-manager/cluster-issuer.yml index 39b15c6..94a9d2e 100644 --- a/deploy/cert-manager/cluster-issuer.yml +++ b/deploy/cert-manager/cluster-issuer.yml @@ -3,7 +3,7 @@ # helm install cert-manager jetstack/cert-manager \ # --namespace cert-manager --create-namespace --set crds.enabled=true # -# Un certificat wildcard (*.demo.demo-uber.club) ne peut être émis que via un +# Un certificat wildcard (*.omnex.sbs) ne peut être émis que via un # challenge DNS-01 (le challenge HTTP-01 ne supporte pas les wildcards) : il # faut donc que cert-manager puisse créer des enregistrements TXT chez votre # fournisseur DNS. Exemple ci-dessous avec Cloudflare (le plus courant) — @@ -16,7 +16,7 @@ metadata: namespace: cert-manager type: Opaque stringData: - # Token Cloudflare avec la permission "Zone:DNS:Edit" sur la zone demo-uber.club. + # Token Cloudflare avec la permission "Zone:DNS:Edit" sur la zone omnex.sbs. # https://dash.cloudflare.com/profile/api-tokens api-token: "" # À REMPLIR avant application @@ -28,7 +28,7 @@ metadata: spec: acme: server: https://acme-v02.api.letsencrypt.org/directory - email: admin@demo-uber.club # À adapter + email: admin@omnex.sbs # À adapter privateKeySecretRef: name: letsencrypt-dns-account-key solvers: @@ -39,7 +39,7 @@ spec: key: api-token selector: dnsZones: - - "demo-uber.club" + - "omnex.sbs" --- # Issuer de test (ne compte pas dans les limites de rate-limit de Let's Encrypt @@ -51,7 +51,7 @@ metadata: spec: acme: server: https://acme-staging-v02.api.letsencrypt.org/directory - email: admin@demo-uber.club # À adapter + email: admin@omnex.sbs # À adapter privateKeySecretRef: name: letsencrypt-dns-staging-account-key solvers: @@ -62,4 +62,4 @@ spec: key: api-token selector: dnsZones: - - "demo-uber.club" + - "omnex.sbs" diff --git a/deploy/cert-manager/wildcard-certificate.yml b/deploy/cert-manager/wildcard-certificate.yml index ed8bd6e..f9352f4 100644 --- a/deploy/cert-manager/wildcard-certificate.yml +++ b/deploy/cert-manager/wildcard-certificate.yml @@ -13,7 +13,7 @@ spec: issuerRef: name: letsencrypt-dns kind: ClusterIssuer - commonName: "demo.demo-uber.club" + commonName: "omnex.sbs" dnsNames: - - "demo.demo-uber.club" - - "*.demo.demo-uber.club" + - "omnex.sbs" + - "*.omnex.sbs" diff --git a/deploy/chart-gestion/ingressroute/templates/ingressroute.yaml b/deploy/chart-gestion/ingressroute/templates/ingressroute.yaml index 9cceb59..105b406 100644 --- a/deploy/chart-gestion/ingressroute/templates/ingressroute.yaml +++ b/deploy/chart-gestion/ingressroute/templates/ingressroute.yaml @@ -1,5 +1,5 @@ {{- if not .Values.host }} -{{- fail "ingressroute: .Values.host est requis (ex: demo-abc123.demo.demo-uber.club)" }} +{{- fail "ingressroute: .Values.host est requis (ex: demo-abc123.omnex.sbs)" }} {{- end }} # Routage HTTPS de cette démo. Le TLS n'est pas précisé : Traefik utilise # automatiquement le certificat wildcard par défaut du TLSStore partagé diff --git a/deploy/chart-gestion/ingressroute/values.yaml b/deploy/chart-gestion/ingressroute/values.yaml index d1ff19a..8ec6ced 100644 --- a/deploy/chart-gestion/ingressroute/values.yaml +++ b/deploy/chart-gestion/ingressroute/values.yaml @@ -1,5 +1,5 @@ -# Sous-domaine complet de la démo, ex: demo-abc123.demo.demo-uber.club -# (doit correspondre au wildcard *.demo.demo-uber.club émis par cert-manager +# Sous-domaine complet de la démo, ex: demo-abc123.omnex.sbs +# (doit correspondre au wildcard *.omnex.sbs émis par cert-manager # et à l'URL calculée par le control-plane pour cette démo — voir # control-plane/api/internal/demos/service.go). host: ""