diff --git a/README.md b/README.md index f80b33b..5c1bc92 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ -# plugin-sonar +# Woodpecker plugin for integration with SonarQube -A Woodpecker-Ci plugin integrating sonar-scanner \ No newline at end of file +## Meta + +This is one of my first go projects, it was created to facilitate the use of [Woodpecker-CI](https://woodpecker-ci.org) in combination with SonarQube. + +Since I am using the [mc1arke/sonarqube-community-branch-plugin](https://github.com/mc1arke/sonarqube-community-branch-plugin) plugin for SonarQube, I needed a plugin capable of properly setting the scanner parameters to allow for PR (not implemented in any plugins I found) and branch scanning (implemented in aosapps/drone-sonar). + +## Inspiration / acknowledgements + +This plugin was inspired by the works of [aosapps/drone-sonar](https://github.com/aosapps/drone-sonar-plugin) +and [diegopereiraeng/harness-cie-sonarqube-scanner](https://github.com/diegopereiraeng/harness-cie-sonarqube-scanner). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a4da56b --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module git.kle.li/gapodo/woodpecker-plugin-sonar + +go 1.19 + +require ( + git.kle.li/gapodo/woodpecker-plugin-lib v0.0.4 + github.com/pelletier/go-toml v1.9.5 + github.com/urfave/cli/v2 v2.20.3 + go.uber.org/zap v1.23.0 +) + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/woodpecker-ci/woodpecker v0.15.5 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + go.uber.org/atomic v1.10.0 // indirect + go.uber.org/multierr v1.8.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a0754f2 --- /dev/null +++ b/go.sum @@ -0,0 +1,40 @@ +git.kle.li/gapodo/woodpecker-plugin-lib v0.0.4 h1:+N8ZKk7NkpnCb3/nNTJwM8rjGlK1Wzi4uLzN7HY+wZM= +git.kle.li/gapodo/woodpecker-plugin-lib v0.0.4/go.mod h1:HcNuDYsML3ZwNi/pYrgpzwEqkV/e+sC2eaPpaLmmBLQ= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +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/franela/goblin v0.0.0-20211003143422-0a4f594942bf h1:NrF81UtW8gG2LBGkXFQFqlfNnvMt9WdB46sfdJY4oqc= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +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/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/urfave/cli/v2 v2.20.3 h1:lOgGidH/N5loaigd9HjFsOIhXSTrzl7tBpHswZ428w4= +github.com/urfave/cli/v2 v2.20.3/go.mod h1:1CNUng3PtjQMtRzJO4FMXBQvkGtuYRxxiR9xMa7jMwI= +github.com/woodpecker-ci/woodpecker v0.15.5 h1:8l6p1qhwUpjNXQNCkEpH6lQf0npfips4I8xYX9dBC4k= +github.com/woodpecker-ci/woodpecker v0.15.5/go.mod h1:Po9JhNe1H/AInF5eeqOAfAsXMfdmfR8E7ghUxM0sDEY= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= +go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= +go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8= +go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY= +go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..2c43d5a --- /dev/null +++ b/main.go @@ -0,0 +1,252 @@ +// Copyright 2021 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "log" + "os" + "time" + + plugLib "git.kle.li/gapodo/woodpecker-plugin-lib/urfave" + "git.kle.li/gapodo/woodpecker-plugin-sonar/pkg/plugin" + "git.kle.li/gapodo/woodpecker-plugin-sonar/pkg/sonar" + "github.com/urfave/cli/v2" + "go.uber.org/zap" +) + +var appVersion = "v0.0.1" + +func main() { + app := &cli.App{ + Name: "Woodpecker-SonarQube-Plugin", + Usage: "Trigger SonarQube scan from a Woodpecker-CI pipeline", + Version: appVersion, + Compiled: time.Now(), + Authors: []*cli.Author{ + { + Name: "Michael Amann [@gapodo:kle.li]", + Email: "gapodo@geekvoid.net", + }, + }, + Copyright: "(c) 2022 Michael Amann", + Before: func(ctx *cli.Context) error { + fmt.Fprintf(ctx.App.Writer, "Woodpecker-CI SonarQube plugin\n") + return nil + }, + After: func(ctx *cli.Context) error { + fmt.Fprintf(ctx.App.Writer, "Exiting Woodpecker-CI SonarQube plugin\n") + return nil + }, + Action: run, + Flags: Flags(), + } + + app.Run(os.Args) +} + +func Flags() []cli.Flag { + flags := []cli.Flag{ + &cli.StringFlag{ + Name: "logLevel", + Usage: "plugin log level (debug, info, warn, error, dpanic, panic, and fatal)", + Value: "info", + EnvVars: []string{"PLUGIN_LOG_LEVEL"}, + }, + &cli.StringFlag{ + Name: "key", + Usage: "SonarQube project key", + EnvVars: []string{"PLUGIN_SONAR_KEY"}, + }, + &cli.StringFlag{ + Name: "name", + Usage: "SonarQube project name", + EnvVars: []string{"PLUGIN_SONAR_NAME"}, + }, + &cli.StringFlag{ + Name: "host-url", + Usage: "SonarQube host url", + EnvVars: []string{"PLUGIN_SONAR_URL"}, + }, + &cli.StringFlag{ + Name: "token", + Usage: "SonarQube analysis token", + EnvVars: []string{"PLUGIN_SONAR_TOKEN"}, + }, + &cli.StringFlag{ + Name: "ver", + Usage: "Project version", + EnvVars: []string{"PLUGIN_SONAR_VERSION"}, + }, + &cli.IntFlag{ + Name: "http-timeout", + Usage: "Web request timeout", + Value: 300, + EnvVars: []string{"PLUGIN_TIMEOUT"}, + }, + &cli.StringFlag{ + Name: "sources", + Usage: "analysis sources", + Value: ".", + EnvVars: []string{"PLUGIN_SOURCES"}, + }, + &cli.StringFlag{ + Name: "inclusions", + Usage: "code inclusions", + EnvVars: []string{"PLUGIN_INCLUSIONS"}, + }, + &cli.StringFlag{ + Name: "exclusions", + Usage: "code exclusions", + EnvVars: []string{"PLUGIN_EXCLUSIONS"}, + }, + &cli.StringFlag{ + Name: "sonar-log-Level", + Usage: "sonar-scanner log level (as per sonar-scanner)", + Value: "INFO", + EnvVars: []string{"PLUGIN_SONAR_LOG_LEVEL"}, + }, + &cli.BoolFlag{ + Name: "showProfiling", + Usage: "showProfiling during analysis", + Value: false, + EnvVars: []string{"PLUGIN_SHOWPROFILING"}, + }, + &cli.StringFlag{ + Name: "branchAnalysis", + Usage: "execute branchAnalysis (true, auto, false)", + EnvVars: []string{"PLUGIN_BRANCHANALYSIS"}, + Value: "false", + }, + &cli.StringFlag{ + Name: "usingProperties", + Usage: "using sonar-project.properties", + EnvVars: []string{"PLUGIN_USINGPROPERTIES"}, + Value: "false", + }, + &cli.StringFlag{ + Name: "binaries", + Usage: "Java Binaries", + EnvVars: []string{"PLUGIN_BINARIES"}, + }, + &cli.StringFlag{ + Name: "quality-target", + Usage: "Quality Gate target", + EnvVars: []string{"PLUGIN_QUALITYGATE"}, + Value: "OK", + }, + &cli.BoolFlag{ + Name: "quality-gate-enabled", + Usage: "true or false - stop pipeline if sonar quality gate conditions are not met", + Value: true, + EnvVars: []string{"PLUGIN_SONAR_QUALITY_ENABLED"}, + }, + &cli.IntFlag{ + Name: "quality-gate-timeout", + Usage: "number in seconds for timeout", + Value: 300, + EnvVars: []string{"PLUGIN_SONAR_QUALITY_GATE_TIMEOUT"}, + }, + &cli.StringFlag{ + Name: "artifact-file", + Usage: "Artifact file location that will be generated by the plugin. This file will include information of docker images that are uploaded by the plugin.", + Value: "artifact.json", + EnvVars: []string{"PLUGIN_ARTIFACT_FILE"}, + }, + } + + flags = append(flags, plugLib.BuildFlags()...) + + return flags +} + +func run(ctx *cli.Context) error { + + slog := setupSugaredLogger(ctx.String("logLevel")) + + slog.Infow("SonarQube plugin for Woodpecker", "version", appVersion) + + slog.Infow("Parsing values from env") + plugin := pluginFromContext(ctx, slog) + slog.Infow("Starting scanner") + plugin.Exec() + + return nil +} + +func pluginFromContext(ctx *cli.Context, logger *zap.SugaredLogger) plugin.Plugin { + config := sonar.Config{ + Key: ctx.String("key"), + Name: ctx.String("name"), + HostURL: ctx.String("host-url"), + Token: ctx.String("token"), + Version: ctx.String("ver"), + HttpTimeout: ctx.Int("http-timeout"), + Sources: ctx.String("sources"), + Inclusions: ctx.String("inclusions"), + Exclusions: ctx.String("exclusions"), + SonarLogLevel: ctx.String("sonar-log-Level"), + ShowProfiling: ctx.String("showProfiling"), + BranchAnalysis: ctx.String("branchAnalysis"), + UsingProperties: ctx.Bool("usingProperties"), + Binaries: ctx.String("binaries"), + Quality: ctx.String("quality-target"), + QualityEnabled: ctx.Bool("quality-gate-enabled"), + QualityTimeout: ctx.Int("quality-gate-timeout"), + ArtifactFile: ctx.String("artifact-file"), + + Logger: logger, + } + + bdata := plugLib.BuildDataFromContext(ctx, nil) + + plug := plugin.Plugin{ + Config: &config, + BuildData: &bdata, + Logger: logger, + } + + return plug +} + +func setupSugaredLogger(level string) *zap.SugaredLogger { + + fmt.Printf("loglevel %s", level) + + alvl, err := zap.ParseAtomicLevel(level) + if err != nil { + log.Fatal(fmt.Errorf("provided plugin log level failed to parse \"%s\": %w", level, err)) + } + + conf := zap.NewProductionConfig() + conf.EncoderConfig.TimeKey = "" // don't print a timestamp + conf.EncoderConfig.CallerKey = "" // don't show caller + conf.Level = alvl + + if conf.Level.Level() == zap.DebugLevel { + conf.EncoderConfig.CallerKey = "caller" + conf.EncoderConfig.FunctionKey = "func" + } + + logger := zap.Must(conf.Build()) + defer logger.Sync() + + sugar := logger.Sugar() + + sugar.Infow("Logger setup", "levelConf", level) + sugar.Debugw("debug level enabled") + + return sugar +} diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go new file mode 100644 index 0000000..e01f6a6 --- /dev/null +++ b/pkg/plugin/plugin.go @@ -0,0 +1,241 @@ +// Copyright 2022 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package plugin + +import ( + "os" + "os/exec" + "regexp" + "strconv" + "strings" + + "git.kle.li/gapodo/woodpecker-plugin-lib/common" + "git.kle.li/gapodo/woodpecker-plugin-sonar/pkg/sonar" + "github.com/pelletier/go-toml" + "go.uber.org/zap" +) + +type ( + Plugin struct { + Config *sonar.Config + BuildData *common.BuildData + Logger *zap.SugaredLogger + } + + args struct { + list []string + logger *zap.SugaredLogger + } +) + +func (p *Plugin) AnalyzeBranch() bool { + return p.Config.BranchAnalysis == "true" || (p.Config.BranchAnalysis == "auto" && !p.SourceBranchIsDefault()) +} + +func (p *Plugin) SourceBranchIsDefault() bool { + return p.BuildData.SourceBranch() == p.BuildData.Metadata.Repo.Branch || len(p.BuildData.SourceBranch()) == 0 +} + +func (p *Plugin) getAuth() *sonar.Authentication { + return &sonar.Authentication{ + Token: p.Config.Token, + } +} + +func (p *Plugin) Exec() { + + p.prepareVersion() + p.runScanner() + + report := p.staticScanToReport() + + p.Logger.Infow("job url", "url", report.CeTaskURL) + + task := sonar.WaitForSonarJob(report, p.getAuth(), p.Config) + + p.Logger.Infow("Job finished", "report", report) + + status := sonar.GetStatus(task, report, p.getAuth(), p.Config) + p.Logger.Infow("status received", "status", status, "dashboardURL", p.Config.GetProjectDashUrl()) + + p.Logger.Infow("Woodpecker SonarQube Plugin", + "qualityEnabled", p.Config.QualityEnabled) + + if p.Config.QualityEnabled { + p.Logger.Info("QualityGate ENABLED") + + if status != p.Config.Quality { + p.Logger.Fatal("QualityGate status FAILED", + "targetQuality", p.Config.Quality, + "qualityResult", status) + } else { + p.Logger.Infow("QualityGate PASSED", "status", status) + } + } + + if !p.Config.QualityEnabled { + p.Logger.Info("QualityGate DISABLED only displaying information") + if status != p.Config.Quality { + p.Logger.Infow("QualityGate status FAILED", "status", status) + } else { + p.Logger.Infow("QualityGate status PASSED", "status", status) + } + + } +} + +func (p *Plugin) runScanner() { + scannerArgs := p.generateScannerArgs() + + p.Logger.Debugw("Prepping env SONAR_USER_HOME") + os.Setenv("SONAR_USER_HOME", ".sonar") + + p.Logger.Infow("executing sonar-scanner", "options", strings.Join(scannerArgs, " ")) + + cmd := exec.Command("sonar-scanner", scannerArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err := cmd.Run() + + if err != nil { + p.Logger.Fatalw("executing sonarscanner failed", "error", err) + } +} + +func (p *Plugin) generateScannerArgs() []string { + args := &args{ + list: []string{ + "-Dsonar.host.url=" + p.Config.HostURL, + "-Dsonar.login=" + p.Config.Token, + }, + logger: p.Logger, + } + + if !p.Config.UsingProperties { + argsParameter := []string{ + "-Dsonar.projectKey=" + p.Config.Key, + "-Dsonar.projectName=" + p.Config.Name, + "-Dsonar.projectVersion=" + p.Config.Version, + "-Dsonar.sources=" + p.Config.Sources, + "-Dsonar.ws.timeout=" + strconv.Itoa(p.Config.HttpTimeout), + "-Dsonar.log.level=" + p.Config.SonarLogLevel, + "-Dsonar.showProfiling=" + p.Config.ShowProfiling, + "-Dsonar.scm.provider=" + p.BuildData.AdditionalData.SCM, + } + args.append(argsParameter...) + + args.appendIfNotEmpty("-Dsonar.java.binaries=", p.Config.Binaries) + args.appendIfNotEmpty("-Dsonar.exclusions=", p.Config.Exclusions) + args.appendIfNotEmpty("-Dsonar.inclusions=", p.Config.Inclusions) + + } + + p.Logger.Debugw("argsbuilder", + "analyze", p.AnalyzeBranch(), + "isPr", p.BuildData.IsPR(), + "isTag", p.BuildData.IsTag(), + "source", p.BuildData.SourceBranch(), + "target", p.BuildData.TargetBranch(), + "prKey", p.BuildData.PullRequest()) + + if p.AnalyzeBranch() && !p.BuildData.IsPR() { + args.appendMustIfNotEmpty("-Dsonar.branch.name=", p.BuildData.TargetBranch()) + } + + if p.AnalyzeBranch() && p.BuildData.IsPR() { + args.appendMustIfNotEmpty("-Dsonar.pullrequest.branch=", p.BuildData.SourceBranch()) + args.appendMustIfNotEmpty("-Dsonar.pullrequest.base=", p.BuildData.TargetBranch()) + args.appendMustIfNotEmpty("-Dsonar.pullrequest.key=", p.BuildData.PullRequest()) + } + + args.appendIfNotEmpty("-Dsonar.qualitygate.wait=", strconv.FormatBool(p.Config.QualityEnabled)) + args.appendIfNotEmpty("-Dsonar.qualitygate.timeout=", strconv.Itoa(p.Config.QualityTimeout)) + + validArg := regexp.MustCompile(".*=.+") + for _, argument := range args.list { + if !validArg.Match([]byte(argument)) { + p.Logger.Fatalf("verifying generateScannerArgs failed: %s does not have a value assigned", argument) + } + } + + return args.list +} + +func (p *Plugin) staticScanToReport() *sonar.Report { + + var reportLocation = ".scannerwork/report-task.txt" + + fileData, err := os.ReadFile(reportLocation) + if err != nil { + p.Logger.Fatalw("failed to access report-task file", + "filepath", reportLocation, + "error", err) + } + + replacer := regexp.MustCompile("(?m)^(.*?)=(.*)($)") + data := replacer.ReplaceAllString(string(fileData), "$1=\"$2\"") + + report := sonar.Report{} + + if err = toml.Unmarshal([]byte(data), &report); err != nil { + p.Logger.Fatalw("failed to unmarshal task-report toml to Report", "error", err) + } + + return &report +} + +func (p *Plugin) prepareVersion() { + if len(p.Config.Version) > 0 { + return + } + + if p.BuildData.IsTag() { + p.Config.Version = p.BuildData.Tag() + p.Logger.Infow("Version unset updating it", + "action", "tag", + "version", p.Config.Version) + return + } + + if p.BuildData.IsPR() { + p.Config.Version = p.BuildData.SourceBranch() + p.Logger.Infow("Version unset updating it", + "action", "pull request", + "version", p.Config.Version) + return + } + + p.Config.Version = p.BuildData.TargetBranch() +} + +func (a *args) appendMustIfNotEmpty(prefix string, value string) { + if len(value) > 0 { + a.append(prefix + value) + } else { + a.logger.Fatalw("error appending argument, value empty", + "prefix", prefix) + } +} + +func (a *args) appendIfNotEmpty(prefix string, value string) { + if len(value) > 0 { + a.append(prefix + value) + } +} + +func (a *args) append(elems ...string) { + a.list = append(a.list, elems...) +} diff --git a/pkg/sonar/sonar.go b/pkg/sonar/sonar.go new file mode 100644 index 0000000..75fb914 --- /dev/null +++ b/pkg/sonar/sonar.go @@ -0,0 +1,241 @@ +// Copyright 2022 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sonar + +import ( + "encoding/base64" + "encoding/json" + "encoding/xml" + "io" + "net" + "net/http" + "net/url" + "reflect" + "time" + + "go.uber.org/zap" +) + +const ( + DashboardURL = "/dashboard?id=" +) + +var netClient *http.Client + +type ( + Config struct { + Key string + Name string + HostURL string + Token string + + Version string + Sources string + HttpTimeout int + Inclusions string + Exclusions string + SonarLogLevel string + ShowProfiling string + BranchAnalysis string + UsingProperties bool + + Binaries string + Quality string + QualityEnabled bool + QualityTimeout int + ArtifactFile string + + Logger *zap.SugaredLogger + } + + Authentication struct { + Token string + } + + Report struct { + ProjectKey string `toml:"projectKey"` + ServerURL string `toml:"serverUrl"` + DashboardURL string `toml:"dashboardUrl"` + CeTaskID string `toml:"ceTaskId"` + CeTaskURL string `toml:"ceTaskUrl"` + } + + TaskResponse struct { + Task struct { + ID string `json:"id"` + Type string `json:"type"` + ComponentID string `json:"componentId"` + ComponentKey string `json:"componentKey"` + ComponentName string `json:"componentName"` + AnalysisID string `json:"analysisId"` + Status string `json:"status"` + } `json:"task"` + } + + ProjectStatusResponse struct { + ProjectStatus struct { + Status string `json:"status"` + } `json:"projectStatus"` + } + + Project struct { + ProjectStatus Status `json:"projectStatus"` + } + + Status struct { + Status string `json:"status"` + IgnoredConditions bool `json:"ignoredConditions"` + Conditions []Condition `json:"conditions"` + } + + Condition struct { + Status string `json:"status"` + MetricKey string `json:"metricKey"` + Comparator string `json:"comparator"` + PeriodIndex int `json:"periodIndex"` + ErrorThreshold string `json:"errorThreshold"` + ActualValue string `json:"actualValue"` + } + + Testsuites struct { + XMLName xml.Name `xml:"testsuites"` + Text string `xml:",chardata"` + TestSuite []Testsuite `xml:"testsuite"` + } + + Testsuite struct { + Text string `xml:",chardata"` + Package string `xml:"package,attr"` + Time int `xml:"time,attr"` + Tests int `xml:"tests,attr"` + Errors int `xml:"errors,attr"` + Name string `xml:"name,attr"` + TestCase []Testcase `xml:"testcase"` + } + + Testcase struct { + Text string `xml:",chardata"` + Time int `xml:"time,attr"` // Actual Value Sonar + Name string `xml:"name,attr"` // Metric Key + Classname string `xml:"classname,attr"` // The metric Rule + Failure *Failure `xml:"failure"` // Sonar Failure - show results + } + + Failure struct { + Text string `xml:",chardata"` + Message string `xml:"message,attr"` + } +) + +func init() { + netClient = &http.Client{ + Timeout: time.Second * 10, + Transport: &http.Transport{ + Dial: (&net.Dialer{ + Timeout: 5 * time.Second, + }).Dial, + TLSHandshakeTimeout: 5 * time.Second, + }, + } +} + +func (c *Config) GetProjectDashUrl() string { + return c.HostURL + DashboardURL + c.Name +} + +func (a *Authentication) UrlToken() string { + return base64.StdEncoding.EncodeToString([]byte((a.Token + ":"))) +} + +func (a *Authentication) AddAuthHeader(r *http.Request) { + r.Header.Add("Authorization", "Basic "+a.UrlToken()) +} + +func GetSonarJobStatus(report *Report, auth *Authentication, config *Config) *TaskResponse { + task := &TaskResponse{} + sonarJsonRequest("GET", report.CeTaskURL, nil, auth, config, task) + + return task +} + +func WaitForSonarJob(report *Report, auth *Authentication, config *Config) *TaskResponse { + timeout := time.NewTimer(300 * time.Second) + tick := time.NewTicker(500 * time.Millisecond) + for { + select { + case <-timeout.C: + tick.Stop() + timeout.Stop() + config.Logger.Fatalw("WaitForSonarJob timed out") + case <-tick.C: + job := GetSonarJobStatus(report, auth, config) + + if job.Task.Status == "SUCCESS" { + timeout.Stop() + tick.Stop() + return job + } + if job.Task.Status == "ERROR" { + timeout.Stop() + tick.Stop() + config.Logger.Fatalw("WaitForSonarJob task failed with status: ERROR") + } + } + } +} + +func GetStatus(task *TaskResponse, report *Report, auth *Authentication, config *Config) (status string) { + reportRequest := url.Values{ + "analysisId": {task.Task.AnalysisID}, + } + project := &ProjectStatusResponse{} + + url := report.ServerURL + "/api/qualitygates/project_status?" + reportRequest.Encode() + sonarJsonRequest("GET", url, nil, auth, config, project) + + return project.ProjectStatus.Status +} + +func sonarJsonRequest(reqType string, url string, body io.Reader, auth *Authentication, config *Config, targetPointer any) { + + rv := reflect.ValueOf(targetPointer) + if rv.Kind() != reflect.Pointer || rv.IsNil() { + config.Logger.Fatalw("invalid parameter, not a pointer", "type", reflect.TypeOf(targetPointer)) + } + + req, err := http.NewRequest(reqType, url, body) + if err != nil { + config.Logger.Fatalw("failed to build request for url", + "httpRequestType", reqType, + "url", url, + "targetType", reflect.TypeOf(targetPointer).String(), + "error", err) + } + + auth.AddAuthHeader(req) + resp, err := netClient.Do(req) + if err != nil { + config.Logger.Fatalw("failed getting data from server", + "targetType", reflect.TypeOf(targetPointer).String(), + "error", err) + } + + buf, _ := io.ReadAll(resp.Body) + if err := json.Unmarshal(buf, targetPointer); err != nil { + config.Logger.Fatalw("failed to unmarshal response", + "targetType", reflect.TypeOf(targetPointer).String(), + "error", err) + } +} diff --git a/woodpecker-plugin-sonar b/woodpecker-plugin-sonar new file mode 100755 index 0000000..fdbaeeb Binary files /dev/null and b/woodpecker-plugin-sonar differ