commit adba4e25dc229ab3dd1021059808e646aaa8eb46 Author: Atridad Lahiji Date: Wed Dec 4 16:40:52 2024 -0600 Re-work diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..55370fc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +# flyctl launch added from .gitignore +**/loadr +**/.reports +fly.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfac963 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +loadr +.reports +.DS_Store \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c229705 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM golang:1.23-alpine AS builder + +WORKDIR /app +COPY . . + +# Build loadr +RUN go build -o loadr + +# Create and build the server using a shell script +RUN < server.go +package main + +import ( + "log" + "net/http" +) + +func main() { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Loadr is ready")) + }) + + log.Printf("Server starting on :8080") + if err := http.ListenAndServe(":8080", nil); err != nil { + log.Fatal(err) + } +} +EOF + +RUN go build -o server server.go + +FROM alpine:latest + +WORKDIR /app +COPY --from=builder /app/loadr . +COPY --from=builder /app/server . + +EXPOSE 8080 + +CMD ["/app/server"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1829df --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Loadr + +A lightweight REST load testing tool with robust support for different request patterns, token auth, and performance reports. + +## Installation + +```bash +go build +``` + +## Quick Start + +```bash +# Simple pattern: 5 POST requests +./loadr -rate=20 -max=100 -url=http://api.example.com -pattern=5p + +# Mixed pattern: 1 POST followed by 5 GETs, repeating +./loadr -rate=20 -max=100 -url=http://api.example.com -pattern=1p5g + +# With authentication and request body +./loadr -rate=20 -max=100 -url=http://api.example.com -pattern=2p3g -json=./data.json -token=YourBearerToken +``` + +## Request Patterns + +The `-pattern` flag supports flexible request patterns: + +### Simple Patterns +- `5p` : 5 POST requests +- `3g` : 3 GET requests + +### Sequential Patterns +- `1p5g` : 1 POST followed by 5 GETs +- `2p3g` : 2 POSTs followed by 3 GETs +- `3g2p` : 3 GETs followed by 2 POSTs + +### Pattern Rules +- Numbers specify how many requests of each type +- 'p' or 'P' specifies POST requests +- 'g' or 'G' specifies GET requests +- If no number is specified, 1 is assumed (e.g., "pg" = "1p1g") +- Pattern repeats until max requests is reached + +## Command Line Flags + +- `-rate`: Number of requests per second (default: 10) +- `-max`: Maximum number of requests to send (default: 50) +- `-url`: Target URL (default: "https://example.com") +- `-pattern`: Request pattern (e.g., "5p", "1p5g", "3g2p") +- `-json`: Path to JSON file for request body +- `-token`: Bearer token for authorization +- `-v`, `-version`: Print version information + +## Reports + +Test results are automatically: +1. Displayed in the console +2. Saved to `.reports/[timestamp].txt` + +Reports include: +- Total requests sent and received +- Average, maximum, and minimum latency +- Requests per second (sent and received) +- Pattern information diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..0e8aee1 --- /dev/null +++ b/fly.toml @@ -0,0 +1,14 @@ +app = "loadr" +primary_region = "ord" + +[build] + +[http_service] + internal_port = 8080 + force_https = true + auto_start_machines = true + min_machines_running = 1 + processes = ["app"] + +[[vm]] + size = "shared-cpu-1x" \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9f4dfa1 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module loadr + +go 1.23 diff --git a/lib/metrics.go b/lib/metrics.go new file mode 100644 index 0000000..bb7a74a --- /dev/null +++ b/lib/metrics.go @@ -0,0 +1,104 @@ +package lib + +import ( + "fmt" + "math" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +var metrics = PerformanceMetrics{ + MinLatency: time.Duration(math.MaxInt64), + ResponseCounters: make(map[int]int32), +} + +func UpdateMetrics(duration time.Duration, resp *http.Response, second int) { + metrics.Mu.Lock() + defer metrics.Mu.Unlock() + + fmt.Printf("Updating metrics - Duration: %v, Status: %d\n", duration, resp.StatusCode) // Debug log + + metrics.TotalRequests++ + metrics.TotalLatency += duration + + if duration > metrics.MaxLatency { + metrics.MaxLatency = duration + } + if duration < metrics.MinLatency { + metrics.MinLatency = duration + } + + if resp.StatusCode == http.StatusOK { + metrics.TotalResponses++ + metrics.ResponseCounters[second]++ + } + + // Debug log + fmt.Printf("Current metrics - Total Requests: %d, Total Responses: %d\n", + metrics.TotalRequests, metrics.TotalResponses) +} + +func CalculateAndPrintMetrics(startTime time.Time, requestsPerSecond float64, endpoint string, patterns []RequestPattern) { + time.Sleep(100 * time.Millisecond) + + metrics.Mu.Lock() + defer metrics.Mu.Unlock() + + averageLatency := time.Duration(0) + if metrics.TotalRequests > 0 { + averageLatency = metrics.TotalLatency / time.Duration(metrics.TotalRequests) + } + + totalDuration := time.Since(startTime).Seconds() + totalResponses := int32(0) + for _, count := range metrics.ResponseCounters { + totalResponses += count + } + + if metrics.MinLatency == time.Duration(math.MaxInt64) { + metrics.MinLatency = 0 + } + + results := fmt.Sprintf("Load Test Report\n") + results += fmt.Sprintf("=============\n\n") + + results += fmt.Sprintf("Endpoint: %s\n", endpoint) + results += fmt.Sprintf("Pattern: ") + for i, p := range patterns { + if i > 0 { + results += " → " + } + results += fmt.Sprintf("%d%s", p.Sequence, strings.ToLower(p.Verb[:1])) + } + results += "\n\n" + + results += fmt.Sprintf("Performance Metrics\n") + results += fmt.Sprintf("-----------------\n") + results += fmt.Sprintf("Total Requests Sent: %d\n", metrics.TotalRequests) + results += fmt.Sprintf("Total Responses Received: %d\n", totalResponses) + results += fmt.Sprintf("Average Latency: %s\n", averageLatency) + results += fmt.Sprintf("Max Latency: %s\n", metrics.MaxLatency) + results += fmt.Sprintf("Min Latency: %s\n", metrics.MinLatency) + results += fmt.Sprintf("Requests/sec (Target): %.2f\n", requestsPerSecond) + results += fmt.Sprintf("Requests/sec (Actual): %.2f\n", float64(metrics.TotalRequests)/totalDuration) + results += fmt.Sprintf("Responses/sec: %.2f\n", float64(totalResponses)/totalDuration) + + fmt.Println(results) + saveReport(results) +} + +func saveReport(results string) { + resultsDir := ".reports" + os.MkdirAll(resultsDir, os.ModePerm) + + resultsFile := filepath.Join(resultsDir, fmt.Sprintf("%d.txt", time.Now().Unix())) + if err := os.WriteFile(resultsFile, []byte(results), 0644); err != nil { + fmt.Println("Error saving report:", err) + return + } + + fmt.Println("Report saved:", resultsFile) +} diff --git a/lib/requests.go b/lib/requests.go new file mode 100644 index 0000000..9c07eca --- /dev/null +++ b/lib/requests.go @@ -0,0 +1,112 @@ +package lib + +import ( + "bytes" + "fmt" + "math" + "math/rand/v2" + "net/http" + "sync" + "sync/atomic" + "time" +) + +var client = &http.Client{} + +func (e *RequestError) Error() string { + return fmt.Sprintf("error making %s request to %s: %v", e.Verb, e.URL, e.Err) +} + +func makeRequest(verb, url, token string, jsonData []byte, second int) error { + startTime := time.Now() + + var req *http.Request + var err error + if jsonData != nil { + req, err = http.NewRequest(verb, url, bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + } else { + req, err = http.NewRequest(verb, url, nil) + } + if err != nil { + return &RequestError{Verb: verb, URL: url, Err: err} + } + + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := client.Do(req) + if err != nil { + return &RequestError{Verb: verb, URL: url, Err: err} + } + defer resp.Body.Close() + + duration := time.Since(startTime) + + UpdateMetrics(duration, resp, second) + + return nil +} + +func SendRequests(url string, patterns []RequestPattern, maxRequests int, requestsPerSecond float64, token string, jsonData []byte) { + metrics = PerformanceMetrics{ + MinLatency: time.Duration(math.MaxInt64), + ResponseCounters: make(map[int]int32), + } + + startTime := time.Now() + rateLimit := time.Second / time.Duration(requestsPerSecond) + ticker := time.NewTicker(rateLimit) + defer ticker.Stop() + + var requestCount int32 + var wg sync.WaitGroup + + patternIndex := 0 + sequenceCount := 0 + + for range ticker.C { + if int(requestCount) >= maxRequests { + break + } + + var selectedVerb string + if patterns[0].Sequence > 0 { + currentPattern := patterns[patternIndex] + selectedVerb = currentPattern.Verb + + sequenceCount++ + if sequenceCount >= currentPattern.Sequence { + sequenceCount = 0 + patternIndex = (patternIndex + 1) % len(patterns) + } + } else { + rand := rand.Float64() * 100 + cumulative := 0.0 + for _, p := range patterns { + cumulative += p.Percentage + if rand <= cumulative { + selectedVerb = p.Verb + break + } + } + } + + wg.Add(1) + go func(verb string) { + defer wg.Done() + err := makeRequest(verb, url, token, jsonData, int(time.Since(startTime).Seconds())) + if err != nil { + fmt.Printf("Error making request: %v\n", err) + } + atomic.AddInt32(&requestCount, 1) + }(selectedVerb) + } + + wg.Wait() + + time.Sleep(100 * time.Millisecond) + + CalculateAndPrintMetrics(startTime, requestsPerSecond, url, patterns) +} diff --git a/lib/types.go b/lib/types.go new file mode 100644 index 0000000..983ab42 --- /dev/null +++ b/lib/types.go @@ -0,0 +1,28 @@ +package lib + +import ( + "sync" + "time" +) + +type PerformanceMetrics struct { + Mu sync.Mutex + TotalRequests int32 + TotalResponses int32 + TotalLatency time.Duration + MaxLatency time.Duration + MinLatency time.Duration + ResponseCounters map[int]int32 +} + +type RequestError struct { + Verb string + URL string + Err error +} + +type RequestPattern struct { + Verb string + Percentage float64 + Sequence int +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..ff81f7f --- /dev/null +++ b/main.go @@ -0,0 +1,117 @@ +package main + +import ( + "flag" + "fmt" + "loadr/lib" + "os" +) + +var version string = "1.0.2" + +func parseCommandLine() (float64, int, string, []lib.RequestPattern, string, string) { + requestsPerSecond := flag.Float64("rate", 10, "Number of requests per second") + maxRequests := flag.Int("max", 50, "Maximum number of requests to send (0 for unlimited)") + url := flag.String("url", "https://example.com", "The URL to make requests to") + pattern := flag.String("pattern", "", `Request pattern (e.g., "5p" for 5 POSTs, "1p5g" for 1 POST + 5 GETs)`) + jsonFilePath := flag.String("json", "", "Path to the JSON file with request data") + bearerToken := flag.String("token", "", "Bearer token for authorization") + versionFlag := flag.Bool("version", false, "Print the version and exit") + versionFlagShort := flag.Bool("v", false, "Print the version and exit") + + flag.Parse() + + if *versionFlag || *versionFlagShort { + fmt.Println("Version:", version) + os.Exit(0) + } + + fmt.Printf("Received pattern value: '%s'\n", *pattern) + + patterns, err := parsePattern(*pattern) + if err != nil { + fmt.Printf("Warning: %v. Using default GET pattern.\n", err) + patterns = []lib.RequestPattern{{Verb: "GET", Percentage: 100}} + } else { + fmt.Printf("Using pattern: %+v\n", patterns) + } + + return *requestsPerSecond, *maxRequests, *url, patterns, *jsonFilePath, *bearerToken +} + +func parsePattern(pattern string) ([]lib.RequestPattern, error) { + if pattern == "" { + return []lib.RequestPattern{{Verb: "GET", Percentage: 100}}, nil + } + + var patterns []lib.RequestPattern + var current int + + for i := 0; i < len(pattern); i++ { + c := pattern[i] + switch { + case c >= '0' && c <= '9': + current = current*10 + int(c-'0') + case c == 'p' || c == 'P': + if current == 0 { + current = 1 + } + patterns = append(patterns, lib.RequestPattern{ + Verb: "POST", + Sequence: current, + Percentage: 0, + }) + current = 0 + case c == 'g' || c == 'G': + if current == 0 { + current = 1 + } + patterns = append(patterns, lib.RequestPattern{ + Verb: "GET", + Sequence: current, + Percentage: 0, + }) + current = 0 + default: + return nil, fmt.Errorf("invalid pattern character: %c", c) + } + } + + if len(patterns) == 0 { + return nil, fmt.Errorf("no valid patterns found in: %s", pattern) + } + + total := 0 + for _, p := range patterns { + total += p.Sequence + } + for i := range patterns { + patterns[i].Percentage = float64(patterns[i].Sequence) / float64(total) * 100 + } + + return patterns, nil +} + +func readJSONFile(filePath string) ([]byte, error) { + if filePath == "" { + return nil, nil + } + return os.ReadFile(filePath) +} + +func main() { + requestsPerSecond, maxRequests, url, patterns, jsonFilePath, bearerToken := parseCommandLine() + + if maxRequests <= 0 { + fmt.Println("Error: max must be an integer greater than 0") + return + } + + jsonData, err := readJSONFile(jsonFilePath) + if err != nil { + fmt.Println("Error reading JSON file:", err) + return + } + + lib.SendRequests(url, patterns, maxRequests, requestsPerSecond, bearerToken, jsonData) +}