Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: CI

on:
push:
branches: [main]
paths-ignore:
- "**.md"
- "scripts/install.sh"
- "version.yml"
pull_request:
branches: [main]
paths-ignore:
- "**.md"
- "scripts/install.sh"
- "version.yml"

jobs:
build:
name: Build & Verify
runs-on: ubuntu-latest

strategy:
matrix:
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
exclude:
- goos: windows
goarch: arm64

steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Download dependencies
run: go mod download

- name: Verify dependencies
run: go mod verify

- name: Build CLI
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: go build -o /dev/null ./cmd/loadforge

- name: Build Web
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: go build -o /dev/null ./cmd/web

lint:
name: Lint
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Run go vet
run: go vet ./...
67 changes: 67 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: Release

on:
push:
branches: [main]
paths-ignore:
- "**.md"
- "scripts/install.sh"

permissions:
contents: write

jobs:
release:
name: Tag & Publish
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}

- name: Read version from version.yml
id: version
run: |
VERSION=$(grep 'version:' version.yml | sed "s/version: *['\"]*//" | tr -d "'\"" | tr -d '[:space:]')
echo "value=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"

- name: Check if tag already exists
id: tag_check
run: |
TAG="${{ steps.version.outputs.tag }}"
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Tag $TAG already exists — skipping release."
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "Tag $TAG not found — will create release."
fi

- name: Create and push tag
if: steps.tag_check.outputs.exists == 'false'
run: |
TAG="${{ steps.version.outputs.tag }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "$TAG"
git push origin "$TAG"

- name: Set up Go
if: steps.tag_check.outputs.exists == 'false'
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Run GoReleaser
if: steps.tag_check.outputs.exists == 'false'
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean --config scripts/goreleaser.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
13 changes: 12 additions & 1 deletion cmd/web/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,26 @@ package main
import (
"flag"
"log"
"os"
"path/filepath"

"github.com/farhapartex/loadforge/web"
)

func main() {
configPath := flag.String("config", "web.yml", "path to web server config file")
defaultConfig := defaultConfigPath()
configPath := flag.String("config", defaultConfig, "path to web server config file")
flag.Parse()

if err := web.Start(*configPath); err != nil {
log.Fatal(err)
}
}

func defaultConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
return "web.yml"
}
return filepath.Join(home, ".loadforge", "web.yml")
}
45 changes: 44 additions & 1 deletion internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,66 @@ package cli
import (
"fmt"
"os"
"path/filepath"

"github.com/spf13/cobra"
)

const installPath = "/usr/local/bin/loadforge"

var uninstall bool

var rootCmd = &cobra.Command{
Use: "loadforge",
Short: "A powerful HTTP load testing tool",
Long: "loadforge is a developer first HTTP load testing tool",
RunE: func(cmd *cobra.Command, args []string) error {
if uninstall {
return runUninstall()
}
return cmd.Help()
},
}

func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(os.Stderr, err)
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

func init() {
rootCmd.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose output")
rootCmd.Flags().BoolVar(&uninstall, "uninstall", false, "Uninstall loadforge from the system")
}

func runUninstall() error {
if _, err := os.Stat(installPath); os.IsNotExist(err) {
return fmt.Errorf("loadforge is not installed at %s", installPath)
}

if err := os.Remove(installPath); err != nil {
return fmt.Errorf("failed to remove %s: %w (try running with sudo)", installPath, err)
}
fmt.Printf("Removed %s\n", installPath)

appDir, err := appDataDir()
if err == nil {
if err := os.RemoveAll(appDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not remove %s: %v\n", appDir, err)
} else {
fmt.Printf("Removed %s\n", appDir)
}
}

fmt.Println("loadforge has been uninstalled.")
return nil
}

func appDataDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".loadforge"), nil
}
2 changes: 1 addition & 1 deletion internal/cli/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"github.com/spf13/cobra"
)

var version = "0.1.0"
var version = "dev"

var versionCmd = &cobra.Command{
Use: "version",
Expand Down
24 changes: 20 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,26 @@ import (
)

type Config struct {
Name string `yaml:"name"`
BaseURL string `yaml:"base_url"`
Scenarios []Scenario `yaml:"scenarios"`
Load LoadConfig `yaml:"load"`
Name string `yaml:"name"`
BaseURL string `yaml:"base_url"`
Scenarios []Scenario `yaml:"scenarios"`
Load LoadConfig `yaml:"load"`
Assertions []Assertion `yaml:"assertions,omitempty"`
}

type Assertion struct {
Metric string `yaml:"metric" json:"metric"`
Operator string `yaml:"operator" json:"operator"`
Value float64 `yaml:"value" json:"value"`
Enabled bool `yaml:"enabled" json:"enabled"`
}

type AssertionResult struct {
Metric string `yaml:"metric" json:"metric"`
Operator string `yaml:"operator" json:"operator"`
Threshold float64 `yaml:"threshold" json:"threshold"`
Actual float64 `yaml:"actual" json:"actual"`
Passed bool `yaml:"passed" json:"passed"`
}

type Scenario struct {
Expand Down
94 changes: 94 additions & 0 deletions internal/runner/assertions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package runner

import (
"time"

"github.com/farhapartex/loadforge/internal/config"
"github.com/farhapartex/loadforge/internal/loader"
)

func evaluateAssertions(assertions []config.Assertion, snap *loader.MetricsSnapshot, percentiles *LatencyPercentiles) ([]config.AssertionResult, bool) {
if len(assertions) == 0 {
return nil, true
}

metricValues := buildMetricValues(snap, percentiles)

results := make([]config.AssertionResult, 0, len(assertions))
allPassed := true

for _, a := range assertions {
if !a.Enabled {
continue
}

actual, ok := metricValues[a.Metric]
if !ok {
continue
}

passed := applyOperator(actual, a.Operator, a.Value)
if !passed {
allPassed = false
}

results = append(results, config.AssertionResult{
Metric: a.Metric,
Operator: a.Operator,
Threshold: a.Value,
Actual: actual,
Passed: passed,
})
}

return results, allPassed
}

func buildMetricValues(snap *loader.MetricsSnapshot, p *LatencyPercentiles) map[string]float64 {
values := map[string]float64{
"rps": snap.RPS,
"total_requests": float64(snap.TotalRequests),
"total_errors": float64(snap.TotalFailures),
"error_rate": snap.ErrorRate(),
"success_rate": successRate(snap),
}

if p != nil {
values["p50_latency"] = durationToMs(p.P50)
values["p90_latency"] = durationToMs(p.P90)
values["p95_latency"] = durationToMs(p.P95)
values["p99_latency"] = durationToMs(p.P99)
values["avg_latency"] = durationToMs(p.Avg)
values["max_latency"] = durationToMs(p.Max)
}

return values
}

func durationToMs(d time.Duration) float64 {
return float64(d.Milliseconds())
}

func successRate(snap *loader.MetricsSnapshot) float64 {
if snap.TotalRequests == 0 {
return 0
}
return float64(snap.TotalSuccesses) / float64(snap.TotalRequests) * 100
}

func applyOperator(actual float64, operator string, threshold float64) bool {
switch operator {
case "less_than":
return actual < threshold
case "less_than_or_equal":
return actual <= threshold
case "greater_than":
return actual > threshold
case "greater_than_or_equal":
return actual >= threshold
case "equal":
return actual == threshold
default:
return false
}
}
Loading
Loading