diff --git a/cmd/analyze.go b/cmd/analyze.go index 06853d2..7d2e65b 100644 --- a/cmd/analyze.go +++ b/cmd/analyze.go @@ -233,7 +233,18 @@ func runAnalyze(cmd *cobra.Command, args []string) error { // Run analysis fmt.Fprintf(os.Stderr, "Analyzing project: %s on %s\n", projectPath, cleanGitlabURL) + // Start progress spinner (only when printing output and not in verbose mode) + sp := newSpinner() + if printOutput && !verbose { + conf.ProgressFunc = func(step, total int, message string) { + sp.Update(step, total, message) + } + sp.InstallLogHook() + sp.Start() + } + result, err := control.RunAnalysis(conf) + sp.Stop() if err != nil { return fmt.Errorf("analysis failed: %w", err) } diff --git a/cmd/spinner.go b/cmd/spinner.go new file mode 100644 index 0000000..698ffdd --- /dev/null +++ b/cmd/spinner.go @@ -0,0 +1,149 @@ +package cmd + +import ( + "fmt" + "os" + "sync" + "time" + + "github.com/sirupsen/logrus" +) + +// progressSpinner displays a progress indicator on stderr during long-running operations. +// It shows an animated spinner with the current step message and a progress bar. +type progressSpinner struct { + mu sync.Mutex + step int + total int + message string + done chan struct{} + stopped chan struct{} + started bool +} + +// newSpinner creates a new progressSpinner. Call Start() to begin animation. +func newSpinner() *progressSpinner { + return &progressSpinner{ + done: make(chan struct{}), + stopped: make(chan struct{}), + } +} + +// spinnerFrames are the spinner animation characters +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// Update sets the current progress step and message. +// This is safe to call from any goroutine. +func (s *progressSpinner) Update(step, total int, message string) { + s.mu.Lock() + defer s.mu.Unlock() + s.step = step + s.total = total + s.message = message +} + +// ClearLine erases the spinner line so log output can print cleanly. +func (s *progressSpinner) ClearLine() { + if s.started { + fmt.Fprintf(os.Stderr, "\r\033[K") + } +} + +// spinnerLogHook is a logrus hook that clears the spinner line before each log entry. +type spinnerLogHook struct { + spinner *progressSpinner +} + +func (h *spinnerLogHook) Levels() []logrus.Level { + return logrus.AllLevels +} + +func (h *spinnerLogHook) Fire(_ *logrus.Entry) error { + h.spinner.ClearLine() + return nil +} + +// InstallLogHook adds a logrus hook that clears the spinner line before each log message. +// This prevents log output from being interleaved with the spinner animation. +func (s *progressSpinner) InstallLogHook() { + logrus.AddHook(&spinnerLogHook{spinner: s}) +} + +// Start begins the spinner animation in a background goroutine. +// The spinner renders to stderr so it doesn't interfere with stdout output. +func (s *progressSpinner) Start() { + s.started = true + go func() { + defer close(s.stopped) + ticker := time.NewTicker(80 * time.Millisecond) + defer ticker.Stop() + frameIdx := 0 + + for { + select { + case <-s.done: + // Render final completion state before clearing + s.mu.Lock() + step := s.step + total := s.total + msg := s.message + s.mu.Unlock() + + if total > 0 { + bar := "" + for i := 0; i < 20; i++ { + bar += "█" + } + fmt.Fprintf(os.Stderr, "\r\033[K ✓ [%s] (%d/%d) %s\n", bar, step, total, msg) + } else { + fmt.Fprintf(os.Stderr, "\r\033[K") + } + return + case <-ticker.C: + s.mu.Lock() + step := s.step + total := s.total + msg := s.message + s.mu.Unlock() + + if total == 0 { + continue + } + + frame := spinnerFrames[frameIdx%len(spinnerFrames)] + frameIdx++ + + // Build progress bar + barWidth := 20 + filled := 0 + if total > 0 { + filled = (step * barWidth) / total + } + if filled > barWidth { + filled = barWidth + } + + bar := "" + for i := 0; i < barWidth; i++ { + if i < filled { + bar += "█" + } else { + bar += "░" + } + } + + // Render: ⠋ [████████░░░░░░░░░░░░] (3/14) Collecting pipeline origins + line := fmt.Sprintf("\r\033[K %s [%s] (%d/%d) %s", frame, bar, step, total, msg) + fmt.Fprint(os.Stderr, line) + } + } + }() +} + +// Stop terminates the spinner animation and waits for cleanup. +func (s *progressSpinner) Stop() { + if s.started { + close(s.done) + <-s.stopped // wait for the goroutine to finish rendering + } +} diff --git a/configuration/configuration.go b/configuration/configuration.go index c24526b..40c140a 100644 --- a/configuration/configuration.go +++ b/configuration/configuration.go @@ -46,6 +46,10 @@ type Configuration struct { ControlsFilter []string // SkipControlsFilter skips the listed controls when set; SkipControlsFilter []string + + // ProgressFunc is an optional callback invoked during analysis to report progress. + // step: current step number (1-based), total: total number of steps, message: description. + ProgressFunc func(step int, total int, message string) } // NewDefaultConfiguration creates a Configuration with sensible defaults diff --git a/control/task.go b/control/task.go index fde1a7e..f8acf99 100644 --- a/control/task.go +++ b/control/task.go @@ -56,6 +56,23 @@ func shouldRunControl(controlName string, conf *configuration.Configuration) boo return true } +// reportProgress calls the optional progress callback if configured. +func reportProgress(conf *configuration.Configuration, step, total int, message string) { + if conf.ProgressFunc != nil { + conf.ProgressFunc(step, total, message) + } +} + +// clearProgressLine clears the spinner line before writing direct stderr output. +func clearProgressLine(conf *configuration.Configuration) { + if conf.ProgressFunc != nil { + fmt.Fprint(os.Stderr, "\r\033[K") + } +} + +// analysisStepCount is the total number of progress steps reported during analysis. +const analysisStepCount = 12 + // RunAnalysis executes the complete pipeline analysis for a GitLab project func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { l := l.WithFields(logrus.Fields{ @@ -72,6 +89,7 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { /////////////////////// // Fetch Project Info from GitLab /////////////////////// + reportProgress(conf, 1, analysisStepCount, "Fetching project information") l.Info("Fetching project information from GitLab") project, err := gitlab.FetchProjectDetails(conf.ProjectPath, conf.GitlabToken, conf.GitlabURL, conf) if err != nil { @@ -138,6 +156,7 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { if content, err := os.ReadFile(localCIPath); err == nil { conf.LocalCIConfigContent = content conf.UsingLocalCIConfig = true + clearProgressLine(conf) fmt.Fprintf(os.Stderr, "Using local CI configuration (specify --branch to force upstream CI config fetch): %s\n", localCIPath) l.WithField("localCIPath", localCIPath).Info("Using local CI configuration file") } else { @@ -147,6 +166,7 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { }).Debug("Local CI config file not found, will use remote") } } else if conf.Branch != "" { + clearProgressLine(conf) fmt.Fprintf(os.Stderr, "Using remote CI configuration from branch: %s\n", projectInfo.AnalyzeBranch) } @@ -160,6 +180,7 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { /////////////////////// // 1. Run Pipeline Origin data collection + reportProgress(conf, 2, analysisStepCount, "Collecting pipeline origins") l.Info("Running Pipeline Origin data collection") originDC := &collector.GitlabPipelineOriginDataCollection{} pipelineOriginData, pipelineOriginMetrics, err := originDC.Run(projectInfo, conf.GitlabToken, conf) @@ -211,6 +232,7 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { } // 2. Run Pipeline Image data collection + reportProgress(conf, 3, analysisStepCount, "Collecting pipeline images") l.Info("Running Pipeline Image data collection") imageDC := &collector.GitlabPipelineImageDataCollection{} pipelineImageData, pipelineImageMetrics, err := imageDC.Run(projectInfo, conf.GitlabToken, conf, pipelineOriginData) @@ -241,6 +263,7 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { /////////////////// // 3. Run Forbidden Image Tags control + reportProgress(conf, 4, analysisStepCount, "Checking forbidden image tags") l.Info("Running Forbidden Image Tags control") // Load control configuration from PlumberConfig (required) @@ -258,6 +281,7 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { result.ImageForbiddenTagsResult = forbiddenTagsResult // 4. Run Image Authorized Sources control + reportProgress(conf, 5, analysisStepCount, "Checking authorized image sources") l.Info("Running Image Authorized Sources control") authorizedSourcesConf := &GitlabImageAuthorizedSourcesConf{} @@ -274,6 +298,7 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { result.ImageAuthorizedSourcesResult = authorizedSourcesResult // 5. Run Pipeline Must Not Include Hardcoded Jobs control + reportProgress(conf, 6, analysisStepCount, "Checking hardcoded jobs") l.Info("Running Pipeline Must Not Include Hardcoded Jobs control") hardcodedJobsConf := &GitlabPipelineHardcodedJobsConf{} @@ -290,6 +315,7 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { result.HardcodedJobsResult = hardcodedJobsResult // 6. Run Includes Must Be Up To Date control + reportProgress(conf, 7, analysisStepCount, "Checking includes versions") l.Info("Running Includes Must Be Up To Date control") outdatedConf := &GitlabPipelineIncludesOutdatedConf{} @@ -306,6 +332,7 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { result.OutdatedIncludesResult = outdatedResult // 7. Run Includes Must Not Use Forbidden Versions control + reportProgress(conf, 8, analysisStepCount, "Checking forbidden versions") l.Info("Running Includes Must Not Use Forbidden Versions control") forbiddenVersionConf := &GitlabPipelineIncludesForbiddenVersionConf{} @@ -322,6 +349,7 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { result.ForbiddenVersionsIncludesResult = forbiddenVersionResult // 8. Run Branch Must Be Protected control (if enabled) + reportProgress(conf, 9, analysisStepCount, "Checking branch protection") if shouldRunControl(controlBranchMustBeProtected, conf) { branchProtectionConfig := conf.PlumberConfig.GetBranchMustBeProtectedConfig() if branchProtectionConfig != nil && branchProtectionConfig.IsEnabled() { @@ -358,6 +386,7 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { } // 9. Run Pipeline Must Include Component control + reportProgress(conf, 10, analysisStepCount, "Checking required components") l.Info("Running Pipeline Must Include Component control") requiredComponentsConf := &GitlabPipelineRequiredComponentsConf{} @@ -374,6 +403,7 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { result.RequiredComponentsResult = requiredComponentsResult // 10. Run Pipeline Must Include Template control + reportProgress(conf, 11, analysisStepCount, "Checking required templates") l.Info("Running Pipeline Must Include Template control") requiredTemplatesConf := &GitlabPipelineRequiredTemplatesConf{} @@ -389,6 +419,8 @@ func RunAnalysis(conf *configuration.Configuration) (*AnalysisResult, error) { requiredTemplatesResult := requiredTemplatesConf.Run(pipelineOriginData) result.RequiredTemplatesResult = requiredTemplatesResult + reportProgress(conf, analysisStepCount, analysisStepCount, "Analysis complete") + l.WithFields(logrus.Fields{ "ciValid": result.CiValid, "ciMissing": result.CiMissing,