diff --git a/signal/loadtest/README.md b/signal/loadtest/README.md index 2063ebd1e..11a26bee2 100644 --- a/signal/loadtest/README.md +++ b/signal/loadtest/README.md @@ -17,6 +17,56 @@ Load testing tool for the NetBird signal server. ## Usage +### Standalone Binary + +Build and run the load test as a standalone binary: + +```bash +# Build the binary +cd signal/loadtest/cmd/signal-loadtest +go build -o signal-loadtest + +# Single message exchange +./signal-loadtest \ + -server http://localhost:10000 \ + -pairs-per-sec 10 \ + -total-pairs 100 \ + -message-size 100 + +# Continuous exchange for 30 seconds +./signal-loadtest \ + -server http://localhost:10000 \ + -pairs-per-sec 10 \ + -total-pairs 20 \ + -message-size 200 \ + -exchange-duration 30s \ + -message-interval 200ms + +# Long-running test (10 minutes) +./signal-loadtest \ + -server http://localhost:10000 \ + -pairs-per-sec 20 \ + -total-pairs 50 \ + -message-size 500 \ + -exchange-duration 10m \ + -message-interval 100ms \ + -test-duration 15m \ + -log-level debug + +# Show help +./signal-loadtest -h +``` + +**Available Flags:** +- `-server`: Signal server URL (default: `http://localhost:10000`) +- `-pairs-per-sec`: Peer pairs created per second (default: 10) +- `-total-pairs`: Total number of peer pairs (default: 100) +- `-message-size`: Message size in bytes (default: 100) +- `-test-duration`: Maximum test duration, 0 = unlimited (default: 0) +- `-exchange-duration`: Continuous exchange duration per pair, 0 = single message (default: 0) +- `-message-interval`: Interval between messages in continuous mode (default: 100ms) +- `-log-level`: Log level: trace, debug, info, warn, error (default: info) + ### Running Tests ```bash diff --git a/signal/loadtest/cmd/signal-loadtest/integration_test.go b/signal/loadtest/cmd/signal-loadtest/integration_test.go new file mode 100644 index 000000000..f22fa25e2 --- /dev/null +++ b/signal/loadtest/cmd/signal-loadtest/integration_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/shared/signal/proto" + "github.com/netbirdio/netbird/signal/server" +) + +func TestCLI_SingleMessage(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + grpcServer, serverAddr := startTestSignalServer(t, ctx) + defer grpcServer.Stop() + + cmd := exec.Command("go", "run", "main.go", + "-server", serverAddr, + "-pairs-per-sec", "3", + "-total-pairs", "5", + "-message-size", "50", + "-log-level", "warn") + + output, err := cmd.CombinedOutput() + require.NoError(t, err, "CLI should execute successfully") + + outputStr := string(output) + require.Contains(t, outputStr, "Load Test Report") + require.Contains(t, outputStr, "Total Pairs Sent: 5") + require.Contains(t, outputStr, "Successful Exchanges: 5") + t.Logf("Output:\n%s", outputStr) +} + +func TestCLI_ContinuousExchange(t *testing.T) { + if testing.Short() { + t.Skip("Skipping continuous exchange CLI test in short mode") + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + grpcServer, serverAddr := startTestSignalServer(t, ctx) + defer grpcServer.Stop() + + cmd := exec.Command("go", "run", "main.go", + "-server", serverAddr, + "-pairs-per-sec", "2", + "-total-pairs", "3", + "-message-size", "100", + "-exchange-duration", "3s", + "-message-interval", "100ms", + "-log-level", "warn") + + output, err := cmd.CombinedOutput() + require.NoError(t, err, "CLI should execute successfully") + + outputStr := string(output) + require.Contains(t, outputStr, "Load Test Report") + require.Contains(t, outputStr, "Total Pairs Sent: 3") + require.Contains(t, outputStr, "Successful Exchanges: 3") + t.Logf("Output:\n%s", outputStr) +} + +func TestCLI_InvalidConfig(t *testing.T) { + tests := []struct { + name string + args []string + }{ + { + name: "negative pairs", + args: []string{"-pairs-per-sec", "-1"}, + }, + { + name: "zero total pairs", + args: []string{"-total-pairs", "0"}, + }, + { + name: "negative message size", + args: []string{"-message-size", "-100"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := append([]string{"run", "main.go"}, tt.args...) + cmd := exec.Command("go", args...) + output, err := cmd.CombinedOutput() + require.Error(t, err, "Should fail with invalid config") + require.Contains(t, string(output), "Configuration error") + }) + } +} + +func startTestSignalServer(t *testing.T, ctx context.Context) (*grpc.Server, string) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + grpcServer := grpc.NewServer() + + signalServer, err := server.NewServer(ctx, otel.Meter("cli-test")) + require.NoError(t, err) + + proto.RegisterSignalExchangeServer(grpcServer, signalServer) + + go func() { + if err := grpcServer.Serve(listener); err != nil { + t.Logf("Server stopped: %v", err) + } + }() + + time.Sleep(100 * time.Millisecond) + + return grpcServer, fmt.Sprintf("http://%s", listener.Addr().String()) +} + +func TestMain(m *testing.M) { + os.Exit(m.Run()) +} diff --git a/signal/loadtest/cmd/signal-loadtest/main.go b/signal/loadtest/cmd/signal-loadtest/main.go new file mode 100644 index 000000000..537584b61 --- /dev/null +++ b/signal/loadtest/cmd/signal-loadtest/main.go @@ -0,0 +1,105 @@ +package main + +import ( + "flag" + "fmt" + "os" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/signal/loadtest" +) + +var ( + serverURL string + pairsPerSecond int + totalPairs int + messageSize int + testDuration time.Duration + exchangeDuration time.Duration + messageInterval time.Duration + logLevel string +) + +func init() { + flag.StringVar(&serverURL, "server", "http://localhost:10000", "Signal server URL") + flag.IntVar(&pairsPerSecond, "pairs-per-sec", 10, "Number of peer pairs to create per second") + flag.IntVar(&totalPairs, "total-pairs", 100, "Total number of peer pairs to create") + flag.IntVar(&messageSize, "message-size", 100, "Size of test message in bytes") + flag.DurationVar(&testDuration, "test-duration", 0, "Maximum test duration (0 = unlimited)") + flag.DurationVar(&exchangeDuration, "exchange-duration", 0, "Duration for continuous message exchange per pair (0 = single message)") + flag.DurationVar(&messageInterval, "message-interval", 100*time.Millisecond, "Interval between messages in continuous mode") + flag.StringVar(&logLevel, "log-level", "info", "Log level (trace, debug, info, warn, error)") +} + +func main() { + flag.Parse() + + level, err := log.ParseLevel(logLevel) + if err != nil { + fmt.Fprintf(os.Stderr, "Invalid log level: %v\n", err) + os.Exit(1) + } + log.SetLevel(level) + + config := loadtest.LoadTestConfig{ + ServerURL: serverURL, + PairsPerSecond: pairsPerSecond, + TotalPairs: totalPairs, + MessageSize: messageSize, + TestDuration: testDuration, + ExchangeDuration: exchangeDuration, + MessageInterval: messageInterval, + } + + if err := validateConfig(config); err != nil { + fmt.Fprintf(os.Stderr, "Configuration error: %v\n", err) + flag.Usage() + os.Exit(1) + } + + log.Infof("Signal Load Test Configuration:") + log.Infof(" Server URL: %s", config.ServerURL) + log.Infof(" Pairs per second: %d", config.PairsPerSecond) + log.Infof(" Total pairs: %d", config.TotalPairs) + log.Infof(" Message size: %d bytes", config.MessageSize) + if config.TestDuration > 0 { + log.Infof(" Test duration: %v", config.TestDuration) + } + if config.ExchangeDuration > 0 { + log.Infof(" Exchange duration: %v", config.ExchangeDuration) + log.Infof(" Message interval: %v", config.MessageInterval) + } else { + log.Infof(" Mode: Single message exchange") + } + fmt.Println() + + lt := loadtest.NewLoadTest(config) + if err := lt.Run(); err != nil { + log.Errorf("Load test failed: %v", err) + os.Exit(1) + } + + metrics := lt.GetMetrics() + metrics.PrintReport() +} + +func validateConfig(config loadtest.LoadTestConfig) error { + if config.ServerURL == "" { + return fmt.Errorf("server URL is required") + } + if config.PairsPerSecond <= 0 { + return fmt.Errorf("pairs-per-sec must be greater than 0") + } + if config.TotalPairs <= 0 { + return fmt.Errorf("total-pairs must be greater than 0") + } + if config.MessageSize <= 0 { + return fmt.Errorf("message-size must be greater than 0") + } + if config.MessageInterval <= 0 { + return fmt.Errorf("message-interval must be greater than 0") + } + return nil +} diff --git a/signal/loadtest/cmd/signal-loadtest/test.sh b/signal/loadtest/cmd/signal-loadtest/test.sh new file mode 100644 index 000000000..947047580 --- /dev/null +++ b/signal/loadtest/cmd/signal-loadtest/test.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -e + +echo "Building signal-loadtest binary..." +go build -o signal-loadtest + +echo "" +echo "=== Test 1: Single message exchange (5 pairs) ===" +./signal-loadtest \ + -server http://localhost:10000 \ + -pairs-per-sec 5 \ + -total-pairs 5 \ + -message-size 50 \ + -log-level info + +echo "" +echo "=== Test 2: Continuous exchange (3 pairs, 5 seconds) ===" +./signal-loadtest \ + -server http://localhost:10000 \ + -pairs-per-sec 3 \ + -total-pairs 3 \ + -message-size 100 \ + -exchange-duration 5s \ + -message-interval 200ms \ + -log-level info + +echo "" +echo "All tests completed!"