// 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.") } }