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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ bin/
*.dll
*.so
*.dylib
metrics-analyzer

# Test artifacts
coverage.out
Expand All @@ -27,6 +28,9 @@ go.work.sum
*.tmp
*.log

# Frontend build output
dist/

# Demo GIFs (hosted on vhs.charm.sh)
demo/*.gif

16 changes: 15 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
.PHONY: build test lint clean validate-rules
.PHONY: build test lint clean validate-rules build-web release-build

VERSION ?= $(shell cat VERSION 2>/dev/null || echo dev)
BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
WEB_LDFLAGS := -X 'main.buildVersion=$(VERSION)' -X 'main.buildTime=$(BUILD_TIME)'
RELEASE_OS ?= linux
RELEASE_ARCH ?= amd64

build:
go build -o bin/metrics-analyzer ./cmd/metrics-analyzer

build-web:
go build -ldflags "$(WEB_LDFLAGS)" -o bin/web-server ./web/server

release-build:
mkdir -p dist
GOOS=$(RELEASE_OS) GOARCH=$(RELEASE_ARCH) go build -o dist/metrics-analyzer-$(VERSION)-$(RELEASE_OS)-$(RELEASE_ARCH) ./cmd/metrics-analyzer
GOOS=$(RELEASE_OS) GOARCH=$(RELEASE_ARCH) go build -ldflags "$(WEB_LDFLAGS)" -o dist/web-server-$(VERSION)-$(RELEASE_OS)-$(RELEASE_ARCH) ./web/server

test:
go test -v ./...

Expand Down
1 change: 1 addition & 0 deletions VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.0.2
82 changes: 16 additions & 66 deletions cmd/metrics-analyzer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,9 @@ import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/stackrox/sensor-metrics-analyzer/internal/evaluator"
"github.com/stackrox/sensor-metrics-analyzer/internal/loadlevel"
"github.com/stackrox/sensor-metrics-analyzer/internal/parser"
"github.com/stackrox/sensor-metrics-analyzer/internal/analyzer"
"github.com/stackrox/sensor-metrics-analyzer/internal/reporter"
"github.com/stackrox/sensor-metrics-analyzer/internal/rules"
"github.com/stackrox/sensor-metrics-analyzer/internal/tui"
Expand Down Expand Up @@ -87,61 +84,18 @@ func analyzeCommand() {
}
}

// Extract cluster name from filename if not provided
if *clusterName == "" {
*clusterName = extractClusterName(metricsFile)
}

// Load load detection rules
fmt.Fprintf(os.Stderr, "Loading load detection rules from %s...\n", *loadLevelDir)
loadRules, err := rules.LoadLoadDetectionRules(*loadLevelDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to load load detection rules: %v\n", err)
loadRules = []rules.LoadDetectionRule{}
}

// Load evaluation rules
fmt.Fprintf(os.Stderr, "Loading rules from %s...\n", *rulesDir)
rulesList, err := rules.LoadRules(*rulesDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to load rules: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "Loaded %d rules\n", len(rulesList))

// Parse metrics
fmt.Fprintf(os.Stderr, "Parsing metrics from %s...\n", metricsFile)
metrics, err := parser.ParseFile(metricsFile)
report, err := analyzer.AnalyzeFile(metricsFile, analyzer.Options{
RulesDir: *rulesDir,
LoadLevelDir: *loadLevelDir,
ClusterName: *clusterName,
LoadLevelOverride: *loadLevelOverride,
ACSVersionOverride: *acsVersionOverride,
Logger: os.Stderr,
})
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to parse metrics: %v\n", err)
fmt.Fprintf(os.Stderr, "Failed to analyze metrics: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "Parsed %d metrics\n", len(metrics))

// Detect ACS version
acsVersion := *acsVersionOverride
if acsVersion == "" {
if detected, ok := metrics.DetectACSVersion(); ok {
acsVersion = detected
fmt.Fprintf(os.Stderr, "Detected ACS version: %s\n", acsVersion)
} else {
fmt.Fprintf(os.Stderr, "Warning: Could not detect ACS version\n")
}
}

// Detect load level
loadDetector := loadlevel.NewDetector(loadRules)
detectedLoadLevel, err := loadlevel.DetectWithOverride(metrics, loadDetector, rules.LoadLevel(*loadLevelOverride))
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Load level detection failed: %v\n", err)
detectedLoadLevel = rules.LoadLevelMedium
}
fmt.Fprintf(os.Stderr, "Detected load level: %s\n", detectedLoadLevel)

// Evaluate all rules
fmt.Fprintf(os.Stderr, "Evaluating rules...\n")
report := evaluator.EvaluateAllRules(rulesList, metrics, detectedLoadLevel, acsVersion)
report.ClusterName = *clusterName

// Generate report
var outputContent string
Expand All @@ -165,10 +119,12 @@ func analyzeCommand() {
return
}
case "markdown":
outputContent = reporter.GenerateMarkdown(report, *templatePath)
if outputContent == "" {
fmt.Fprintf(os.Stderr, "Warning: Markdown generation returned empty content\n")
markdown, mdErr := reporter.GenerateMarkdown(report, *templatePath)
if mdErr != nil {
fmt.Fprintf(os.Stderr, "Markdown generation failed: %v\n", mdErr)
os.Exit(1)
}
outputContent = markdown
default:
fmt.Fprintf(os.Stderr, "Unknown format: %s\n", *format)
os.Exit(1)
Expand Down Expand Up @@ -255,13 +211,7 @@ func listRulesCommand() {
}

func extractClusterName(filename string) string {
base := filepath.Base(filename)
// Remove extension
name := strings.TrimSuffix(base, filepath.Ext(base))
// Remove common prefixes/suffixes
name = strings.TrimSuffix(name, "-sensor-metrics")
name = strings.TrimSuffix(name, "-metrics")
return name
return analyzer.ExtractClusterName(filename)
}

func printUsage() {
Expand Down
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/charmbracelet/lipgloss v1.1.0
github.com/fatih/color v1.18.0
github.com/jedib0t/go-pretty/v6 v6.7.1
github.com/stretchr/testify v1.11.1
golang.org/x/term v0.29.0
)

Expand All @@ -19,6 +20,7 @@ require (
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
Expand All @@ -28,8 +30,10 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
Expand All @@ -63,5 +63,7 @@ golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
100 changes: 100 additions & 0 deletions internal/analyzer/analyzer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package analyzer

import (
"fmt"
"io"
"path/filepath"
"strings"

"github.com/stackrox/sensor-metrics-analyzer/internal/evaluator"
"github.com/stackrox/sensor-metrics-analyzer/internal/loadlevel"
"github.com/stackrox/sensor-metrics-analyzer/internal/parser"
"github.com/stackrox/sensor-metrics-analyzer/internal/rules"
)

// Options controls analysis behavior and logging.
type Options struct {
RulesDir string
LoadLevelDir string
ClusterName string
LoadLevelOverride string
ACSVersionOverride string
Logger io.Writer
}

// AnalyzeFile parses metrics and evaluates rules, returning the analysis report.
func AnalyzeFile(metricsFile string, opts Options) (rules.AnalysisReport, error) {
logOut := opts.Logger
if logOut == nil {
logOut = io.Discard
}

rulesDir := opts.RulesDir
if rulesDir == "" {
return rules.AnalysisReport{}, fmt.Errorf("rules directory is required")
}

loadLevelDir := opts.LoadLevelDir
if loadLevelDir == "" {
loadLevelDir = filepath.Join(rulesDir, "load-level")
}

clusterName := opts.ClusterName
if clusterName == "" {
clusterName = ExtractClusterName(metricsFile)
}

fmt.Fprintf(logOut, "Loading load detection rules from %s...\n", loadLevelDir)
loadRules, err := rules.LoadLoadDetectionRules(loadLevelDir)
if err != nil {
fmt.Fprintf(logOut, "Warning: Failed to load load detection rules: %v\n", err)
loadRules = []rules.LoadDetectionRule{}
}

fmt.Fprintf(logOut, "Loading rules from %s...\n", rulesDir)
rulesList, err := rules.LoadRules(rulesDir)
if err != nil {
return rules.AnalysisReport{}, fmt.Errorf("failed to load rules: %w", err)
}
fmt.Fprintf(logOut, "Loaded %d rules\n", len(rulesList))

fmt.Fprintf(logOut, "Parsing metrics from %s...\n", metricsFile)
metrics, err := parser.ParseFile(metricsFile)
if err != nil {
return rules.AnalysisReport{}, fmt.Errorf("failed to parse metrics: %w", err)
}
fmt.Fprintf(logOut, "Parsed %d metrics\n", len(metrics))

acsVersion := opts.ACSVersionOverride
if acsVersion == "" {
if detected, ok := metrics.DetectACSVersion(); ok {
acsVersion = detected
fmt.Fprintf(logOut, "Detected ACS version: %s\n", acsVersion)
} else {
fmt.Fprintf(logOut, "Warning: Could not detect ACS version\n")
}
}

loadDetector := loadlevel.NewDetector(loadRules)
detectedLoadLevel, err := loadlevel.DetectWithOverride(metrics, loadDetector, rules.LoadLevel(opts.LoadLevelOverride))
if err != nil {
fmt.Fprintf(logOut, "Warning: Load level detection failed: %v\n", err)
detectedLoadLevel = rules.LoadLevelMedium
}
fmt.Fprintf(logOut, "Detected load level: %s\n", detectedLoadLevel)

fmt.Fprintf(logOut, "Evaluating rules...\n")
report := evaluator.EvaluateAllRules(rulesList, metrics, detectedLoadLevel, acsVersion)
report.ClusterName = clusterName

return report, nil
}

// ExtractClusterName derives a cluster name from a file name.
func ExtractClusterName(filename string) string {
base := filepath.Base(filename)
name := strings.TrimSuffix(base, filepath.Ext(base))
name = strings.TrimSuffix(name, "-sensor-metrics")
name = strings.TrimSuffix(name, "-metrics")
return name
}
37 changes: 37 additions & 0 deletions internal/analyzer/analyzer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package analyzer

import (
"bytes"
"path/filepath"
"runtime"
"testing"

"github.com/stretchr/testify/assert"
)

func TestAnalyzeFile(t *testing.T) {
t.Parallel()

_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("AnalyzeFile() failed to resolve test file path")
}
repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(thisFile)))
metricsFile := filepath.Join(repoRoot, "testdata", "fixtures", "sample_metrics.txt")
rulesDir := filepath.Join(repoRoot, "automated-rules")

var logs bytes.Buffer
report, err := AnalyzeFile(metricsFile, Options{
RulesDir: rulesDir,
Logger: &logs,
})
assert.NoError(t, err)

assert.NotEmpty(t, report.ClusterName, "AnalyzeFile() cluster name is empty")
assert.False(t, report.Timestamp.IsZero(), "AnalyzeFile() timestamp is zero")
assert.NotEmpty(t, report.LoadLevel, "AnalyzeFile() load level is empty")
assert.NotEmpty(t, report.Results, "AnalyzeFile() returned no results")
assert.Equal(t, report.Summary.TotalAnalyzed, len(report.Results), "AnalyzeFile() summary mismatch")
statusTotal := report.Summary.RedCount + report.Summary.YellowCount + report.Summary.GreenCount
assert.LessOrEqual(t, statusTotal, report.Summary.TotalAnalyzed, "AnalyzeFile() summary counts exceed total")
}
Loading
Loading