diff --git a/DOCS.md b/DOCS.md index 744bdd6..627abc6 100644 --- a/DOCS.md +++ b/DOCS.md @@ -62,6 +62,7 @@ Safety first, the host and token are stored in Drone Secrets. * TRACE: Display DEBUG logs + the timings of all ElasticSearch queries and Web API calls executed by the SonarQube Scanner. * `showProfiling`: Display logs to see where the analyzer spends time. Default value `false` * `branchAnalysis`: Pass currently analysed branch to SonarQube. (Must not be active for initial scan!) Default value `false` +* `enableGateBreaker`: Abort pipeline if quality gate fais. Default value `false` * `usingProperties`: Using the `sonar-project.properties` file in root directory as sonar parameters. (Not include `sonar_host` and diff --git a/main.go b/main.go index d23168f..4fa8ccb 100755 --- a/main.go +++ b/main.go @@ -92,31 +92,39 @@ func main() { Usage: "using sonar-project.properties", EnvVar: "PLUGIN_USINGPROPERTIES", }, + cli.BoolFlag{ + Name: "enableGateBreaker", + Usage: "fail if quality gate fails", + EnvVar: "PLUGIN_ENABLEGATEBREAKER", + }, } app.Run(os.Args) } func run(c *cli.Context) { - plugin := Plugin{ - Config: Config{ - Key: c.String("key"), - Name: c.String("name"), - Host: c.String("host"), - Token: c.String("token"), - - Version: c.String("ver"), - Branch: c.String("branch"), - Timeout: c.String("timeout"), - Sources: c.String("sources"), - Inclusions: c.String("inclusions"), - Exclusions: c.String("exclusions"), - Level: c.String("level"), - ShowProfiling: c.String("showProfiling"), - BranchAnalysis: c.Bool("branchAnalysis"), - UsingProperties: c.Bool("usingProperties"), + config := Config{ + Key: c.String("key"), + Name: c.String("name"), + Host: c.String("host"), + Token: c.String("token"), - }, + Version: c.String("ver"), + Branch: c.String("branch"), + Timeout: c.String("timeout"), + Sources: c.String("sources"), + Inclusions: c.String("inclusions"), + Exclusions: c.String("exclusions"), + Level: c.String("level"), + ShowProfiling: c.String("showProfiling"), + BranchAnalysis: c.Bool("branchAnalysis"), + UsingProperties: c.Bool("usingProperties"), + EnableGateBreaker: c.Bool("enableGateBreaker"), + } + plugin, pluginError := NewPlugin(config) + if pluginError != nil { + fmt.Println(pluginError) + os.Exit(1) } if err := plugin.Exec(); err != nil { diff --git a/plugin.go b/plugin.go index c9bf783..2e39014 100755 --- a/plugin.go +++ b/plugin.go @@ -1,7 +1,9 @@ package main import ( + "bytes" "fmt" + sonargo "github.com/magicsong/sonargo/sonar" "os" "os/exec" "strings" @@ -14,23 +16,30 @@ type ( Host string Token string - Version string - Branch string - Sources string - Timeout string - Inclusions string - Exclusions string - Level string - ShowProfiling string - BranchAnalysis bool - UsingProperties bool + Version string + Branch string + Sources string + Timeout string + Inclusions string + Exclusions string + Level string + ShowProfiling string + BranchAnalysis bool + UsingProperties bool + EnableGateBreaker bool } Plugin struct { - Config Config + Config Config + SonarClient *sonargo.Client } ) -func (p Plugin) Exec() error { +func (p Plugin) getProjectKey() string { + return strings.Replace(p.Config.Key, "/", ":", -1) +} + +// Returns array of arguments that will be used during the command call +func (p Plugin) getCommandArgs() []string { args := []string{ "-Dsonar.host.url=" + p.Config.Host, "-Dsonar.login=" + p.Config.Token, @@ -38,7 +47,7 @@ func (p Plugin) Exec() error { if !p.Config.UsingProperties { argsParameter := []string{ - "-Dsonar.projectKey=" + strings.Replace(p.Config.Key, "/", ":", -1), + "-Dsonar.projectKey=" + p.getProjectKey(), "-Dsonar.projectName=" + p.Config.Name, "-Dsonar.projectVersion=" + p.Config.Version, "-Dsonar.sources=" + p.Config.Sources, @@ -57,15 +66,49 @@ func (p Plugin) Exec() error { args = append(args, "-Dsonar.branch.name=" + p.Config.Branch) } + return args +} + +func (p Plugin) Exec() error { + args := p.getCommandArgs() cmd := exec.Command("sonar-scanner", args...) // fmt.Printf("==> Executing: %s\n", strings.Join(cmd.Args, " ")) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + + var outb, errb bytes.Buffer + cmd.Stdout = &outb + cmd.Stderr = &errb fmt.Printf("==> Code Analysis Result:\n") err := cmd.Run() if err != nil { return err } + _, _ = os.Stdout.Write(outb.Bytes()) + _, _ = os.Stderr.Write(errb.Bytes()) + + if p.Config.EnableGateBreaker { + // Extract task id from command log + taskId, extractError := p.extractReportIdFromAnalysisLog(outb.String()) + if extractError != nil { + return extractError + } + // Check if quality gate succeeded + if err = p.validateQualityGate(taskId); err != nil { + return err + } + } + return nil } + +func NewPlugin(config Config) (*Plugin, error) { + client, err := sonargo.NewClientByToken(config.Host+"/api", config.Token) + if err != nil { + return nil, err + } + + return &Plugin{ + Config: config, + SonarClient: client, + }, nil +} diff --git a/sonarapi.go b/sonarapi.go new file mode 100644 index 0000000..4db5131 --- /dev/null +++ b/sonarapi.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + sonargo "github.com/magicsong/sonargo/sonar" + "regexp" + "time" +) + +const SONAR_TIME_FORMAT = "2006-01-02T15:04:05-0700" + +// How long to wait between consecutive API requests (seconds) +const SONAR_REQUEST_SLEEP = 10 + +/** + * Extracts the report id from code analysis result + * The corresponding line looks like this: + More about the report processing at https://sonarcloud.io/api/ce/task?id=AW6aSZwxGXJ8Zd7jkmP2 +*/ +func (p Plugin) extractReportIdFromAnalysisLog(analysisLog string) (string, error) { + var re = regexp.MustCompile(`(?m)More about the report processing at .*\/api\/ce\/task\?id=(.*)$`) + matches := re.FindStringSubmatch(analysisLog) + if len(matches) != 2 { // expect exactly 2 results. 0 is full string. 1 is the id + return "", fmt.Errorf("unable to get report processing url from analysis result.") + } + return matches[1], nil +} + +func (p Plugin) getCompletedTaskReport(taskId string) (*sonargo.CeTaskObject, error) { + for { + taskObject, _, apiError := p.SonarClient.Ce.Task(&sonargo.CeTaskOption{Id: taskId}) + if apiError != nil { + return nil, apiError + } + startedTime, timeErr := time.Parse(SONAR_TIME_FORMAT, taskObject.Task.StartedAt) + if timeErr != nil { + return nil, timeErr + } + taskStatus := taskObject.Task.Status + if taskStatus != "SUCCESS" && taskStatus != "FAILED" { + fmt.Printf("Awaiting completion of analysis. Current status is \"%s\". Analysis started on %s.\n", taskStatus, startedTime) + time.Sleep(SONAR_REQUEST_SLEEP * time.Second) + continue + } + completedTime, timeErr := time.Parse(SONAR_TIME_FORMAT, taskObject.Task.ExecutedAt) + if timeErr != nil { + return nil, timeErr + } + fmt.Printf("Analysis completed on %s with status \"%s\".\n", completedTime, taskStatus) + if taskStatus == "FAILED" { + return nil, fmt.Errorf("pipeline aborted because processing by the Sonar server failed") + } + return taskObject, nil + } +} + +func (p Plugin) validateQualityGate(taskId string) error { + // Get completed analysis report + ceTask, err := p.getCompletedTaskReport(taskId) + if err != nil { + return err + } + + // Check Quality Gate + projectStatusOption := &sonargo.QualitygatesProjectStatusOption{AnalysisId: ceTask.Task.AnalysisID} + qualitygate, _, err := p.SonarClient.Qualitygates.ProjectStatus(projectStatusOption) + if err != nil { + return err + } + if qualitygate.ProjectStatus.Status == "ERROR" { + return fmt.Errorf("pipeline aborted because quality gate failed") + } + return nil +}