@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user