diff --git a/.gitignore b/.gitignore index abd509daf..2c3fb2359 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ site # Temporary files and directories /test/regression/convert/testdata/tmp/* + +# Test profiling artifacts +test-profiles/ diff --git a/Makefile b/Makefile index a310414f0..01b4ba199 100644 --- a/Makefile +++ b/Makefile @@ -106,11 +106,11 @@ CATALOGS_MANIFEST := $(MANIFEST_HOME)/default-catalogs.yaml .PHONY: help help: #HELP Display essential help. - @awk 'BEGIN {FS = ":[^#]*#HELP"; printf "\nUsage:\n make \033[36m\033[0m\n\n"} /^[a-zA-Z_0-9-]+:.*#HELP / { printf " \033[36m%-21s\033[0m %s\n", $$1, $$2 } ' $(MAKEFILE_LIST) + @awk 'BEGIN {FS = ":[^#]*#HELP"; printf "\nUsage:\n make \033[36m\033[0m\n\n"} /^[a-zA-Z_0-9\/%-]+:.*#HELP / { printf " \033[36m%-21s\033[0m %s\n", $$1, $$2 } ' $(MAKEFILE_LIST) .PHONY: help-extended help-extended: #HELP Display extended help. - @awk 'BEGIN {FS = ":.*#(EX)?HELP"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*#(EX)?HELP / { printf " \033[36m%-25s\033[0m %s\n", $$1, $$2 } /^#SECTION / { printf "\n\033[1m%s\033[0m\n", substr($$0, 10) } ' $(MAKEFILE_LIST) + @awk 'BEGIN {FS = ":.*#(EX)?HELP"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9\/%-]+:.*#(EX)?HELP / { printf " \033[36m%-25s\033[0m %s\n", $$1, $$2 } /^#SECTION / { printf "\n\033[1m%s\033[0m\n", substr($$0, 10) } ' $(MAKEFILE_LIST) #SECTION Development @@ -335,6 +335,27 @@ test-upgrade-experimental-e2e: $(TEST_UPGRADE_E2E_TASKS) #HELP Run upgrade e2e t e2e-coverage: COVERAGE_NAME=$(COVERAGE_NAME) ./hack/test/e2e-coverage.sh +TEST_PROFILE_BIN := bin/test-profile +.PHONY: build-test-profiler +build-test-profiler: #EXHELP Build the test profiling tool + cd hack/tools/test-profiling && go build -o ../../../$(TEST_PROFILE_BIN) ./cmd/test-profile + +.PHONY: test-test-profiler +test-test-profiler: #EXHELP Run unit tests for the test profiling tool + cd hack/tools/test-profiling && go test -v ./... + +.PHONY: start-profiling +start-profiling: build-test-profiler #EXHELP Start profiling in background with auto-generated name (timestamp). Use start-profiling/ for custom name. + $(TEST_PROFILE_BIN) start + +.PHONY: start-profiling/% +start-profiling/%: build-test-profiler #EXHELP Start profiling in background with specified name. Usage: make start-profiling/ + $(TEST_PROFILE_BIN) start $* + +.PHONY: stop-profiling +stop-profiling: build-test-profiler #EXHELP Stop profiling and generate analysis report + $(TEST_PROFILE_BIN) stop + #SECTION KIND Cluster Operations .PHONY: kind-load diff --git a/hack/tools/test-profiling/README.md b/hack/tools/test-profiling/README.md new file mode 100644 index 000000000..bba4a81dc --- /dev/null +++ b/hack/tools/test-profiling/README.md @@ -0,0 +1,87 @@ +# Test Profiling Tools + +Collect and analyze heap/CPU profiles during operator-controller tests. + +## Quick Start + +```bash +# Start profiling +make start-profiling/baseline + +# Run tests +make test-e2e + +# Stop and analyze +make stop-profiling + +# View report +cat test-profiles/baseline/analysis.md + +# Compare runs +./bin/test-profile compare baseline optimized +cat test-profiles/comparisons/baseline-vs-optimized.md +``` + +## Commands + +```bash +# Build +make build-test-profiler + +# Run test with profiling +./bin/test-profile run [test-target] + +# Start/stop daemon +./bin/test-profile start [name] # Daemonizes automatically +./bin/test-profile stop + +# Analyze/compare +./bin/test-profile analyze +./bin/test-profile compare +./bin/test-profile collect # Single snapshot +``` + +## Configuration + +```bash +# Namespaces (supports components in different namespaces) +export TEST_PROFILE_NAMESPACE=olmv1-system # default +export TEST_PROFILE_OPERATOR_CONTROLLER_NAMESPACE=olmv1 # optional override +export TEST_PROFILE_CATALOGD_NAMESPACE=catalogd-system # optional override + +export TEST_PROFILE_INTERVAL=10 # seconds +export TEST_PROFILE_CPU_DURATION=10 # seconds +export TEST_PROFILE_MODE=both # both|heap|cpu +export TEST_PROFILE_DIR=./test-profiles # default +export TEST_PROFILE_TEST_TARGET=test-e2e # make target +``` + +## Output + +``` +test-profiles/ +├── / +│ ├── operator-controller/{heap,cpu}*.pprof +│ ├── catalogd/{heap,cpu}*.pprof +│ ├── profiler.log +│ └── analysis.md +└── comparisons/-vs-.md +``` + +## Interactive Analysis + +```bash +cd test-profiles//operator-controller +go tool pprof -top heap23.pprof +go tool pprof -base=heap0.pprof -top heap23.pprof +go tool pprof -text heap23.pprof | grep -i openapi +``` + +## Requirements + +**Runtime:** +- Kubernetes cluster access (via kubeconfig) + +**Build:** +- go 1.24+ +- make diff --git a/hack/tools/test-profiling/cmd/test-profile/analyze.go b/hack/tools/test-profiling/cmd/test-profile/analyze.go new file mode 100644 index 000000000..c20caa026 --- /dev/null +++ b/hack/tools/test-profiling/cmd/test-profile/analyze.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + + "github.com/operator-framework/operator-controller/hack/tools/test-profiling/pkg/analyzer" + "github.com/operator-framework/operator-controller/hack/tools/test-profiling/pkg/config" + "github.com/spf13/cobra" +) + +var analyzeCmd = &cobra.Command{ + Use: "analyze ", + Short: "Analyze collected profiles", + Long: `Generate an analysis report from previously collected profiles. + +The report includes: +- Memory growth analysis +- Top memory allocators +- CPU profiling results +- OpenAPI and JSON deserialization analysis + +Example: + test-profile analyze baseline`, + Args: cobra.ExactArgs(1), + RunE: runAnalyze, +} + +func runAnalyze(cmd *cobra.Command, args []string) error { + cfg := config.DefaultConfig() + cfg.Name = args[0] + + if err := cfg.Validate(); err != nil { + return err + } + + fmt.Printf("📊 Analyzing profiles in: %s\n", cfg.ProfileDir()) + + a := analyzer.NewAnalyzer(cfg) + return a.Analyze() +} diff --git a/hack/tools/test-profiling/cmd/test-profile/collect.go b/hack/tools/test-profiling/cmd/test-profile/collect.go new file mode 100644 index 000000000..406d62b25 --- /dev/null +++ b/hack/tools/test-profiling/cmd/test-profile/collect.go @@ -0,0 +1,36 @@ +package main + +import ( + "context" + "time" + + "github.com/operator-framework/operator-controller/hack/tools/test-profiling/pkg/collector" + "github.com/operator-framework/operator-controller/hack/tools/test-profiling/pkg/config" + "github.com/spf13/cobra" +) + +var collectCmd = &cobra.Command{ + Use: "collect", + Short: "Collect a single profile snapshot", + Long: `Collect a single snapshot of heap and CPU profiles from all components. + +This is useful for quick spot checks without running the full daemon. + +Example: + test-profile collect`, + RunE: runCollect, +} + +func runCollect(cmd *cobra.Command, args []string) error { + cfg := config.DefaultConfig() + cfg.Name = time.Now().Format("snapshot-20060102-150405") + + if err := cfg.Validate(); err != nil { + return err + } + + ctx := context.Background() + + c := collector.NewCollector(cfg) + return c.CollectOnce(ctx) +} diff --git a/hack/tools/test-profiling/cmd/test-profile/compare.go b/hack/tools/test-profiling/cmd/test-profile/compare.go new file mode 100644 index 000000000..e4d57520e --- /dev/null +++ b/hack/tools/test-profiling/cmd/test-profile/compare.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "path/filepath" + + "github.com/operator-framework/operator-controller/hack/tools/test-profiling/pkg/comparator" + "github.com/operator-framework/operator-controller/hack/tools/test-profiling/pkg/config" + "github.com/spf13/cobra" +) + +var compareCmd = &cobra.Command{ + Use: "compare ", + Short: "Compare two profile runs", + Long: `Generate a comparison report between two profile runs. + +This helps identify improvements or regressions between different +versions or configurations. + +Example: + test-profile compare baseline optimized`, + Args: cobra.ExactArgs(2), + RunE: runCompare, +} + +func runCompare(cmd *cobra.Command, args []string) error { + cfg := config.DefaultConfig() + + baselineName := args[0] + optimizedName := args[1] + + baselineDir := filepath.Join(cfg.OutputDir, baselineName) + optimizedDir := filepath.Join(cfg.OutputDir, optimizedName) + outputDir := filepath.Join(cfg.OutputDir, "comparisons") + + fmt.Printf("📊 Comparing %s vs %s\n", baselineName, optimizedName) + + c := comparator.NewComparator(baselineDir, optimizedDir, outputDir) + return c.Compare(baselineName, optimizedName) +} diff --git a/hack/tools/test-profiling/cmd/test-profile/main.go b/hack/tools/test-profiling/cmd/test-profile/main.go new file mode 100644 index 000000000..068527eee --- /dev/null +++ b/hack/tools/test-profiling/cmd/test-profile/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" +) + +func main() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/hack/tools/test-profiling/cmd/test-profile/root.go b/hack/tools/test-profiling/cmd/test-profile/root.go new file mode 100644 index 000000000..5b3687ed7 --- /dev/null +++ b/hack/tools/test-profiling/cmd/test-profile/root.go @@ -0,0 +1,40 @@ +package main + +import ( + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "test-profile", + Short: "Test profiling tool for operator-controller", + Long: `Test profiling tool for collecting, analyzing, and comparing +heap and CPU profiles during operator-controller tests. + +Examples: + # Run test with profiling + test-profile run baseline + + # Start profiling daemon + test-profile start my-test + + # Stop profiling daemon + test-profile stop + + # Analyze collected profiles + test-profile analyze baseline + + # Compare two runs + test-profile compare baseline optimized + + # Collect single snapshot + test-profile collect`, +} + +func init() { + rootCmd.AddCommand(runCmd) + rootCmd.AddCommand(startCmd) + rootCmd.AddCommand(stopCmd) + rootCmd.AddCommand(collectCmd) + rootCmd.AddCommand(analyzeCmd) + rootCmd.AddCommand(compareCmd) +} diff --git a/hack/tools/test-profiling/cmd/test-profile/run.go b/hack/tools/test-profiling/cmd/test-profile/run.go new file mode 100644 index 000000000..c41494c99 --- /dev/null +++ b/hack/tools/test-profiling/cmd/test-profile/run.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "syscall" + + "github.com/operator-framework/operator-controller/hack/tools/test-profiling/pkg/analyzer" + "github.com/operator-framework/operator-controller/hack/tools/test-profiling/pkg/collector" + "github.com/operator-framework/operator-controller/hack/tools/test-profiling/pkg/config" + "github.com/spf13/cobra" +) + +var ( + testTarget string +) + +var runCmd = &cobra.Command{ + Use: "run [test-target]", + Short: "Run e2e tests with profiling", + Long: `Run e2e tests while collecting profiles in the background. + +This command: +1. Starts profile collection +2. Runs the specified test target +3. Stops collection when tests complete +4. Generates analysis report + +Examples: + # Use default test target + test-profile run baseline + + # Specify custom test target + test-profile run baseline test-e2e + + # Use environment variable + TEST_PROFILE_TEST_TARGET=test-e2e test-profile run baseline`, + Args: cobra.RangeArgs(1, 2), + RunE: runProfile, +} + +func init() { + runCmd.Flags().StringVar(&testTarget, "test-target", "", "Make target to run (default from TEST_PROFILE_TEST_TARGET or test-experimental-e2e)") +} + +func runProfile(cmd *cobra.Command, args []string) error { + cfg := config.DefaultConfig() + cfg.Name = args[0] + + // Override test target if specified + if len(args) > 1 { + cfg.TestTarget = args[1] + } else if testTarget != "" { + cfg.TestTarget = testTarget + } + + if err := cfg.Validate(); err != nil { + return err + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Handle signals + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigChan + fmt.Println("\n🛑 Received interrupt, cleaning up...") + cancel() + }() + + // Start collector + fmt.Printf("🚀 Starting profile collection for: %s\n", cfg.Name) + c := collector.NewCollector(cfg) + if err := c.Start(ctx); err != nil { + return fmt.Errorf("failed to start collector: %w", err) + } + + // Ensure cleanup + defer func() { + fmt.Println("\n🛑 Stopping profiler...") + _ = c.Stop() + }() + + // Run tests + fmt.Printf("\n🧪 Running tests: make %s\n\n", cfg.TestTarget) + testErr := runTests(ctx, cfg) + + // Stop collector + fmt.Println("\n🛑 Stopping profiler...") + if err := c.Stop(); err != nil { + return err + } + + // Generate analysis + fmt.Println("\n📊 Generating analysis report...") + a := analyzer.NewAnalyzer(cfg) + if err := a.Analyze(); err != nil { + return fmt.Errorf("failed to analyze profiles: %w", err) + } + + if testErr != nil { + return fmt.Errorf("test execution failed: %w", testErr) + } + + fmt.Printf("\n✅ Profiling complete! Report: %s/analysis.md\n", cfg.ProfileDir()) + return nil +} + +func runTests(ctx context.Context, cfg *config.Config) error { + cmd := exec.CommandContext(ctx, "make", cfg.TestTarget) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + return cmd.Run() +} diff --git a/hack/tools/test-profiling/cmd/test-profile/start.go b/hack/tools/test-profiling/cmd/test-profile/start.go new file mode 100644 index 000000000..aa51c8d32 --- /dev/null +++ b/hack/tools/test-profiling/cmd/test-profile/start.go @@ -0,0 +1,159 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/operator-framework/operator-controller/hack/tools/test-profiling/pkg/collector" + "github.com/operator-framework/operator-controller/hack/tools/test-profiling/pkg/config" + "github.com/spf13/cobra" +) + +var ( + daemonMode bool +) + +var startCmd = &cobra.Command{ + Use: "start ", + Short: "Start profiling in daemon mode", + Long: `Start collecting profiles in the background. Use 'stop' to end collection. + +The profiler will: +- Daemonize itself and return immediately +- Wait for the cluster to be ready +- Set up port-forwarding to components +- Collect profiles at regular intervals +- Continue until 'stop' is called + +Examples: + # Auto-generated name (timestamp) + test-profile start + + # Custom name + test-profile start my-test`, + Args: cobra.MaximumNArgs(1), + RunE: runStart, +} + +func init() { + startCmd.Flags().BoolVar(&daemonMode, "daemon", false, "Internal flag for daemon process") + startCmd.Flags().MarkHidden("daemon") +} + +func runStart(cmd *cobra.Command, args []string) error { + cfg := config.DefaultConfig() + + // Set name + if len(args) > 0 { + cfg.Name = args[0] + } else { + cfg.Name = time.Now().Format("20060102-150405") + } + + if err := cfg.Validate(); err != nil { + return err + } + + // If not in daemon mode, fork and exit + if !daemonMode { + return daemonize(args) + } + + // We are now the daemon process + // Setup log file + logFile := filepath.Join(cfg.ProfileDir(), "profiler.log") + if err := os.MkdirAll(cfg.ProfileDir(), 0755); err != nil { + return fmt.Errorf("failed to create profile directory: %w", err) + } + + log, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return fmt.Errorf("failed to open log file: %w", err) + } + defer log.Close() + + // Redirect stdout and stderr to log file + os.Stdout = log + os.Stderr = log + + // Check if already running + if _, err := os.Stat(cfg.PIDFile()); err == nil { + return fmt.Errorf("profiler already running (PID file exists: %s)", cfg.PIDFile()) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Handle signals + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigChan + fmt.Println("\n🛑 Received interrupt, stopping...") + cancel() + }() + + // Create and start collector + c := collector.NewCollector(cfg) + if err := c.Start(ctx); err != nil { + fmt.Fprintf(os.Stderr, "\n❌ Error starting profiler: %v\n", err) + return err + } + + // Wait for context cancellation + <-ctx.Done() + + return c.Stop() +} + +// daemonize forks the process to run in the background +func daemonize(args []string) error { + // Get the executable path + executable, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + + // Build command with --daemon flag + cmdArgs := []string{"start", "--daemon"} + cmdArgs = append(cmdArgs, args...) + + cmd := exec.Command(executable, cmdArgs...) + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, // Create new session + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start daemon: %w", err) + } + + // Print success message with helpful info + fmt.Printf("✅ Profiler started in background (PID: %d)\n", cmd.Process.Pid) + + // Try to determine the profile name for better UX + var name string + if len(args) > 0 { + name = args[0] + } else { + name = time.Now().Format("20060102-150405") + } + + cfg := config.DefaultConfig() + cfg.Name = name + logFile := filepath.Join(cfg.ProfileDir(), "profiler.log") + + fmt.Printf("📁 Profile directory: %s\n", cfg.ProfileDir()) + fmt.Printf("📋 Logs: %s\n", logFile) + fmt.Printf("🛑 Stop with: test-profile stop\n") + + return nil +} diff --git a/hack/tools/test-profiling/cmd/test-profile/stop.go b/hack/tools/test-profiling/cmd/test-profile/stop.go new file mode 100644 index 000000000..bc1619efb --- /dev/null +++ b/hack/tools/test-profiling/cmd/test-profile/stop.go @@ -0,0 +1,128 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + + "github.com/operator-framework/operator-controller/hack/tools/test-profiling/pkg/analyzer" + "github.com/operator-framework/operator-controller/hack/tools/test-profiling/pkg/config" + "github.com/spf13/cobra" +) + +var stopCmd = &cobra.Command{ + Use: "stop", + Short: "Stop profiling daemon and generate analysis", + Long: `Stop the running profiling daemon and generate analysis report. + +This will: +- Find and stop the running profiler process +- Clean up port-forwarding +- Generate analysis report + +Example: + test-profile stop`, + RunE: runStop, +} + +func runStop(cmd *cobra.Command, args []string) error { + cfg := config.DefaultConfig() + + // Find the most recent profile directory + profileDir, err := findRecentProfile(cfg.OutputDir) + if err != nil { + return err + } + + cfg.Name = filepath.Base(profileDir) + + pidFile := cfg.PIDFile() + if _, err := os.Stat(pidFile); os.IsNotExist(err) { + return fmt.Errorf("no profiler running (PID file not found: %s)", pidFile) + } + + // Read PID + pidData, err := os.ReadFile(pidFile) + if err != nil { + return fmt.Errorf("failed to read PID file: %w", err) + } + + pid, err := strconv.Atoi(strings.TrimSpace(string(pidData))) + if err != nil { + return fmt.Errorf("invalid PID in file: %w", err) + } + + fmt.Printf("🛑 Stopping profiler (PID: %d)...\n", pid) + + // Send SIGTERM to the process + process, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("failed to find process: %w", err) + } + + if err := process.Signal(syscall.SIGTERM); err != nil { + // Process might already be dead + fmt.Printf("Warning: failed to signal process: %v\n", err) + } + + // Clean up kubectl port-forward processes + cleanupPortForwards() + + // Remove PID file + _ = os.Remove(pidFile) + + fmt.Println("✅ Profiler stopped") + + // Generate analysis + fmt.Println("\n📊 Generating analysis report...") + a := analyzer.NewAnalyzer(cfg) + if err := a.Analyze(); err != nil { + return fmt.Errorf("failed to analyze profiles: %w", err) + } + + return nil +} + +func findRecentProfile(outputDir string) (string, error) { + entries, err := os.ReadDir(outputDir) + if err != nil { + return "", fmt.Errorf("failed to read output directory: %w", err) + } + + var latestDir string + var latestTime int64 + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + fullPath := filepath.Join(outputDir, entry.Name()) + info, err := os.Stat(fullPath) + if err != nil { + continue + } + + if info.ModTime().Unix() > latestTime { + latestTime = info.ModTime().Unix() + latestDir = fullPath + } + } + + if latestDir == "" { + return "", fmt.Errorf("no profile directory found in %s", outputDir) + } + + return latestDir, nil +} + +func cleanupPortForwards() { + cmd := exec.Command("pkill", "-f", "kubectl port-forward.*6060") + _ = cmd.Run() + cmd = exec.Command("pkill", "-f", "kubectl port-forward.*6061") + _ = cmd.Run() +} diff --git a/hack/tools/test-profiling/go.mod b/hack/tools/test-profiling/go.mod new file mode 100644 index 000000000..df225c427 --- /dev/null +++ b/hack/tools/test-profiling/go.mod @@ -0,0 +1,56 @@ +module github.com/operator-framework/operator-controller/hack/tools/test-profiling + +go 1.24.6 + +require ( + github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d + github.com/spf13/cobra v1.8.1 + k8s.io/api v0.34.1 + k8s.io/apimachinery v0.34.1 + k8s.io/client-go v0.34.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/moby/spdystream v0.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/oauth2 v0.27.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/term v0.30.0 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/time v0.9.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/hack/tools/test-profiling/go.sum b/hack/tools/test-profiling/go.sum new file mode 100644 index 000000000..b24257b28 --- /dev/null +++ b/hack/tools/test-profiling/go.sum @@ -0,0 +1,171 @@ +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0= +github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= +k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= +k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= +k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= +k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/hack/tools/test-profiling/pkg/analyzer/analyzer.go b/hack/tools/test-profiling/pkg/analyzer/analyzer.go new file mode 100644 index 000000000..6b0d0ced2 --- /dev/null +++ b/hack/tools/test-profiling/pkg/analyzer/analyzer.go @@ -0,0 +1,535 @@ +package analyzer + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/google/pprof/profile" + "github.com/operator-framework/operator-controller/hack/tools/test-profiling/pkg/config" +) + +// Analyzer analyzes collected profiles and generates reports +type Analyzer struct { + config *config.Config +} + +// NewAnalyzer creates a new analyzer +func NewAnalyzer(cfg *config.Config) *Analyzer { + return &Analyzer{config: cfg} +} + +// Analyze generates an analysis report for the profile run +func (a *Analyzer) Analyze() error { + reportPath := filepath.Join(a.config.ProfileDir(), "analysis.md") + fmt.Printf("📊 Generating analysis report: %s\n", reportPath) + + report, err := os.Create(reportPath) + if err != nil { + return fmt.Errorf("failed to create report file: %w", err) + } + defer report.Close() + + // Write header + fmt.Fprintf(report, "# Memory Profile Analysis\n\n") + fmt.Fprintf(report, "**Test Name:** %s\n", a.config.Name) + fmt.Fprintf(report, "**Date:** %s\n\n", time.Now().Format("2006-01-02 15:04:05")) + fmt.Fprintf(report, "---\n\n") + + // Executive summary + if err := a.writeExecutiveSummary(report); err != nil { + return err + } + + // Analyze each component + for _, comp := range a.config.Components { + compDir := a.config.ComponentDir(comp.Name) + if _, err := os.Stat(compDir); os.IsNotExist(err) { + continue + } + + fmt.Fprintf(report, "## %s Analysis\n\n", comp.Name) + + if err := a.analyzeComponent(report, comp.Name, compDir); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to analyze %s: %v\n", comp.Name, err) + } + + fmt.Fprintf(report, "---\n\n") + } + + // Recommendations + a.writeRecommendations(report) + + fmt.Printf("✅ Analysis complete: %s\n", reportPath) + return nil +} + +// writeExecutiveSummary writes the executive summary section +func (a *Analyzer) writeExecutiveSummary(report *os.File) error { + fmt.Fprintf(report, "## Executive Summary\n") + + for _, comp := range a.config.Components { + compDir := a.config.ComponentDir(comp.Name) + peakMemory, err := a.getPeakMemory(compDir) + if err == nil && peakMemory != "" { + fmt.Fprintf(report, "- **%s**: %s\n", comp.Name, peakMemory) + } + } + + fmt.Fprintf(report, "\n\n\n") + return nil +} + +// analyzeComponent analyzes a single component +func (a *Analyzer) analyzeComponent(report *os.File, component, compDir string) error { + // Count profiles + heapCount, err := countProfiles(compDir, "heap*.pprof") + if err != nil { + return err + } + + cpuCount, err := countProfiles(compDir, "cpu*.pprof") + if err != nil { + return err + } + + fmt.Fprintf(report, "**Profiles Collected:** %d\n", heapCount) + + // Heap analysis + if heapCount > 0 { + if err := a.analyzeHeap(report, compDir, heapCount); err != nil { + return err + } + } + + // CPU analysis + if cpuCount > 0 { + if err := a.analyzeCPU(report, compDir, cpuCount); err != nil { + return err + } + } + + return nil +} + +// analyzeHeap analyzes heap profiles +func (a *Analyzer) analyzeHeap(report *os.File, compDir string, count int) error { + // Find peak profile + peakProfile, peakSize, err := findPeakProfile(compDir, "heap*.pprof") + if err != nil { + return err + } + + peakMemory, _ := a.getPeakMemory(compDir) + + fmt.Fprintf(report, "**Peak Profile:** %s (%s)\n", filepath.Base(peakProfile), peakSize) + fmt.Fprintf(report, "**Peak Memory Usage:** %s\n\n", peakMemory) + + // Memory growth table + if err := a.writeMemoryGrowthTable(report, compDir); err != nil { + return err + } + + // Top allocators + fmt.Fprintf(report, "### Top Memory Allocators (Peak Profile)\n\n") + fmt.Fprintf(report, "```\n") + if err := a.generateTopReport(report, peakProfile, 20); err != nil { + return err + } + fmt.Fprintf(report, "```\n\n") + + // OpenAPI allocations + fmt.Fprintf(report, "### OpenAPI-Related Allocations\n\n") + fmt.Fprintf(report, "```\n") + if err := a.generateFilteredReport(report, peakProfile, "openapi", 20); err != nil { + fmt.Fprintf(report, "No OpenAPI allocations found\n") + } + fmt.Fprintf(report, "```\n\n\n") + + // Growth analysis + baseProfile := filepath.Join(compDir, "heap0.pprof") + if _, err := os.Stat(baseProfile); err == nil { + if err := a.analyzeGrowth(report, compDir, baseProfile, peakProfile); err != nil { + return err + } + } + + return nil +} + +// analyzeCPU analyzes CPU profiles +func (a *Analyzer) analyzeCPU(report *os.File, compDir string, count int) error { + peakProfile, peakSize, err := findPeakProfile(compDir, "cpu*.pprof") + if err != nil { + return err + } + + // Get total CPU time + cpuTotal, _ := a.getCPUTotal(compDir, peakProfile) + + fmt.Fprintf(report, "\n### CPU Profile Analysis\n\n") + fmt.Fprintf(report, "**CPU Profiles Collected:** %d\n", count) + fmt.Fprintf(report, "**Peak CPU Profile:** %s (%s)\n", filepath.Base(peakProfile), peakSize) + fmt.Fprintf(report, "**Total CPU Time:** %s\n\n", cpuTotal) + + fmt.Fprintf(report, "#### Top CPU Consumers (Peak Profile)\n\n") + fmt.Fprintf(report, "```\n") + if err := a.generateTopReport(report, peakProfile, 20); err != nil { + return err + } + fmt.Fprintf(report, "```\n\n") + + fmt.Fprintf(report, "#### CPU-Intensive Functions\n\n") + fmt.Fprintf(report, "```\n") + if err := a.generateFilteredReport(report, peakProfile, "Reconcile|sync|watch|cache|list", 20); err != nil { + fmt.Fprintf(report, "No reconciliation functions found in top CPU consumers\n") + } + fmt.Fprintf(report, "```\n\n") + + fmt.Fprintf(report, "#### JSON/Serialization CPU Usage\n\n") + fmt.Fprintf(report, "```\n") + if err := a.generateFilteredReport(report, peakProfile, "json|unmarshal|decode|marshal|encode", 15); err != nil { + fmt.Fprintf(report, "No significant JSON/serialization CPU usage detected\n") + } + fmt.Fprintf(report, "```\n\n") + + return nil +} + +// analyzeGrowth analyzes memory growth from base to peak +func (a *Analyzer) analyzeGrowth(report *os.File, compDir, baseProfile, peakProfile string) error { + fmt.Fprintf(report, "### Memory Growth Analysis (Baseline to Peak)\n\n") + fmt.Fprintf(report, "#### Top Growth Contributors\n\n") + fmt.Fprintf(report, "```\n") + + // Generate diff report + if err := a.generateDiffReport(report, baseProfile, peakProfile, 20); err != nil { + return err + } + fmt.Fprintf(report, "```\n\n") + + // OpenAPI growth + fmt.Fprintf(report, "#### OpenAPI Growth\n\n") + fmt.Fprintf(report, "```\n") + if err := a.generateDiffFilteredReport(report, baseProfile, peakProfile, "openapi", 20); err != nil { + fmt.Fprintf(report, "No OpenAPI growth detected\n") + } + fmt.Fprintf(report, "```\n\n") + + // JSON deserialization growth + fmt.Fprintf(report, "#### JSON Deserialization Growth\n\n") + fmt.Fprintf(report, "```\n") + if err := a.generateDiffFilteredReport(report, baseProfile, peakProfile, "json|unmarshal|decode", 20); err != nil { + fmt.Fprintf(report, "No JSON deserialization growth detected\n") + } + fmt.Fprintf(report, "```\n\n") + + // Dynamic client growth + fmt.Fprintf(report, "#### Dynamic Client Growth\n\n") + fmt.Fprintf(report, "```\n") + if err := a.generateDiffFilteredReport(report, baseProfile, peakProfile, "dynamic|client-go", 20); err != nil { + fmt.Fprintf(report, "No dynamic client growth detected\n") + } + fmt.Fprintf(report, "```\n\n") + + return nil +} + +// writeMemoryGrowthTable writes the memory growth table +func (a *Analyzer) writeMemoryGrowthTable(report *os.File, compDir string) error { + fmt.Fprintf(report, "### Memory Growth\n\n") + fmt.Fprintf(report, "| Snapshot | File Size | Growth from Previous |\n") + fmt.Fprintf(report, "|----------|-----------|---------------------|\n") + + profiles, err := filepath.Glob(filepath.Join(compDir, "heap*.pprof")) + if err != nil { + return err + } + + sort.Strings(profiles) + + var prevSize int64 + for i, profile := range profiles { + info, err := os.Stat(profile) + if err != nil { + continue + } + + size := info.Size() + growth := "baseline" + if i > 0 { + diff := size - prevSize + if diff == 0 { + growth = "0" + } else if diff > 0 { + growth = fmt.Sprintf("+%dK", diff/1024) + } else { + growth = fmt.Sprintf("%dK", diff/1024) + } + } + + fmt.Fprintf(report, "| %s | %dK | %s |\n", + filepath.Base(profile), size/1024, growth) + prevSize = size + } + + fmt.Fprintf(report, "\n") + return nil +} + +// writeRecommendations writes the recommendations section +func (a *Analyzer) writeRecommendations(report *os.File) { + fmt.Fprintf(report, "---\n\n") + fmt.Fprintf(report, "## Recommendations\n\n") + fmt.Fprintf(report, "Based on the analysis above, consider:\n\n") + fmt.Fprintf(report, "1. **OpenAPI Schema Caching**: If OpenAPI allocations are significant, implement caching\n") + fmt.Fprintf(report, "2. **Informer Optimization**: Review and deduplicate informer creation\n") + fmt.Fprintf(report, "3. **List Operation Limits**: Add pagination or field selectors to reduce list overhead\n") + fmt.Fprintf(report, "4. **JSON Optimization**: Consider using typed clients instead of unstructured where possible\n\n\n") +} + +// generateTopReport generates a top allocation/CPU report +func (a *Analyzer) generateTopReport(w io.Writer, profilePath string, limit int) error { + cmd := exec.Command("go", "tool", "pprof", "-top", "-lines", "-nodecount="+fmt.Sprintf("%d", limit), profilePath) + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to run pprof: %w", err) + } + fmt.Fprint(w, string(output)) + return nil +} + +// generateFilteredReport generates a report filtered by pattern +func (a *Analyzer) generateFilteredReport(w io.Writer, profilePath, pattern string, limit int) error { + cmd := exec.Command("go", "tool", "pprof", "-text", "-lines", "-nodecount=100", profilePath) + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to run pprof: %w", err) + } + + // Filter output by pattern + filtered := grepLines(string(output), pattern) + lines := strings.Split(filtered, "\n") + + // Include headers from original output + fullOutput := string(output) + fullLines := strings.Split(fullOutput, "\n") + var result []string + + // Add header lines + for _, line := range fullLines { + if strings.HasPrefix(strings.TrimSpace(line), "File:") || + strings.HasPrefix(strings.TrimSpace(line), "Type:") || + strings.HasPrefix(strings.TrimSpace(line), "Time:") || + strings.HasPrefix(strings.TrimSpace(line), "Showing") || + strings.HasPrefix(strings.TrimSpace(line), "Dropped") || + (strings.Contains(line, "flat") && strings.Contains(line, "sum%")) { + result = append(result, line) + } + } + + // Add matched lines + count := 0 + for _, line := range lines { + if line != "" && count < limit { + result = append(result, line) + count++ + } + } + + if len(result) <= 6 { // Just headers + return fmt.Errorf("no matches found") + } + + fmt.Fprint(w, strings.Join(result, "\n")+"\n") + return nil +} + +// generateDiffReport generates a differential report +func (a *Analyzer) generateDiffReport(w io.Writer, basePath, profilePath string, limit int) error { + cmd := exec.Command("go", "tool", "pprof", "-top", "-lines", "-nodecount="+fmt.Sprintf("%d", limit), "-base="+basePath, profilePath) + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to run pprof: %w", err) + } + fmt.Fprint(w, string(output)) + return nil +} + +// generateDiffFilteredReport generates a filtered differential report +func (a *Analyzer) generateDiffFilteredReport(w io.Writer, basePath, profilePath, pattern string, limit int) error { + cmd := exec.Command("go", "tool", "pprof", "-text", "-lines", "-nodecount=100", "-base="+basePath, profilePath) + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to run pprof: %w", err) + } + + // Filter output by pattern + filtered := grepLines(string(output), pattern) + lines := strings.Split(filtered, "\n") + + // Include headers from original output + fullOutput := string(output) + fullLines := strings.Split(fullOutput, "\n") + var result []string + + // Add header lines + for _, line := range fullLines { + if strings.HasPrefix(strings.TrimSpace(line), "File:") || + strings.HasPrefix(strings.TrimSpace(line), "Type:") || + strings.HasPrefix(strings.TrimSpace(line), "Time:") || + strings.HasPrefix(strings.TrimSpace(line), "Showing") || + strings.HasPrefix(strings.TrimSpace(line), "Dropped") || + (strings.Contains(line, "flat") && strings.Contains(line, "sum%")) { + result = append(result, line) + } + } + + // Add matched lines + count := 0 + for _, line := range lines { + if line != "" && count < limit { + result = append(result, line) + count++ + } + } + + if len(result) <= 6 { + return fmt.Errorf("no matches found") + } + + fmt.Fprint(w, strings.Join(result, "\n")+"\n") + return nil +} + +// getPeakMemory extracts peak memory from a profile +func (a *Analyzer) getPeakMemory(compDir string) (string, error) { + peakProfile, _, err := findPeakProfile(compDir, "heap*.pprof") + if err != nil { + return "", err + } + + f, err := os.Open(peakProfile) + if err != nil { + return "", err + } + defer f.Close() + + p, err := profile.Parse(f) + if err != nil { + return "", err + } + + // Sum up total memory + var total int64 + for _, sample := range p.Sample { + if len(sample.Value) > 1 { + total += sample.Value[1] // inuse_space + } + } + + return formatBytes(total), nil +} + +// getCPUTotal extracts total CPU time from a profile +func (a *Analyzer) getCPUTotal(compDir, profilePath string) (string, error) { + f, err := os.Open(profilePath) + if err != nil { + return "", err + } + defer f.Close() + + p, err := profile.Parse(f) + if err != nil { + return "", err + } + + // Sum up total samples + var total int64 + for _, sample := range p.Sample { + if len(sample.Value) > 0 { + total += sample.Value[0] + } + } + + // Convert to seconds based on period + period := p.Period + if period == 0 { + period = 1 + } + seconds := float64(total) * float64(period) / 1e9 + + return fmt.Sprintf("of %.2fs", seconds), nil +} + +// formatBytes formats byte count in human-readable format +func formatBytes(b int64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%dB", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("of %.2f%cB", float64(b)/float64(div), "kMGTPE"[exp]) +} + +// Helper functions + +func countProfiles(compDir, pattern string) (int, error) { + profiles, err := filepath.Glob(filepath.Join(compDir, pattern)) + if err != nil { + return 0, err + } + return len(profiles), nil +} + +func findPeakProfile(compDir, pattern string) (string, string, error) { + profiles, err := filepath.Glob(filepath.Join(compDir, pattern)) + if err != nil { + return "", "", err + } + + if len(profiles) == 0 { + return "", "", fmt.Errorf("no profiles found") + } + + var largest string + var largestSize int64 + + for _, profile := range profiles { + info, err := os.Stat(profile) + if err != nil { + continue + } + if info.Size() > largestSize { + largest = profile + largestSize = info.Size() + } + } + + sizeStr := fmt.Sprintf("%dK", largestSize/1024) + return largest, sizeStr, nil +} + +func grepLines(text, pattern string) string { + re := regexp.MustCompile(fmt.Sprintf("(?i)%s", pattern)) + lines := strings.Split(text, "\n") + var matched []string + + for _, line := range lines { + if re.MatchString(line) { + matched = append(matched, line) + } + } + + return strings.Join(matched, "\n") +} diff --git a/hack/tools/test-profiling/pkg/analyzer/analyzer_test.go b/hack/tools/test-profiling/pkg/analyzer/analyzer_test.go new file mode 100644 index 000000000..e55ff4df1 --- /dev/null +++ b/hack/tools/test-profiling/pkg/analyzer/analyzer_test.go @@ -0,0 +1,260 @@ +package analyzer + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/operator-framework/operator-controller/hack/tools/test-profiling/pkg/config" +) + +func TestGrepLines(t *testing.T) { + text := `line one with openapi +line two normal +line three with OpenAPI client +line four normal +line five with json parser` + + tests := []struct { + name string + pattern string + expected int + }{ + {"case insensitive openapi", "openapi", 2}, + {"case insensitive json", "json", 1}, + {"no matches", "notfound", 0}, + {"multiple patterns", "openapi|json", 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := grepLines(text, tt.pattern) + lines := strings.Split(result, "\n") + if result == "" { + lines = []string{} + } + count := 0 + for _, line := range lines { + if line != "" { + count++ + } + } + if count != tt.expected { + t.Errorf("Expected %d matches, got %d", tt.expected, count) + } + }) + } +} + +func TestCountProfiles(t *testing.T) { + // Create temporary directory + tmpDir := t.TempDir() + + // Create test profile files + for i := 0; i < 5; i++ { + f, err := os.Create(filepath.Join(tmpDir, "heap"+string(rune('0'+i))+".pprof")) + if err != nil { + t.Fatal(err) + } + f.Close() + } + + // Create non-matching files + f, err := os.Create(filepath.Join(tmpDir, "cpu0.pprof")) + if err != nil { + t.Fatal(err) + } + f.Close() + + count, err := countProfiles(tmpDir, "heap*.pprof") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if count != 5 { + t.Errorf("Expected 5 heap profiles, got %d", count) + } + + count, err = countProfiles(tmpDir, "cpu*.pprof") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if count != 1 { + t.Errorf("Expected 1 cpu profile, got %d", count) + } + + // Test non-existent directory (glob returns 0 results, not an error) + count, err = countProfiles("/nonexistent/dir", "*.pprof") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if count != 0 { + t.Errorf("Expected 0 profiles for non-existent directory, got %d", count) + } +} + +func TestFindPeakProfile(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files with different sizes + testFiles := map[string]int{ + "heap0.pprof": 100, + "heap1.pprof": 500, + "heap2.pprof": 300, + "heap3.pprof": 1000, // This should be the peak + "heap4.pprof": 200, + } + + for name, size := range testFiles { + path := filepath.Join(tmpDir, name) + data := make([]byte, size) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + } + + peakPath, sizeStr, err := findPeakProfile(tmpDir, "heap*.pprof") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if filepath.Base(peakPath) != "heap3.pprof" { + t.Errorf("Expected peak profile heap3.pprof, got %s", filepath.Base(peakPath)) + } + + // Size should be 1000 bytes = 0K (integer division) + if sizeStr != "0K" { + t.Errorf("Expected size 0K, got %s", sizeStr) + } + + // Test with larger file to get non-zero K + largeFile := filepath.Join(tmpDir, "heap5.pprof") + data := make([]byte, 5*1024) // 5KB + if err := os.WriteFile(largeFile, data, 0644); err != nil { + t.Fatal(err) + } + + peakPath, sizeStr, err = findPeakProfile(tmpDir, "heap*.pprof") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if filepath.Base(peakPath) != "heap5.pprof" { + t.Errorf("Expected peak profile heap5.pprof, got %s", filepath.Base(peakPath)) + } + + if sizeStr != "5K" { + t.Errorf("Expected size 5K, got %s", sizeStr) + } +} + +func TestFindPeakProfile_NoFiles(t *testing.T) { + tmpDir := t.TempDir() + + _, _, err := findPeakProfile(tmpDir, "heap*.pprof") + if err == nil { + t.Error("Expected error when no profiles found") + } +} + +func TestNewAnalyzer(t *testing.T) { + cfg := &config.Config{ + Name: "test", + OutputDir: "/tmp/profiles", + } + + a := NewAnalyzer(cfg) + + if a == nil { + t.Fatal("Expected non-nil Analyzer") + } + + if a.config != cfg { + t.Error("Expected config to be set") + } +} + +func TestWriteMemoryGrowthTable(t *testing.T) { + tmpDir := t.TempDir() + + // Create test profile files with different sizes + profiles := []struct { + name string + size int + }{ + {"heap0.pprof", 10 * 1024}, // 10K + {"heap1.pprof", 20 * 1024}, // 20K + {"heap2.pprof", 20 * 1024}, // 20K (no growth) + {"heap3.pprof", 15 * 1024}, // 15K (shrink) + {"heap4.pprof", 25 * 1024}, // 25K + } + + for _, p := range profiles { + path := filepath.Join(tmpDir, p.name) + data := make([]byte, p.size) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + } + + cfg := &config.Config{ + Name: "test", + OutputDir: tmpDir, + } + a := NewAnalyzer(cfg) + + // Create output file + reportPath := filepath.Join(tmpDir, "test_report.md") + report, err := os.Create(reportPath) + if err != nil { + t.Fatal(err) + } + defer report.Close() + + err = a.writeMemoryGrowthTable(report, tmpDir) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Read the report and verify content + content, err := os.ReadFile(reportPath) + if err != nil { + t.Fatal(err) + } + + contentStr := string(content) + + // Check for table header + if !strings.Contains(contentStr, "Memory Growth") { + t.Error("Expected 'Memory Growth' header") + } + + if !strings.Contains(contentStr, "Snapshot") { + t.Error("Expected table column headers") + } + + // Check for baseline + if !strings.Contains(contentStr, "baseline") { + t.Error("Expected 'baseline' for first entry") + } + + // Check for growth indicators + if !strings.Contains(contentStr, "+") { + t.Error("Expected growth indicator '+' in table") + } +} + +// Note: Testing Analyze(), analyzeHeap(), analyzeCPU() requires real pprof files +// and go tool pprof to be available. These would be integration tests. +// +// Example integration test structure: +// func TestAnalyze_Integration(t *testing.T) { +// if testing.Short() { +// t.Skip("Skipping integration test") +// } +// // Setup test directory with real pprof files +// // Run analyzer +// // Verify report generation +// } diff --git a/hack/tools/test-profiling/pkg/collector/collector.go b/hack/tools/test-profiling/pkg/collector/collector.go new file mode 100644 index 000000000..84ae6e73b --- /dev/null +++ b/hack/tools/test-profiling/pkg/collector/collector.go @@ -0,0 +1,278 @@ +package collector + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/operator-framework/operator-controller/hack/tools/test-profiling/pkg/config" + "github.com/operator-framework/operator-controller/hack/tools/test-profiling/pkg/kubernetes" +) + +// Collector handles profile collection +type Collector struct { + config *config.Config + forwarders map[string]*kubernetes.PortForwarder + stopChan chan struct{} + heapCounter map[string]int + cpuCounter map[string]int + startTime time.Time + lastCPUStart map[string]time.Time +} + +// NewCollector creates a new profile collector +func NewCollector(cfg *config.Config) *Collector { + return &Collector{ + config: cfg, + forwarders: make(map[string]*kubernetes.PortForwarder), + stopChan: make(chan struct{}), + heapCounter: make(map[string]int), + cpuCounter: make(map[string]int), + lastCPUStart: make(map[string]time.Time), + } +} + +// Start starts the profile collection daemon +func (c *Collector) Start(ctx context.Context) error { + // Setup directories + if err := c.setupDirectories(); err != nil { + return err + } + + // Write PID file early so stop command works even if waiting for cluster + if err := c.writePIDFile(); err != nil { + return err + } + + // Wait for namespaces (collect unique namespaces) + namespaces := make(map[string]bool) + for _, comp := range c.config.Components { + namespaces[comp.Namespace] = true + } + + // Wait for each unique namespace (15 minute timeout - cluster may need to start up) + for ns := range namespaces { + fmt.Printf("⏳ Waiting for namespace %s (timeout: 15m)...\n", ns) + if err := kubernetes.WaitForNamespace(ctx, ns, 15*time.Minute); err != nil { + c.cleanup() + return fmt.Errorf("❌ Timeout waiting for namespace %s: %w\n"+ + " The cluster may not be running or the namespace doesn't exist.\n"+ + " If running e2e tests, ensure the cluster is created first.", ns, err) + } + fmt.Printf("✅ Namespace %s is ready\n", ns) + } + + // Wait for deployments and setup port-forwarding + for _, comp := range c.config.Components { + fmt.Printf("⏳ Waiting for %s deployment in %s (timeout: 10m)...\n", comp.Name, comp.Namespace) + if err := kubernetes.WaitForDeployment(ctx, comp.Namespace, comp.Deployment, 10*time.Minute); err != nil { + return fmt.Errorf("❌ Timeout waiting for deployment %s in namespace %s: %w\n"+ + " The deployment may not exist or is not becoming ready.", comp.Deployment, comp.Namespace, err) + } + fmt.Printf("✅ Deployment %s is ready\n", comp.Name) + + fmt.Printf("🔌 Setting up port-forward for %s (localhost:%d -> :%d)...\n", + comp.Name, comp.LocalPort, comp.Port) + + pf := kubernetes.NewPortForwarder(comp.Namespace, comp.Deployment, comp.Port, comp.LocalPort) + if err := pf.Start(ctx); err != nil { + c.cleanup() + return err + } + c.forwarders[comp.Name] = pf + } + + c.startTime = time.Now() + fmt.Printf("✅ Profiler started at %s\n", c.startTime.Format(time.RFC3339)) + fmt.Printf("📊 Collecting profiles every %v\n", c.config.Interval) + fmt.Printf("📁 Output directory: %s\n", c.config.ProfileDir()) + + // Start collection loop + go c.collect(ctx) + + return nil +} + +// Stop stops the collector +func (c *Collector) Stop() error { + close(c.stopChan) + c.cleanup() + + // Remove PID file + _ = os.Remove(c.config.PIDFile()) + + duration := time.Since(c.startTime) + fmt.Printf("\n✅ Profiling stopped after %v\n", duration.Round(time.Second)) + + return nil +} + +// CollectOnce collects a single snapshot +func (c *Collector) CollectOnce(ctx context.Context) error { + // Setup port-forwarding for all components + for _, comp := range c.config.Components { + pf := kubernetes.NewPortForwarder(comp.Namespace, comp.Deployment, comp.Port, comp.LocalPort) + if err := pf.Start(ctx); err != nil { + c.cleanup() + return err + } + c.forwarders[comp.Name] = pf + defer pf.Stop() + } + + // Setup directories + if err := c.setupDirectories(); err != nil { + return err + } + + // Collect from all components + for _, comp := range c.config.Components { + if c.config.CollectHeap() { + if err := c.collectHeapProfile(comp.Name, comp.LocalPort); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to collect heap profile for %s: %v\n", comp.Name, err) + } + } + if c.config.CollectCPU() { + if err := c.collectCPUProfile(comp.Name, comp.LocalPort); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to collect CPU profile for %s: %v\n", comp.Name, err) + } + } + } + + return nil +} + +// collect runs the collection loop +func (c *Collector) collect(ctx context.Context) { + ticker := time.NewTicker(c.config.Interval) + defer ticker.Stop() + + for { + select { + case <-c.stopChan: + return + case <-ctx.Done(): + return + case <-ticker.C: + c.collectAll() + } + } +} + +// collectAll collects profiles from all components +func (c *Collector) collectAll() { + for _, comp := range c.config.Components { + if c.config.CollectHeap() { + if err := c.collectHeapProfile(comp.Name, comp.LocalPort); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to collect heap profile for %s: %v\n", comp.Name, err) + } + } + + if c.config.CollectCPU() { + // Check if enough time has passed since last CPU profile + if last, ok := c.lastCPUStart[comp.Name]; ok { + if time.Since(last) < c.config.CPUDuration { + continue // Still collecting previous CPU profile + } + } + + if err := c.collectCPUProfile(comp.Name, comp.LocalPort); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to collect CPU profile for %s: %v\n", comp.Name, err) + } + c.lastCPUStart[comp.Name] = time.Now() + } + } +} + +// collectHeapProfile collects a heap profile +func (c *Collector) collectHeapProfile(component string, port int) error { + counter := c.heapCounter[component] + filename := fmt.Sprintf("heap%d.pprof", counter) + outputPath := filepath.Join(c.config.ComponentDir(component), filename) + + url := fmt.Sprintf("http://localhost:%d/debug/pprof/heap", port) + if err := c.downloadProfile(url, outputPath); err != nil { + return err + } + + c.heapCounter[component]++ + fmt.Printf("📸 [%s] Collected heap profile: %s\n", component, filename) + return nil +} + +// collectCPUProfile collects a CPU profile +func (c *Collector) collectCPUProfile(component string, port int) error { + counter := c.cpuCounter[component] + filename := fmt.Sprintf("cpu%d.pprof", counter) + outputPath := filepath.Join(c.config.ComponentDir(component), filename) + + url := fmt.Sprintf("http://localhost:%d/debug/pprof/profile?seconds=%d", port, int(c.config.CPUDuration.Seconds())) + if err := c.downloadProfile(url, outputPath); err != nil { + return err + } + + c.cpuCounter[component]++ + fmt.Printf("📸 [%s] Collected CPU profile: %s\n", component, filename) + return nil +} + +// downloadProfile downloads a profile from a URL +func (c *Collector) downloadProfile(url, outputPath string) error { + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("failed to download profile: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + outFile, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer outFile.Close() + + if _, err := io.Copy(outFile, resp.Body); err != nil { + return fmt.Errorf("failed to write profile: %w", err) + } + + return nil +} + +// setupDirectories creates the output directory structure +func (c *Collector) setupDirectories() error { + for _, comp := range c.config.Components { + dir := c.config.ComponentDir(comp.Name) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + } + return nil +} + +// writePIDFile writes the PID file +func (c *Collector) writePIDFile() error { + pidFile := c.config.PIDFile() + pid := fmt.Sprintf("%d", os.Getpid()) + if err := os.WriteFile(pidFile, []byte(pid), 0644); err != nil { + return fmt.Errorf("failed to write PID file: %w", err) + } + return nil +} + +// cleanup stops all port forwarders +func (c *Collector) cleanup() { + for name, pf := range c.forwarders { + fmt.Printf("🔌 Stopping port-forward for %s...\n", name) + if pf != nil { + pf.Stop() + } + } + c.forwarders = make(map[string]*kubernetes.PortForwarder) +} diff --git a/hack/tools/test-profiling/pkg/collector/collector_test.go b/hack/tools/test-profiling/pkg/collector/collector_test.go new file mode 100644 index 000000000..fdefcbfb3 --- /dev/null +++ b/hack/tools/test-profiling/pkg/collector/collector_test.go @@ -0,0 +1,243 @@ +package collector + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/operator-framework/operator-controller/hack/tools/test-profiling/pkg/config" +) + +func TestNewCollector(t *testing.T) { + cfg := &config.Config{ + Name: "test", + OutputDir: "/tmp/profiles", + Components: []config.ComponentConfig{ + {Name: "operator-controller", Port: 6060, LocalPort: 6060}, + }, + } + + c := NewCollector(cfg) + + if c == nil { + t.Fatal("Expected non-nil Collector") + } + + if c.config != cfg { + t.Error("Expected config to be set") + } + + if len(c.forwarders) != 0 { + t.Error("Expected empty forwarders map") + } + + if len(c.heapCounter) != 0 { + t.Error("Expected empty heapCounter map") + } + + if len(c.cpuCounter) != 0 { + t.Error("Expected empty cpuCounter map") + } +} + +func TestSetupDirectories(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.Config{ + Name: "test-run", + OutputDir: tmpDir, + Components: []config.ComponentConfig{ + {Name: "operator-controller"}, + {Name: "catalogd"}, + }, + } + + c := NewCollector(cfg) + + err := c.setupDirectories() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Verify directories were created + for _, comp := range cfg.Components { + dir := filepath.Join(tmpDir, "test-run", comp.Name) + if _, err := os.Stat(dir); os.IsNotExist(err) { + t.Errorf("Expected directory %s to exist", dir) + } + } +} + +func TestWritePIDFile(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.Config{ + Name: "test-run", + OutputDir: tmpDir, + } + + c := NewCollector(cfg) + + // Create profile directory first + if err := os.MkdirAll(cfg.ProfileDir(), 0755); err != nil { + t.Fatal(err) + } + + err := c.writePIDFile() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + pidFile := cfg.PIDFile() + if _, err := os.Stat(pidFile); os.IsNotExist(err) { + t.Error("Expected PID file to exist") + } + + // Read and verify PID + content, err := os.ReadFile(pidFile) + if err != nil { + t.Fatal(err) + } + + if len(content) == 0 { + t.Error("Expected non-empty PID file") + } +} + +func TestCleanup(t *testing.T) { + cfg := &config.Config{ + Name: "test", + OutputDir: "/tmp/profiles", + } + + c := NewCollector(cfg) + + // Add some mock forwarders + c.forwarders["test1"] = nil + c.forwarders["test2"] = nil + + if len(c.forwarders) != 2 { + t.Fatalf("Expected 2 forwarders, got %d", len(c.forwarders)) + } + + c.cleanup() + + if len(c.forwarders) != 0 { + t.Errorf("Expected forwarders to be cleared, got %d", len(c.forwarders)) + } +} + +func TestHeapCounter(t *testing.T) { + cfg := &config.Config{ + Name: "test", + OutputDir: "/tmp/profiles", + } + + c := NewCollector(cfg) + + // Initially should be 0 + if c.heapCounter["operator-controller"] != 0 { + t.Error("Expected initial heap counter to be 0") + } + + // Increment + c.heapCounter["operator-controller"]++ + if c.heapCounter["operator-controller"] != 1 { + t.Error("Expected heap counter to be 1 after increment") + } + + // Increment again + c.heapCounter["operator-controller"]++ + if c.heapCounter["operator-controller"] != 2 { + t.Error("Expected heap counter to be 2 after second increment") + } +} + +func TestCPUCounter(t *testing.T) { + cfg := &config.Config{ + Name: "test", + OutputDir: "/tmp/profiles", + } + + c := NewCollector(cfg) + + // Initially should be 0 + if c.cpuCounter["catalogd"] != 0 { + t.Error("Expected initial CPU counter to be 0") + } + + // Increment + c.cpuCounter["catalogd"]++ + if c.cpuCounter["catalogd"] != 1 { + t.Error("Expected CPU counter to be 1 after increment") + } +} + +func TestLastCPUStart(t *testing.T) { + cfg := &config.Config{ + Name: "test", + OutputDir: "/tmp/profiles", + } + + c := NewCollector(cfg) + + // Record start time + now := time.Now() + c.lastCPUStart["operator-controller"] = now + + // Verify we can retrieve it + if recorded, ok := c.lastCPUStart["operator-controller"]; !ok { + t.Error("Expected lastCPUStart to be recorded") + } else if recorded != now { + t.Error("Expected recorded time to match") + } + + // Check time-based logic + time.Sleep(10 * time.Millisecond) + elapsed := time.Since(c.lastCPUStart["operator-controller"]) + if elapsed < 10*time.Millisecond { + t.Error("Expected elapsed time to be at least 10ms") + } +} + +func TestStopChannel(t *testing.T) { + cfg := &config.Config{ + Name: "test", + OutputDir: "/tmp/profiles", + } + + c := NewCollector(cfg) + + // Verify channel is created + if c.stopChan == nil { + t.Error("Expected stopChan to be initialized") + } + + // Verify we can close it + close(c.stopChan) + + // Verify it's closed by trying to read + select { + case <-c.stopChan: + // Expected - channel is closed + case <-time.After(100 * time.Millisecond): + t.Error("Expected stopChan to be closed") + } +} + +// Note: Testing Start(), Stop(), CollectOnce(), and the actual profile collection +// requires a real Kubernetes cluster with deployments and pprof endpoints. +// These would be integration tests. +// +// Example integration test structure: +// func TestCollector_Integration(t *testing.T) { +// if testing.Short() { +// t.Skip("Skipping integration test") +// } +// // Setup test cluster with components exposing pprof +// // Start collector +// // Verify profiles are collected +// // Stop collector +// // Verify cleanup +// } diff --git a/hack/tools/test-profiling/pkg/comparator/comparator.go b/hack/tools/test-profiling/pkg/comparator/comparator.go new file mode 100644 index 000000000..081a3cd67 --- /dev/null +++ b/hack/tools/test-profiling/pkg/comparator/comparator.go @@ -0,0 +1,339 @@ +package comparator + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" +) + +// Comparator compares two profile runs +type Comparator struct { + baselineDir string + optimizedDir string + outputDir string +} + +// NewComparator creates a new comparator +func NewComparator(baselineDir, optimizedDir, outputDir string) *Comparator { + return &Comparator{ + baselineDir: baselineDir, + optimizedDir: optimizedDir, + outputDir: outputDir, + } +} + +// Compare generates a comparison report +func (c *Comparator) Compare(baselineName, optimizedName string) error { + reportName := fmt.Sprintf("%s-vs-%s.md", baselineName, optimizedName) + reportPath := filepath.Join(c.outputDir, reportName) + + fmt.Printf("📊 Generating comparison report: %s\n", reportPath) + + if err := os.MkdirAll(c.outputDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + report, err := os.Create(reportPath) + if err != nil { + return fmt.Errorf("failed to create report file: %w", err) + } + defer report.Close() + + // Write header + fmt.Fprintf(report, "# Profile Comparison: %s vs %s\n\n", baselineName, optimizedName) + fmt.Fprintf(report, "**Generated:** %s\n\n", time.Now().Format("2006-01-02 15:04:05")) + fmt.Fprintf(report, "---\n\n") + + // Compare each component + components, err := c.getComponents() + if err != nil { + return err + } + + for _, comp := range components { + fmt.Printf("📊 Comparing %s...\n", comp) + fmt.Fprintf(report, "## %s\n\n", comp) + + if err := c.compareComponent(report, comp); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to compare %s: %v\n", comp, err) + } + + fmt.Fprintf(report, "---\n\n") + } + + // Summary + c.writeSummary(report, baselineName, optimizedName) + + fmt.Printf("✅ Comparison complete: %s\n", reportPath) + return nil +} + +// compareComponent compares a single component +func (c *Comparator) compareComponent(report *os.File, component string) error { + baselineComp := filepath.Join(c.baselineDir, component) + optimizedComp := filepath.Join(c.optimizedDir, component) + + // Check if both directories exist + if _, err := os.Stat(baselineComp); os.IsNotExist(err) { + fmt.Fprintf(report, "⚠️ Baseline profiles not found\n\n") + return nil + } + if _, err := os.Stat(optimizedComp); os.IsNotExist(err) { + fmt.Fprintf(report, "⚠️ Optimized profiles not found\n\n") + return nil + } + + // Find peak profiles + baselinePeak, baselineSize, err := findPeakProfile(baselineComp, "heap*.pprof") + if err != nil { + return err + } + + optimizedPeak, optimizedSize, err := findPeakProfile(optimizedComp, "heap*.pprof") + if err != nil { + return err + } + + // Extract memory usage + baselineMem, _ := c.getMemoryUsage(baselineComp, baselinePeak) + optimizedMem, _ := c.getMemoryUsage(optimizedComp, optimizedPeak) + + // Calculate improvement + improvement := c.calculateImprovement(baselineMem, optimizedMem) + + fmt.Fprintf(report, "### Memory Comparison\n\n") + fmt.Fprintf(report, "| Metric | Baseline | Optimized | Change |\n") + fmt.Fprintf(report, "|--------|----------|-----------|--------|\n") + fmt.Fprintf(report, "| Peak Profile Size | %s | %s | %s |\n", + baselineSize, optimizedSize, c.formatChange(baselineSize, optimizedSize)) + fmt.Fprintf(report, "| Peak Memory Usage | %s | %s | %s |\n", + baselineMem, optimizedMem, improvement) + fmt.Fprintf(report, "\n") + + // Detailed comparison + fmt.Fprintf(report, "### Top Differences\n\n") + fmt.Fprintf(report, "```\n") + if err := c.runDiff(report, baselineComp, baselinePeak, optimizedComp, optimizedPeak); err != nil { + fmt.Fprintf(report, "Unable to generate diff\n") + } + fmt.Fprintf(report, "```\n\n") + + // OpenAPI comparison + fmt.Fprintf(report, "### OpenAPI Comparison\n\n") + baselineOpenAPI := c.getOpenAPIUsage(baselineComp, baselinePeak) + optimizedOpenAPI := c.getOpenAPIUsage(optimizedComp, optimizedPeak) + + if baselineOpenAPI != "" || optimizedOpenAPI != "" { + fmt.Fprintf(report, "**Baseline OpenAPI Usage:**\n```\n%s\n```\n\n", baselineOpenAPI) + fmt.Fprintf(report, "**Optimized OpenAPI Usage:**\n```\n%s\n```\n\n", optimizedOpenAPI) + } else { + fmt.Fprintf(report, "No significant OpenAPI usage detected in either profile\n\n") + } + + return nil +} + +// getComponents returns the list of components to compare +func (c *Comparator) getComponents() ([]string, error) { + entries, err := os.ReadDir(c.baselineDir) + if err != nil { + return nil, err + } + + var components []string + for _, entry := range entries { + if entry.IsDir() && entry.Name() != "comparisons" { + components = append(components, entry.Name()) + } + } + + return components, nil +} + +// runDiff runs pprof diff between baseline and optimized +func (c *Comparator) runDiff(w *os.File, baselineDir, baselinePeak, optimizedDir, optimizedPeak string) error { + // Change to baseline directory for the command + cmd := exec.Command("go", "tool", "pprof", + "-base="+baselinePeak, + "-top", + optimizedPeak) + cmd.Dir = baselineDir + + output, err := cmd.Output() + if err != nil { + return err + } + + // Limit output + lines := strings.Split(string(output), "\n") + if len(lines) > 25 { + lines = lines[:25] + } + fmt.Fprintf(w, "%s\n", strings.Join(lines, "\n")) + return nil +} + +// getMemoryUsage extracts memory usage from a profile +func (c *Comparator) getMemoryUsage(compDir, profile string) (string, error) { + cmd := exec.Command("go", "tool", "pprof", "-top", profile) + cmd.Dir = compDir + output, err := cmd.Output() + if err != nil { + return "", err + } + + // Extract "Showing nodes accounting for X, Y% of Z total" + re := regexp.MustCompile(`of ([0-9.]+[A-Za-z]+) total`) + matches := re.FindStringSubmatch(string(output)) + if len(matches) > 1 { + return matches[1], nil + } + + return "unknown", nil +} + +// getOpenAPIUsage extracts OpenAPI-related allocations +func (c *Comparator) getOpenAPIUsage(compDir, profile string) string { + cmd := exec.Command("go", "tool", "pprof", "-text", profile) + cmd.Dir = compDir + output, err := cmd.Output() + if err != nil { + return "" + } + + // Filter for openapi + re := regexp.MustCompile("(?i)openapi") + lines := strings.Split(string(output), "\n") + var matched []string + + for _, line := range lines { + if re.MatchString(line) { + matched = append(matched, line) + } + } + + if len(matched) == 0 { + return "" + } + + if len(matched) > 10 { + matched = matched[:10] + } + + return strings.Join(matched, "\n") +} + +// calculateImprovement calculates the improvement percentage +func (c *Comparator) calculateImprovement(baseline, optimized string) string { + baselineVal := parseMemory(baseline) + optimizedVal := parseMemory(optimized) + + if baselineVal == 0 { + return "N/A" + } + + diff := optimizedVal - baselineVal + pct := (diff / baselineVal) * 100 + + if diff < 0 { + return fmt.Sprintf("%.1f%% reduction", -pct) + } + return fmt.Sprintf("+%.1f%%", pct) +} + +// formatChange formats the change between two values +func (c *Comparator) formatChange(baseline, optimized string) string { + baselineVal := parseSize(baseline) + optimizedVal := parseSize(optimized) + + if baselineVal == 0 { + return "N/A" + } + + diff := optimizedVal - baselineVal + pct := (diff / baselineVal) * 100 + + if diff < 0 { + return fmt.Sprintf("%.1f%%", pct) + } + return fmt.Sprintf("+%.1f%%", pct) +} + +// writeSummary writes the summary section +func (c *Comparator) writeSummary(report *os.File, baseline, optimized string) { + fmt.Fprintf(report, "## Summary\n\n") + fmt.Fprintf(report, "This comparison shows the differences between:\n") + fmt.Fprintf(report, "- **Baseline**: %s\n", baseline) + fmt.Fprintf(report, "- **Optimized**: %s\n\n", optimized) + fmt.Fprintf(report, "Look for negative percentages (reductions) as improvements.\n") +} + +// Helper functions + +func findPeakProfile(compDir, pattern string) (string, string, error) { + profiles, err := filepath.Glob(filepath.Join(compDir, pattern)) + if err != nil { + return "", "", err + } + + if len(profiles) == 0 { + return "", "", fmt.Errorf("no profiles found") + } + + var largest string + var largestSize int64 + + for _, profile := range profiles { + info, err := os.Stat(profile) + if err != nil { + continue + } + if info.Size() > largestSize { + largest = profile + largestSize = info.Size() + } + } + + sizeStr := fmt.Sprintf("%dK", largestSize/1024) + return largest, sizeStr, nil +} + +func parseMemory(s string) float64 { + re := regexp.MustCompile(`([0-9.]+)([A-Za-z]+)`) + matches := re.FindStringSubmatch(s) + if len(matches) < 3 { + return 0 + } + + var val float64 + fmt.Sscanf(matches[1], "%f", &val) + + unit := strings.ToLower(matches[2]) + switch unit { + case "kb": + return val + case "mb": + return val * 1024 + case "gb": + return val * 1024 * 1024 + } + + return val +} + +func parseSize(s string) float64 { + re := regexp.MustCompile(`([0-9.]+)([A-Z])`) + matches := re.FindStringSubmatch(s) + if len(matches) < 3 { + return 0 + } + + var val float64 + fmt.Sscanf(matches[1], "%f", &val) + + return val +} diff --git a/hack/tools/test-profiling/pkg/comparator/comparator_test.go b/hack/tools/test-profiling/pkg/comparator/comparator_test.go new file mode 100644 index 000000000..5f93d8adb --- /dev/null +++ b/hack/tools/test-profiling/pkg/comparator/comparator_test.go @@ -0,0 +1,281 @@ +package comparator + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNewComparator(t *testing.T) { + c := NewComparator("/baseline", "/optimized", "/output") + + if c == nil { + t.Fatal("Expected non-nil Comparator") + } + + if c.baselineDir != "/baseline" { + t.Errorf("Expected baseline dir /baseline, got %s", c.baselineDir) + } + + if c.optimizedDir != "/optimized" { + t.Errorf("Expected optimized dir /optimized, got %s", c.optimizedDir) + } + + if c.outputDir != "/output" { + t.Errorf("Expected output dir /output, got %s", c.outputDir) + } +} + +func TestParseMemory(t *testing.T) { + tests := []struct { + input string + expected float64 + }{ + {"100KB", 100}, + {"100kb", 100}, + {"2.5MB", 2560}, + {"2.5mb", 2560}, + {"1GB", 1048576}, + {"1gb", 1048576}, + {"invalid", 0}, + {"", 0}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := parseMemory(tt.input) + if result != tt.expected { + t.Errorf("parseMemory(%s) = %f, expected %f", tt.input, result, tt.expected) + } + }) + } +} + +func TestParseSize(t *testing.T) { + tests := []struct { + input string + expected float64 + }{ + {"100K", 100}, + {"50K", 50}, + {"invalid", 0}, + {"", 0}, + {"123", 0}, // No unit + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := parseSize(tt.input) + if result != tt.expected { + t.Errorf("parseSize(%s) = %f, expected %f", tt.input, result, tt.expected) + } + }) + } +} + +func TestCalculateImprovement(t *testing.T) { + c := NewComparator("", "", "") + + tests := []struct { + name string + baseline string + optimized string + expected string + }{ + { + name: "reduction", + baseline: "100MB", + optimized: "80MB", + expected: "20.0% reduction", + }, + { + name: "increase", + baseline: "100MB", + optimized: "120MB", + expected: "+20.0%", + }, + { + name: "no change", + baseline: "100MB", + optimized: "100MB", + expected: "+0.0%", + }, + { + name: "zero baseline", + baseline: "0MB", + optimized: "100MB", + expected: "N/A", + }, + { + name: "invalid baseline", + baseline: "invalid", + optimized: "100MB", + expected: "N/A", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := c.calculateImprovement(tt.baseline, tt.optimized) + if result != tt.expected { + t.Errorf("calculateImprovement(%s, %s) = %s, expected %s", + tt.baseline, tt.optimized, result, tt.expected) + } + }) + } +} + +func TestFormatChange(t *testing.T) { + c := NewComparator("", "", "") + + tests := []struct { + name string + baseline string + optimized string + contains string // Check if result contains this string + }{ + { + name: "reduction", + baseline: "100K", + optimized: "80K", + contains: "-20", + }, + { + name: "increase", + baseline: "100K", + optimized: "120K", + contains: "+20", + }, + { + name: "no change", + baseline: "100K", + optimized: "100K", + contains: "0.0", + }, + { + name: "zero baseline", + baseline: "0K", + optimized: "100K", + contains: "N/A", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := c.formatChange(tt.baseline, tt.optimized) + if tt.contains != "" && result != "N/A" { + // For non-N/A results, just verify format looks reasonable + if result == "" { + t.Errorf("formatChange(%s, %s) returned empty string", + tt.baseline, tt.optimized) + } + } else if tt.contains == "" && result != "N/A" { + t.Errorf("formatChange(%s, %s) = %s, expected N/A", + tt.baseline, tt.optimized, result) + } + }) + } +} + +func TestGetComponents(t *testing.T) { + tmpDir := t.TempDir() + + // Create component directories + components := []string{"operator-controller", "catalogd", "other-component"} + for _, comp := range components { + if err := os.MkdirAll(filepath.Join(tmpDir, comp), 0755); err != nil { + t.Fatal(err) + } + } + + // Create a file (should be ignored) + if err := os.WriteFile(filepath.Join(tmpDir, "file.txt"), []byte("test"), 0644); err != nil { + t.Fatal(err) + } + + // Create comparisons directory (should be ignored) + if err := os.MkdirAll(filepath.Join(tmpDir, "comparisons"), 0755); err != nil { + t.Fatal(err) + } + + c := NewComparator(tmpDir, "", "") + + result, err := c.getComponents() + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(result) != 3 { + t.Errorf("Expected 3 components, got %d", len(result)) + } + + // Verify specific components are present + found := make(map[string]bool) + for _, comp := range result { + found[comp] = true + } + + for _, expected := range components { + if !found[expected] { + t.Errorf("Expected component %s not found in results", expected) + } + } + + if found["comparisons"] { + t.Error("comparisons directory should be excluded") + } +} + +func TestGetComponents_NonExistentDir(t *testing.T) { + c := NewComparator("/nonexistent", "", "") + + _, err := c.getComponents() + if err == nil { + t.Error("Expected error for non-existent directory") + } +} + +func TestFindPeakProfile_Comparator(t *testing.T) { + tmpDir := t.TempDir() + + // Create test files + testFiles := map[string]int{ + "heap0.pprof": 1024, + "heap1.pprof": 2048, + "heap2.pprof": 512, + } + + for name, size := range testFiles { + path := filepath.Join(tmpDir, name) + data := make([]byte, size) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + } + + peakPath, sizeStr, err := findPeakProfile(tmpDir, "heap*.pprof") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if filepath.Base(peakPath) != "heap1.pprof" { + t.Errorf("Expected peak profile heap1.pprof, got %s", filepath.Base(peakPath)) + } + + if sizeStr != "2K" { + t.Errorf("Expected size 2K, got %s", sizeStr) + } +} + +// Note: Testing Compare() requires real pprof files and go tool pprof. +// This would be an integration test. +// +// Example integration test structure: +// func TestCompare_Integration(t *testing.T) { +// if testing.Short() { +// t.Skip("Skipping integration test") +// } +// // Setup baseline and optimized directories with real pprof files +// // Run comparator +// // Verify comparison report generation +// } diff --git a/hack/tools/test-profiling/pkg/config/config.go b/hack/tools/test-profiling/pkg/config/config.go new file mode 100644 index 000000000..3fe5d0dd9 --- /dev/null +++ b/hack/tools/test-profiling/pkg/config/config.go @@ -0,0 +1,135 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "time" +) + +// Config holds all configuration for e2e profiling +type Config struct { + // Namespace to profile + Namespace string + + // Collection interval + Interval time.Duration + + // CPU profiling duration + CPUDuration time.Duration + + // Profile mode: both, heap, cpu + Mode string + + // Output directory + OutputDir string + + // Test target (make target) + TestTarget string + + // Profile name + Name string + + // Components to profile + Components []ComponentConfig +} + +// ComponentConfig defines a component to profile +type ComponentConfig struct { + Name string + Namespace string + Deployment string + Port int + LocalPort int +} + +// DefaultConfig returns configuration with defaults from environment +func DefaultConfig() *Config { + defaultNamespace := getEnv("TEST_PROFILE_NAMESPACE", "olmv1-system") + + return &Config{ + Namespace: defaultNamespace, + Interval: getDurationEnv("TEST_PROFILE_INTERVAL", 10*time.Second), + CPUDuration: getDurationEnv("TEST_PROFILE_CPU_DURATION", 10*time.Second), + Mode: getEnv("TEST_PROFILE_MODE", "both"), + OutputDir: getEnv("TEST_PROFILE_DIR", "./test-profiles"), + TestTarget: getEnv("TEST_PROFILE_TEST_TARGET", "test-experimental-e2e"), + Components: []ComponentConfig{ + { + Name: "operator-controller", + Namespace: getEnv("TEST_PROFILE_OPERATOR_CONTROLLER_NAMESPACE", defaultNamespace), + Deployment: "operator-controller-controller-manager", + Port: 6060, + LocalPort: 6060, + }, + { + Name: "catalogd", + Namespace: getEnv("TEST_PROFILE_CATALOGD_NAMESPACE", defaultNamespace), + Deployment: "catalogd-controller-manager", + Port: 6060, + LocalPort: 6061, + }, + }, + } +} + +// ProfileDir returns the full path to the profile directory +func (c *Config) ProfileDir() string { + return filepath.Join(c.OutputDir, c.Name) +} + +// ComponentDir returns the directory for a specific component +func (c *Config) ComponentDir(component string) string { + return filepath.Join(c.ProfileDir(), component) +} + +// PIDFile returns the path to the PID file +func (c *Config) PIDFile() string { + return filepath.Join(c.ProfileDir(), ".profiler.pid") +} + +// CollectHeap returns whether to collect heap profiles +func (c *Config) CollectHeap() bool { + return c.Mode == "both" || c.Mode == "heap" +} + +// CollectCPU returns whether to collect CPU profiles +func (c *Config) CollectCPU() bool { + return c.Mode == "both" || c.Mode == "cpu" +} + +// Validate checks if the configuration is valid +func (c *Config) Validate() error { + if c.Name == "" { + return fmt.Errorf("profile name is required") + } + if c.Mode != "both" && c.Mode != "heap" && c.Mode != "cpu" { + return fmt.Errorf("mode must be 'both', 'heap', or 'cpu', got: %s", c.Mode) + } + if c.Interval <= 0 { + return fmt.Errorf("interval must be positive") + } + if c.CPUDuration <= 0 { + return fmt.Errorf("CPU duration must be positive") + } + return nil +} + +// getEnv gets an environment variable or returns a default +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// getDurationEnv gets a duration from environment (in seconds) or returns default +func getDurationEnv(key string, defaultValue time.Duration) time.Duration { + if value := os.Getenv(key); value != "" { + if seconds, err := strconv.Atoi(value); err == nil { + return time.Duration(seconds) * time.Second + } + } + return defaultValue +} diff --git a/hack/tools/test-profiling/pkg/config/config_test.go b/hack/tools/test-profiling/pkg/config/config_test.go new file mode 100644 index 000000000..b301374e2 --- /dev/null +++ b/hack/tools/test-profiling/pkg/config/config_test.go @@ -0,0 +1,292 @@ +package config + +import ( + "os" + "testing" + "time" +) + +func TestDefaultConfig(t *testing.T) { + // Clear environment + os.Clearenv() + + cfg := DefaultConfig() + + if cfg.Namespace != "olmv1-system" { + t.Errorf("Expected namespace olmv1-system, got %s", cfg.Namespace) + } + + if cfg.Interval != 10*time.Second { + t.Errorf("Expected interval 10s, got %v", cfg.Interval) + } + + if cfg.CPUDuration != 10*time.Second { + t.Errorf("Expected CPU duration 10s, got %v", cfg.CPUDuration) + } + + if cfg.Mode != "both" { + t.Errorf("Expected mode both, got %s", cfg.Mode) + } + + if cfg.OutputDir != "./test-profiles" { + t.Errorf("Expected output dir ./test-profiles, got %s", cfg.OutputDir) + } + + if cfg.TestTarget != "test-experimental-e2e" { + t.Errorf("Expected test target test-experimental-e2e, got %s", cfg.TestTarget) + } + + if len(cfg.Components) != 2 { + t.Errorf("Expected 2 components, got %d", len(cfg.Components)) + } +} + +func TestConfigFromEnvironment(t *testing.T) { + os.Clearenv() + os.Setenv("TEST_PROFILE_NAMESPACE", "test-namespace") + os.Setenv("TEST_PROFILE_INTERVAL", "5") + os.Setenv("TEST_PROFILE_CPU_DURATION", "15") + os.Setenv("TEST_PROFILE_MODE", "heap") + os.Setenv("TEST_PROFILE_DIR", "./custom-dir") + os.Setenv("TEST_PROFILE_TEST_TARGET", "test-e2e") + + cfg := DefaultConfig() + + if cfg.Namespace != "test-namespace" { + t.Errorf("Expected namespace test-namespace, got %s", cfg.Namespace) + } + + if cfg.Interval != 5*time.Second { + t.Errorf("Expected interval 5s, got %v", cfg.Interval) + } + + if cfg.CPUDuration != 15*time.Second { + t.Errorf("Expected CPU duration 15s, got %v", cfg.CPUDuration) + } + + if cfg.Mode != "heap" { + t.Errorf("Expected mode heap, got %s", cfg.Mode) + } + + if cfg.OutputDir != "./custom-dir" { + t.Errorf("Expected output dir ./custom-dir, got %s", cfg.OutputDir) + } + + if cfg.TestTarget != "test-e2e" { + t.Errorf("Expected test target test-e2e, got %s", cfg.TestTarget) + } + + os.Clearenv() +} + +func TestConfigValidation(t *testing.T) { + tests := []struct { + name string + config *Config + expectErr bool + }{ + { + name: "valid config", + config: &Config{ + Name: "test", + Mode: "both", + Interval: 10 * time.Second, + CPUDuration: 10 * time.Second, + }, + expectErr: false, + }, + { + name: "empty name", + config: &Config{ + Name: "", + Mode: "both", + Interval: 10 * time.Second, + CPUDuration: 10 * time.Second, + }, + expectErr: true, + }, + { + name: "invalid mode", + config: &Config{ + Name: "test", + Mode: "invalid", + Interval: 10 * time.Second, + CPUDuration: 10 * time.Second, + }, + expectErr: true, + }, + { + name: "negative interval", + config: &Config{ + Name: "test", + Mode: "both", + Interval: -1 * time.Second, + CPUDuration: 10 * time.Second, + }, + expectErr: true, + }, + { + name: "zero CPU duration", + config: &Config{ + Name: "test", + Mode: "both", + Interval: 10 * time.Second, + CPUDuration: 0, + }, + expectErr: true, + }, + { + name: "heap mode", + config: &Config{ + Name: "test", + Mode: "heap", + Interval: 10 * time.Second, + CPUDuration: 10 * time.Second, + }, + expectErr: false, + }, + { + name: "cpu mode", + config: &Config{ + Name: "test", + Mode: "cpu", + Interval: 10 * time.Second, + CPUDuration: 10 * time.Second, + }, + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if tt.expectErr && err == nil { + t.Error("Expected error but got nil") + } + if !tt.expectErr && err != nil { + t.Errorf("Expected no error but got: %v", err) + } + }) + } +} + +func TestProfileDir(t *testing.T) { + cfg := &Config{ + Name: "test-run", + OutputDir: "/tmp/profiles", + } + + expected := "/tmp/profiles/test-run" + if cfg.ProfileDir() != expected { + t.Errorf("Expected profile dir %s, got %s", expected, cfg.ProfileDir()) + } +} + +func TestComponentDir(t *testing.T) { + cfg := &Config{ + Name: "test-run", + OutputDir: "/tmp/profiles", + } + + expected := "/tmp/profiles/test-run/operator-controller" + if cfg.ComponentDir("operator-controller") != expected { + t.Errorf("Expected component dir %s, got %s", expected, cfg.ComponentDir("operator-controller")) + } +} + +func TestPIDFile(t *testing.T) { + cfg := &Config{ + Name: "test-run", + OutputDir: "/tmp/profiles", + } + + expected := "/tmp/profiles/test-run/.profiler.pid" + if cfg.PIDFile() != expected { + t.Errorf("Expected PID file %s, got %s", expected, cfg.PIDFile()) + } +} + +func TestCollectHeap(t *testing.T) { + tests := []struct { + mode string + expected bool + }{ + {"both", true}, + {"heap", true}, + {"cpu", false}, + } + + for _, tt := range tests { + t.Run(tt.mode, func(t *testing.T) { + cfg := &Config{Mode: tt.mode} + if cfg.CollectHeap() != tt.expected { + t.Errorf("Expected CollectHeap() = %v for mode %s", tt.expected, tt.mode) + } + }) + } +} + +func TestCollectCPU(t *testing.T) { + tests := []struct { + mode string + expected bool + }{ + {"both", true}, + {"cpu", true}, + {"heap", false}, + } + + for _, tt := range tests { + t.Run(tt.mode, func(t *testing.T) { + cfg := &Config{Mode: tt.mode} + if cfg.CollectCPU() != tt.expected { + t.Errorf("Expected CollectCPU() = %v for mode %s", tt.expected, tt.mode) + } + }) + } +} + +func TestGetEnv(t *testing.T) { + os.Clearenv() + + // Test default value + val := getEnv("NONEXISTENT_VAR", "default") + if val != "default" { + t.Errorf("Expected 'default', got %s", val) + } + + // Test existing value + os.Setenv("TEST_VAR", "custom") + val = getEnv("TEST_VAR", "default") + if val != "custom" { + t.Errorf("Expected 'custom', got %s", val) + } + + os.Clearenv() +} + +func TestGetDurationEnv(t *testing.T) { + os.Clearenv() + + // Test default value + dur := getDurationEnv("NONEXISTENT_VAR", 5*time.Second) + if dur != 5*time.Second { + t.Errorf("Expected 5s, got %v", dur) + } + + // Test valid integer + os.Setenv("TEST_DURATION", "10") + dur = getDurationEnv("TEST_DURATION", 5*time.Second) + if dur != 10*time.Second { + t.Errorf("Expected 10s, got %v", dur) + } + + // Test invalid value (should use default) + os.Setenv("TEST_DURATION", "invalid") + dur = getDurationEnv("TEST_DURATION", 5*time.Second) + if dur != 5*time.Second { + t.Errorf("Expected default 5s for invalid value, got %v", dur) + } + + os.Clearenv() +} diff --git a/hack/tools/test-profiling/pkg/kubernetes/portforward.go b/hack/tools/test-profiling/pkg/kubernetes/portforward.go new file mode 100644 index 000000000..69b33a478 --- /dev/null +++ b/hack/tools/test-profiling/pkg/kubernetes/portforward.go @@ -0,0 +1,344 @@ +package kubernetes + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/portforward" + "k8s.io/client-go/transport/spdy" +) + +// PortForwarder manages port-forwarding to a deployment +type PortForwarder struct { + namespace string + deployment string + remotePort int + localPort int + clientset *kubernetes.Clientset + config *rest.Config + stopChan chan struct{} + readyChan chan struct{} +} + +// NewPortForwarder creates a new port forwarder +func NewPortForwarder(namespace, deployment string, remotePort, localPort int) *PortForwarder { + return &PortForwarder{ + namespace: namespace, + deployment: deployment, + remotePort: remotePort, + localPort: localPort, + } +} + +// Start starts port-forwarding in the background +func (pf *PortForwarder) Start(ctx context.Context) error { + // Get kubeconfig + config, err := getKubeConfig() + if err != nil { + return fmt.Errorf("failed to get kubeconfig: %w", err) + } + pf.config = config + + // Create clientset + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return fmt.Errorf("failed to create kubernetes client: %w", err) + } + pf.clientset = clientset + + // Find pod for deployment + podName, err := pf.findPodForDeployment(ctx) + if err != nil { + return fmt.Errorf("failed to find pod: %w", err) + } + + // Setup port forward + pf.stopChan = make(chan struct{}, 1) + pf.readyChan = make(chan struct{}) + + go func() { + if err := pf.portForward(ctx, podName); err != nil { + fmt.Fprintf(os.Stderr, "Port forward error: %v\n", err) + } + }() + + // Wait for port-forward to be ready + select { + case <-pf.readyChan: + // Ready + case <-time.After(30 * time.Second): + pf.Stop() + return fmt.Errorf("timeout waiting for port-forward to be ready") + case <-ctx.Done(): + pf.Stop() + return ctx.Err() + } + + // Additional check that the pprof endpoint is accessible + if err := pf.waitReady(30 * time.Second); err != nil { + pf.Stop() + return err + } + + return nil +} + +// Stop stops the port-forwarding +func (pf *PortForwarder) Stop() { + if pf.stopChan != nil { + close(pf.stopChan) + } +} + +// findPodForDeployment finds a running pod for the given deployment +func (pf *PortForwarder) findPodForDeployment(ctx context.Context) (string, error) { + // Get the deployment to find its label selector + deploymentsClient := pf.clientset.AppsV1().Deployments(pf.namespace) + deployment, err := deploymentsClient.Get(ctx, pf.deployment, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get deployment: %w", err) + } + + // List pods matching the deployment's selector + labelSelector := metav1.FormatLabelSelector(deployment.Spec.Selector) + pods, err := pf.clientset.CoreV1().Pods(pf.namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + FieldSelector: "status.phase=Running", + }) + if err != nil { + return "", fmt.Errorf("failed to list pods: %w", err) + } + + if len(pods.Items) == 0 { + return "", fmt.Errorf("no running pods found for deployment %s", pf.deployment) + } + + // Return the first running pod + return pods.Items[0].Name, nil +} + +// portForward establishes the port forward connection +func (pf *PortForwarder) portForward(ctx context.Context, podName string) error { + // Build URL for port forward + req := pf.clientset.CoreV1().RESTClient().Post(). + Resource("pods"). + Namespace(pf.namespace). + Name(podName). + SubResource("portforward") + + transport, upgrader, err := spdy.RoundTripperFor(pf.config) + if err != nil { + return fmt.Errorf("failed to create round tripper: %w", err) + } + + dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, "POST", req.URL()) + + ports := []string{fmt.Sprintf("%d:%d", pf.localPort, pf.remotePort)} + + // Create port forwarder + fw, err := portforward.New(dialer, ports, pf.stopChan, pf.readyChan, os.Stdout, os.Stderr) + if err != nil { + return fmt.Errorf("failed to create port forwarder: %w", err) + } + + // Start forwarding + return fw.ForwardPorts() +} + +// waitReady waits for the port-forward to be ready by checking the pprof endpoint +func (pf *PortForwarder) waitReady(timeout time.Duration) error { + endpoint := fmt.Sprintf("http://localhost:%d/debug/pprof/", pf.localPort) + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + resp, err := http.Get(endpoint) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + time.Sleep(1 * time.Second) + } + + return fmt.Errorf("port-forward not ready after %v", timeout) +} + +// getKubeConfig returns the kubernetes config +func getKubeConfig() (*rest.Config, error) { + // Try in-cluster config first + config, err := rest.InClusterConfig() + if err == nil { + return config, nil + } + + // Fall back to kubeconfig file + kubeconfig := os.Getenv("KUBECONFIG") + if kubeconfig == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get home directory: %w", err) + } + kubeconfig = filepath.Join(home, ".kube", "config") + } + + config, err = clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + return nil, fmt.Errorf("failed to build kubeconfig: %w", err) + } + + return config, nil +} + +// WaitForNamespace waits for a namespace to exist +func WaitForNamespace(ctx context.Context, namespace string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + lastProgress := time.Now() + var clientset *kubernetes.Clientset + + for time.Now().Before(deadline) { + // Try to get kubeconfig (might not exist yet if cluster is starting) + if clientset == nil { + config, err := getKubeConfig() + if err != nil { + // Kubeconfig doesn't exist yet, wait and retry + if time.Since(lastProgress) >= 15*time.Second { + remaining := time.Until(deadline).Round(time.Second) + fmt.Printf(" Waiting for kubeconfig... (timeout in %v)\n", remaining) + lastProgress = time.Now() + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + continue + } + } + + var err2 error + clientset, err2 = kubernetes.NewForConfig(config) + if err2 != nil { + // Config exists but client creation failed, wait and retry + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + continue + } + } + } + + // Try to get the namespace + _, err := clientset.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) + if err == nil { + return nil + } + + // Print progress every 15 seconds + if time.Since(lastProgress) >= 15*time.Second { + remaining := time.Until(deadline).Round(time.Second) + fmt.Printf(" Still waiting for namespace %s... (timeout in %v)\n", namespace, remaining) + lastProgress = time.Now() + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + } + + return fmt.Errorf("namespace %s not found after %v", namespace, timeout) +} + +// WaitForDeployment waits for a deployment to be ready +func WaitForDeployment(ctx context.Context, namespace, deployment string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + lastProgress := time.Now() + var clientset *kubernetes.Clientset + + for time.Now().Before(deadline) { + // Try to get kubeconfig (might not exist yet if cluster is starting) + if clientset == nil { + config, err := getKubeConfig() + if err != nil { + // Kubeconfig doesn't exist yet, wait and retry + if time.Since(lastProgress) >= 15*time.Second { + remaining := time.Until(deadline).Round(time.Second) + fmt.Printf(" Waiting for kubeconfig... (timeout in %v)\n", remaining) + lastProgress = time.Now() + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + continue + } + } + + var err2 error + clientset, err2 = kubernetes.NewForConfig(config) + if err2 != nil { + // Config exists but client creation failed, wait and retry + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + continue + } + } + } + + dep, err := clientset.AppsV1().Deployments(namespace).Get(ctx, deployment, metav1.GetOptions{}) + if err != nil { + // Print progress every 15 seconds even if deployment doesn't exist yet + if time.Since(lastProgress) >= 15*time.Second { + remaining := time.Until(deadline).Round(time.Second) + fmt.Printf(" Still waiting for deployment %s... (timeout in %v)\n", deployment, remaining) + lastProgress = time.Now() + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + continue + } + } + + // Check if deployment is available + for _, cond := range dep.Status.Conditions { + if cond.Type == "Available" && cond.Status == corev1.ConditionTrue { + return nil + } + } + + // Print progress every 15 seconds + if time.Since(lastProgress) >= 15*time.Second { + remaining := time.Until(deadline).Round(time.Second) + ready := dep.Status.ReadyReplicas + desired := dep.Status.Replicas + fmt.Printf(" Deployment %s: %d/%d replicas ready (timeout in %v)\n", deployment, ready, desired, remaining) + lastProgress = time.Now() + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + } + + return fmt.Errorf("deployment %s not ready after %v", deployment, timeout) +} diff --git a/hack/tools/test-profiling/pkg/kubernetes/portforward_test.go b/hack/tools/test-profiling/pkg/kubernetes/portforward_test.go new file mode 100644 index 000000000..65a30bab4 --- /dev/null +++ b/hack/tools/test-profiling/pkg/kubernetes/portforward_test.go @@ -0,0 +1,50 @@ +package kubernetes + +import ( + "testing" +) + +func TestNewPortForwarder(t *testing.T) { + pf := NewPortForwarder("test-namespace", "deployment/test-deployment", 8080, 9090) + + if pf == nil { + t.Fatal("Expected non-nil PortForwarder") + } + + if pf.namespace != "test-namespace" { + t.Errorf("Expected namespace test-namespace, got %s", pf.namespace) + } + + if pf.deployment != "deployment/test-deployment" { + t.Errorf("Expected deployment deployment/test-deployment, got %s", pf.deployment) + } + + if pf.remotePort != 8080 { + t.Errorf("Expected remote port 8080, got %d", pf.remotePort) + } + + if pf.localPort != 9090 { + t.Errorf("Expected local port 9090, got %d", pf.localPort) + } +} + +func TestPortForwarderStop_BeforeStart(t *testing.T) { + pf := NewPortForwarder("test-namespace", "deployment/test-deployment", 8080, 9090) + + // Should not panic when stopping before starting + pf.Stop() +} + +// Note: Testing Start() requires a real Kubernetes cluster with the deployment available. +// These would be integration tests. Example structure: +// +// func TestPortForwarder_Integration(t *testing.T) { +// if testing.Short() { +// t.Skip("Skipping integration test") +// } +// // Setup test cluster and deployment +// // Test Start(), waitReady(), and Stop() +// } + +// Note: Testing WaitForNamespace() and WaitForDeployment() requires a real Kubernetes cluster. +// These would be integration tests that use a test cluster with kubectl configured. diff --git a/helm/e2e.yaml b/helm/e2e.yaml index 11d51ddad..eebf3265b 100644 --- a/helm/e2e.yaml +++ b/helm/e2e.yaml @@ -6,3 +6,5 @@ options: e2e: enabled: true + profiling: + enabled: true diff --git a/helm/olmv1/templates/deployment-olmv1-system-catalogd-controller-manager.yml b/helm/olmv1/templates/deployment-olmv1-system-catalogd-controller-manager.yml index b3df12139..092cb7a24 100644 --- a/helm/olmv1/templates/deployment-olmv1-system-catalogd-controller-manager.yml +++ b/helm/olmv1/templates/deployment-olmv1-system-catalogd-controller-manager.yml @@ -44,6 +44,9 @@ spec: - --leader-elect {{- end }} - --metrics-bind-address=:7443 + {{- if .Values.options.profiling.enabled }} + - --pprof-bind-address=:6060 + {{- end }} - --external-address=catalogd-service.{{ .Values.namespaces.olmv1.name }}.svc {{- range .Values.options.catalogd.features.enabled }} - --feature-gates={{- . -}}=true diff --git a/helm/olmv1/templates/deployment-olmv1-system-operator-controller-controller-manager.yml b/helm/olmv1/templates/deployment-olmv1-system-operator-controller-controller-manager.yml index 9ec405a3e..249610244 100644 --- a/helm/olmv1/templates/deployment-olmv1-system-operator-controller-controller-manager.yml +++ b/helm/olmv1/templates/deployment-olmv1-system-operator-controller-controller-manager.yml @@ -41,6 +41,9 @@ spec: - args: - --health-probe-bind-address=:8081 - --metrics-bind-address=:8443 + {{- if .Values.options.profiling.enabled }} + - --pprof-bind-address=:6060 + {{- end }} {{- if not .Values.options.tilt.enabled }} - --leader-elect {{- end }} diff --git a/helm/olmv1/values.yaml b/helm/olmv1/values.yaml index 0704f43ef..5ab9d7672 100644 --- a/helm/olmv1/values.yaml +++ b/helm/olmv1/values.yaml @@ -24,6 +24,8 @@ options: enabled: false e2e: enabled: false + profiling: + enabled: false tilt: enabled: false openshift: diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml index 1efa8b8d9..db03c11a8 100644 --- a/manifests/experimental-e2e.yaml +++ b/manifests/experimental-e2e.yaml @@ -2037,6 +2037,7 @@ spec: - args: - --leader-elect - --metrics-bind-address=:7443 + - --pprof-bind-address=:6060 - --external-address=catalogd-service.olmv1-system.svc - --feature-gates=APIV1MetasHandler=true - --tls-cert=/var/certs/tls.crt @@ -2187,6 +2188,7 @@ spec: - args: - --health-probe-bind-address=:8081 - --metrics-bind-address=:8443 + - --pprof-bind-address=:6060 - --leader-elect - --feature-gates=SingleOwnNamespaceInstallSupport=true - --feature-gates=PreflightPermissions=true diff --git a/manifests/standard-e2e.yaml b/manifests/standard-e2e.yaml index 783beec51..5c9590784 100644 --- a/manifests/standard-e2e.yaml +++ b/manifests/standard-e2e.yaml @@ -1784,6 +1784,7 @@ spec: - args: - --leader-elect - --metrics-bind-address=:7443 + - --pprof-bind-address=:6060 - --external-address=catalogd-service.olmv1-system.svc - --tls-cert=/var/certs/tls.crt - --tls-key=/var/certs/tls.key @@ -1933,6 +1934,7 @@ spec: - args: - --health-probe-bind-address=:8081 - --metrics-bind-address=:8443 + - --pprof-bind-address=:6060 - --leader-elect - --tls-cert=/var/certs/tls.crt - --tls-key=/var/certs/tls.key