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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions cmd/analyze.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
149 changes: 149 additions & 0 deletions cmd/spinner.go
Original file line number Diff line number Diff line change
@@ -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
}
}
4 changes: 4 additions & 0 deletions configuration/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions control/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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{}
Expand All @@ -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{}
Expand All @@ -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{}
Expand All @@ -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{}
Expand All @@ -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() {
Expand Down Expand Up @@ -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{}
Expand All @@ -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{}
Expand All @@ -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,
Expand Down