diff --git a/artifactory/commands/buildinfo/publish.go b/artifactory/commands/buildinfo/publish.go index fcfd0c32..231279aa 100644 --- a/artifactory/commands/buildinfo/publish.go +++ b/artifactory/commands/buildinfo/publish.go @@ -3,8 +3,13 @@ package buildinfo import ( "errors" "fmt" + evidenceCli "github.com/jfrog/jfrog-cli-artifactory/evidence/cli" + "github.com/jfrog/jfrog-cli-artifactory/evidence/evidenceproviders" "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils/commandsummary" + "github.com/jfrog/jfrog-cli-core/v2/common/commands" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" "net/url" + "os" "strconv" "strings" "time" @@ -137,11 +142,9 @@ func (bpc *BuildPublishCommand) Run() error { } if found { buildNumbersFrequency := CalculateBuildNumberFrequency(buildRuns) - if frequency, ok := buildNumbersFrequency[buildNumber]; ok { - err = servicesManager.DeleteBuildInfo(buildInfo, project, frequency) - if err != nil { - return err - } + err = servicesManager.DeleteBuildInfo(buildInfo, project, buildNumbersFrequency[buildNumber]) + if err != nil { + return err } } } @@ -177,9 +180,50 @@ func (bpc *BuildPublishCommand) Run() error { log.Info(logMsg + " Browse it in Artifactory under " + buildLink) return nil } - log.Info(logMsg) - return logJsonOutput(buildLink) + err = logJsonOutput(buildLink) + if err != nil { + return err + } + // check configuration to create evidence. + evidenceConfig, err := evidenceproviders.GetConfig() + if err != nil { + return logErrorAndReturn(err) + } + buildPublishConfig := &evidenceproviders.BuildPublishConfig{} + // validate evidence configuration to proceed to create evidence. + if evidenceBuildPublishConfig, ok := evidenceConfig["buildPublish"]; ok && evidenceBuildPublishConfig != nil { + if err := evidenceBuildPublishConfig.Decode(buildPublishConfig); err != nil { + return logErrorAndReturn(err) + } + if buildPublishConfig == nil || !buildPublishConfig.IsEnabled() { + log.Warn("Evidence configuration for build publish is not enabled. Skipping evidence creation.") + return nil + } + err := buildPublishConfig.Validate() + if err != nil { + return logErrorAndReturn(err) + } + os.ExpandEnv(buildPublishConfig.KeyPath) + ctx := &components.Context{} + ctx.AddStringFlag("build-name", buildInfo.Name) + ctx.AddStringFlag("build-number", buildInfo.Number) + ctx.AddStringFlag("predicate-type", buildPublishConfig.EvidenceProvider) + ctx.AddStringFlag("key", os.ExpandEnv(buildPublishConfig.KeyPath)) + ctx.AddStringFlag("key-alias", buildPublishConfig.KeyAlias) + buildInfoEvidenceCMD := evidenceCli.NewEvidenceBuildCommand(ctx, commands.Exec) + evidenceCli.PlatformToEvidenceUrls(bpc.serverDetails) + err = buildInfoEvidenceCMD.CreateEvidence(ctx, bpc.serverDetails) + return logErrorAndReturn(err) + } + return nil +} + +func logErrorAndReturn(err error) error { + if err != nil { + log.Error(err) + } + return nil } // CalculateBuildNumberFrequency since the build number is not unique, we need to calculate the frequency of each build number diff --git a/evidence/cli/command_cli.go b/evidence/cli/command_cli.go index 992d62e5..eed04869 100644 --- a/evidence/cli/command_cli.go +++ b/evidence/cli/command_cli.go @@ -2,16 +2,20 @@ package cli import ( "errors" + "github.com/jfrog/jfrog-cli-artifactory/evidence/cli/docs/config" "github.com/jfrog/jfrog-cli-artifactory/evidence/cli/docs/create" + "github.com/jfrog/jfrog-cli-artifactory/evidence/evidenceproviders" commonCliUtils "github.com/jfrog/jfrog-cli-core/v2/common/cliutils" "github.com/jfrog/jfrog-cli-core/v2/common/commands" pluginsCommon "github.com/jfrog/jfrog-cli-core/v2/plugins/common" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" coreConfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" coreUtils "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" - "github.com/jfrog/jfrog-client-go/utils" "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/io/fileutils" + "gopkg.in/yaml.v3" "os" + "path/filepath" "strings" ) @@ -25,6 +29,14 @@ func GetCommands() []components.Command { Arguments: create.GetArguments(), Action: createEvidence, }, + { + Name: "generate-config", + Aliases: []string{"gc"}, + Flags: GetCommandFlags(GenerateConfig), + Description: config.GetDescription(), + Arguments: create.GetArguments(), + Action: generateEvidenceProviderConfig, + }, } } @@ -60,6 +72,46 @@ func createEvidence(ctx *components.Context) error { return command.CreateEvidence(ctx, serverDetails) } +func generateEvidenceProviderConfig(ctx *components.Context) error { + if show, err := pluginsCommon.ShowCmdHelpIfNeeded(ctx, ctx.Arguments); show || err != nil { + return err + } + if len(ctx.Arguments) != 1 { + return pluginsCommon.WrongNumberOfArgumentsHandler(ctx) + } + globalFlag := ctx.GetBoolFlagValue("global") + evidenceDir, err := evidenceproviders.GetEvidenceDir(globalFlag) + if err != nil { + return err + } + var evidenceConfigMap map[string]*yaml.Node + evidenceFile := filepath.Join(evidenceDir, "evidence.yaml") + if ok, _ := fileutils.IsFileExists(evidenceFile, false); ok { + evidenceConfigMap, err = evidenceproviders.LoadConfig(evidenceFile) + if err != nil { + return err + } + } + evidenceConfig := &evidenceproviders.EvidenceConfig{} + evidenceProviders := strings.Split(ctx.GetArgumentAt(0), ",") + for _, ep := range evidenceProviders { + evidenceProvider := strings.TrimSpace(ep) + if strings.EqualFold(evidenceProvider, "sonar") { + err = CreateSonarConfig(evidenceConfigMap["sonar"], evidenceConfig) + if err != nil { + return err + } + } + if strings.EqualFold(evidenceProvider, "buildPublish") { + err = CreateBuildPublishConfig(evidenceConfigMap["buildPublish"], evidenceConfig) + if err != nil { + return err + } + } + } + return WriteConfigFile(globalFlag, evidenceConfig) +} + func validateCreateEvidenceCommonContext(ctx *components.Context) error { if show, err := pluginsCommon.ShowCmdHelpIfNeeded(ctx, ctx.Arguments); show || err != nil { return err @@ -69,10 +121,6 @@ func validateCreateEvidenceCommonContext(ctx *components.Context) error { return pluginsCommon.WrongNumberOfArgumentsHandler(ctx) } - if !ctx.IsFlagSet(predicate) || assertValueProvided(ctx, predicate) != nil { - return errorutils.CheckErrorf("'predicate' is a mandatory field for creating evidence: --%s", predicate) - } - if !ctx.IsFlagSet(predicateType) || assertValueProvided(ctx, predicateType) != nil { return errorutils.CheckErrorf("'predicate-type' is a mandatory field for creating evidence: --%s", predicateType) } @@ -166,7 +214,7 @@ func evidenceDetailsByFlags(ctx *components.Context) (*coreConfig.ServerDetails, if serverDetails.Url == "" { return nil, errors.New("platform URL is mandatory for evidence commands") } - platformToEvidenceUrls(serverDetails) + PlatformToEvidenceUrls(serverDetails) if serverDetails.GetUser() != "" && serverDetails.GetPassword() != "" { return nil, errors.New("evidence service does not support basic authentication") @@ -175,12 +223,6 @@ func evidenceDetailsByFlags(ctx *components.Context) (*coreConfig.ServerDetails, return serverDetails, nil } -func platformToEvidenceUrls(rtDetails *coreConfig.ServerDetails) { - rtDetails.ArtifactoryUrl = utils.AddTrailingSlashIfNeeded(rtDetails.Url) + "artifactory/" - rtDetails.EvidenceUrl = utils.AddTrailingSlashIfNeeded(rtDetails.Url) + "evidence/" - rtDetails.MetadataUrl = utils.AddTrailingSlashIfNeeded(rtDetails.Url) + "metadata/" -} - func assertValueProvided(c *components.Context, fieldName string) error { if c.GetStringFlagValue(fieldName) == "" { return errorutils.CheckErrorf("the --%s option is mandatory", fieldName) diff --git a/evidence/cli/docs/config/help.go b/evidence/cli/docs/config/help.go new file mode 100644 index 00000000..66380d8b --- /dev/null +++ b/evidence/cli/docs/config/help.go @@ -0,0 +1,13 @@ +package config + +import "github.com/jfrog/jfrog-cli-core/v2/plugins/components" + +func GetDescription() string { + return "Generate Evidence Provider Configuration." +} + +func GetArguments() []components.Argument { + return []components.Argument{ + {Name: "sonar", Description: "Generate configuration for SonarQube."}, + } +} diff --git a/evidence/cli/flags.go b/evidence/cli/flags.go index 13afdc64..8d0f609b 100644 --- a/evidence/cli/flags.go +++ b/evidence/cli/flags.go @@ -35,6 +35,14 @@ const ( subjectSha256 = "subject-sha256" key = "key" keyAlias = "key-alias" + + // Evidence config flags + GenerateConfig = "generate-config" + reportTaskFile = "report-task-file" + maxRetries = "max-retries" + retryInterval = "retry-interval" + proxy = "proxy" + evdConfigURL = "evd-config-url" ) // Flag keys mapped to their corresponding components.Flag definition. @@ -54,13 +62,19 @@ var flagsMap = map[string]components.Flag{ packageVersion: components.NewStringFlag(packageVersion, "Package version.", func(f *components.StringFlag) { f.Mandatory = false }), packageRepoName: components.NewStringFlag(packageRepoName, "Package repository Name.", func(f *components.StringFlag) { f.Mandatory = false }), - predicate: components.NewStringFlag(predicate, "Path to the predicate, arbitrary JSON.", func(f *components.StringFlag) { f.Mandatory = true }), + predicate: components.NewStringFlag(predicate, "Path to the predicate, arbitrary JSON.", func(f *components.StringFlag) { f.Mandatory = false }), predicateType: components.NewStringFlag(predicateType, "Type of the predicate.", func(f *components.StringFlag) { f.Mandatory = true }), markdown: components.NewStringFlag(markdown, "Markdown of the predicate.", func(f *components.StringFlag) { f.Mandatory = false }), subjectRepoPath: components.NewStringFlag(subjectRepoPath, "Full path to some subject' location.", func(f *components.StringFlag) { f.Mandatory = false }), subjectSha256: components.NewStringFlag(subjectSha256, "Subject checksum sha256.", func(f *components.StringFlag) { f.Mandatory = false }), key: components.NewStringFlag(key, "Path to a private key that will sign the DSSE. Supported keys: 'ecdsa','rsa' and 'ed25519'.", func(f *components.StringFlag) { f.Mandatory = false }), keyAlias: components.NewStringFlag(keyAlias, "Key alias", func(f *components.StringFlag) { f.Mandatory = false }), + + evdConfigURL: components.NewStringFlag(url, "Sonar host URL", func(f *components.StringFlag) { f.Mandatory = false }), + reportTaskFile: components.NewStringFlag(reportTaskFile, "Path to the report task file", func(f *components.StringFlag) { f.Mandatory = false }), + maxRetries: components.NewStringFlag(maxRetries, "Maximum number of retries to create evidence", func(f *components.StringFlag) { f.Mandatory = false }), + retryInterval: components.NewStringFlag(retryInterval, "Interval between retries in seconds", func(f *components.StringFlag) { f.Mandatory = false }), + proxy: components.NewStringFlag(proxy, "Proxy URL", func(f *components.StringFlag) { f.Mandatory = false }), } var commandFlags = map[string][]string{ @@ -85,6 +99,13 @@ var commandFlags = map[string][]string{ key, keyAlias, }, + GenerateConfig: { + evdConfigURL, + reportTaskFile, + maxRetries, + retryInterval, + proxy, + }, } func GetCommandFlags(cmdKey string) []components.Flag { diff --git a/evidence/cli/generateconfig.go b/evidence/cli/generateconfig.go new file mode 100644 index 00000000..f6a993ec --- /dev/null +++ b/evidence/cli/generateconfig.go @@ -0,0 +1,153 @@ +package cli + +import ( + "github.com/jfrog/jfrog-cli-artifactory/evidence/evidenceproviders" + "github.com/jfrog/jfrog-cli-artifactory/evidence/evidenceproviders/sonarqube" + "github.com/jfrog/jfrog-cli-core/v2/utils/ioutils" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/io/fileutils" + "github.com/jfrog/jfrog-client-go/utils/log" + "gopkg.in/yaml.v3" + neturl "net/url" + "os" + "path/filepath" + "strconv" + "strings" +) + +// CreateSonarConfig creates sonar configuration based on existing config first if not available +// falls back on to default config values. +func CreateSonarConfig(sonarConfigNode *yaml.Node, evidenceConfig *evidenceproviders.EvidenceConfig) (err error) { + var sonarConfig *evidenceproviders.SonarConfig + if sonarConfigNode != nil { + log.Debug("Using existing evidence.yaml file for Sonar configuration") + if sonarConfig, err = sonarqube.CreateSonarConfiguration(sonarConfigNode); sonarConfig != nil { + sonarConfig = sonarqube.NewSonarConfig( + defaultIfEmpty(sonarConfig.URL, sonarqube.DefaultSonarHost), + defaultIfEmpty(sonarConfig.ReportTaskFile, sonarqube.DefaultReportTaskFile), + defaultIntIfEmpty(sonarConfig.MaxRetries, sonarqube.DefaultRetries), + defaultIntIfEmpty(sonarConfig.RetryInterval, sonarqube.DefaultIntervalInSeconds), + sonarConfig.Proxy, + ) + } else if err != nil { + return err + } + } else { + sonarConfig = sonarqube.NewDefaultSonarConfig() + } + return interactiveSonarEvidenceConfiguration(sonarConfig, evidenceConfig) +} + +// CreateBuildPublishConfig creates build publish configuration based on existing config first if not available +// falls back on to default config values. +func CreateBuildPublishConfig(buildPublishConfigNode *yaml.Node, evidenceConfig *evidenceproviders.EvidenceConfig) (err error) { + buildPublishConfigData := &evidenceproviders.BuildPublishConfig{} + if buildPublishConfigNode != nil { + log.Debug("Using existing evidence.yaml file for build publish configuration") + if buildPublishConfigData = buildPublishConfigData.CreateBuildPublishConfig(buildPublishConfigNode); buildPublishConfigData == nil { + buildPublishConfigData = &evidenceproviders.BuildPublishConfig{ + Enable: true, + KeyPath: "", + KeyAlias: "", + } + } + } + return interactiveBuildPublishConfiguration(buildPublishConfigData, evidenceConfig) +} + +func defaultIfEmpty(value, defaultValue string) string { + if value == "" { + return defaultValue + } + return value +} + +func defaultIntIfEmpty(value *int, defaultValue int) string { + if value == nil { + return strconv.Itoa(defaultValue) + } + return strconv.Itoa(*value) +} + +func interactiveSonarEvidenceConfiguration(sonarConfig *evidenceproviders.SonarConfig, evidenceConfig *evidenceproviders.EvidenceConfig) error { + var sonarURL string + for isURLValid := false; !isURLValid; { + sonarURL = ioutils.AskStringWithDefault("Sonar Qube URL", "", sonarConfig.URL) + isURLValid = validateHostOnlyURL(sonarURL) + } + reportTaskFile := ioutils.AskStringWithDefault("Report task file", "", sonarConfig.ReportTaskFile) + maxRetries := ioutils.AskStringWithDefault("Max retries", "", strconv.Itoa(*sonarConfig.MaxRetries)) + retryInterval := ioutils.AskStringWithDefault("Retry interval in Seconds", "", strconv.Itoa(*sonarConfig.RetryInterval)) + var proxy string + if sonarConfig.Proxy == "" { + proxy = ioutils.AskString("Proxy", "", true, false) + } else { + proxy = ioutils.AskStringWithDefault("Proxy", "", sonarConfig.Proxy) + } + sc := sonarqube.NewSonarConfig(sonarURL, reportTaskFile, maxRetries, retryInterval, proxy) + evidenceConfig.Sonar = sc + return nil +} + +func interactiveBuildPublishConfiguration(buildPublishConfig *evidenceproviders.BuildPublishConfig, evidenceConfig *evidenceproviders.EvidenceConfig) (err error) { + if buildPublishConfig == nil { + buildPublishConfig = &evidenceproviders.BuildPublishConfig{} + } + enableBuildPublish := ioutils.AskStringWithDefault("Enable Build Publish Evidence", "", "true") + buildPublishConfig.Enable, err = strconv.ParseBool(enableBuildPublish) + if err != nil { + log.Warn("Invalid value for Enable Build Publish Evidence, defaulting to false") + buildPublishConfig.Enable = false + } + if !buildPublishConfig.Enable { + return nil + } + buildPublishConfig.EvidenceProvider = ioutils.AskStringWithDefault("Predicate Type (Eg:- sonar)", "", buildPublishConfig.EvidenceProvider) + buildPublishConfig.KeyAlias = ioutils.AskStringWithDefault("Key Alias", "", buildPublishConfig.KeyAlias) + keyPath := ioutils.AskStringWithDefault("Private Key Path", "", buildPublishConfig.KeyPath) + if exists, err := fileutils.IsFileExists(keyPath, false); err != nil || !exists { + return errorutils.CheckErrorf("Private Key path %s does not exist or is not a valid file path", keyPath) + } + buildPublishConfig.KeyPath = keyPath + evidenceConfig.BuildPublish = buildPublishConfig + return nil +} + +func validateHostOnlyURL(rawURL string) bool { + u, err := neturl.Parse(rawURL) + if err != nil { + return false + } + switch strings.ToLower(u.Scheme) { + case "http", "https": + default: + return false + } + if u.Hostname() == "" { + return false + } + if u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" { + return false + } + return true +} + +func WriteConfigFile(global bool, ec *evidenceproviders.EvidenceConfig) error { + evidenceDir, err := evidenceproviders.GetEvidenceDir(global) + if err != nil { + return err + } + if err = fileutils.CreateDirIfNotExist(evidenceDir); err != nil { + return err + } + configFilePath := filepath.Join(evidenceDir, "evidence.yaml") + resBytes, err := yaml.Marshal(ec) + if err != nil { + return errorutils.CheckError(err) + } + if err = os.WriteFile(configFilePath, resBytes, 0644); err != nil { + return errorutils.CheckError(err) + } + log.Info("Evidence config successfully created.") + return nil +} diff --git a/evidence/cli/utils.go b/evidence/cli/utils.go index b1db57df..1476bc45 100644 --- a/evidence/cli/utils.go +++ b/evidence/cli/utils.go @@ -2,7 +2,10 @@ package cli import ( "fmt" + "github.com/jfrog/gofrog/log" "github.com/jfrog/jfrog-cli-core/v2/common/commands" + coreConfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils" "os" ) @@ -25,3 +28,12 @@ func getEnvVariable(envVarName string) (string, error) { } return "", fmt.Errorf("'%s' field wasn't provided.", envVarName) } + +func PlatformToEvidenceUrls(rtDetails *coreConfig.ServerDetails) { + log.Debug("Converting platform URLs to evidence URLs", rtDetails.Url) + rtDetails.ArtifactoryUrl = utils.AddTrailingSlashIfNeeded(rtDetails.Url) + "artifactory/" + rtDetails.EvidenceUrl = utils.AddTrailingSlashIfNeeded(rtDetails.Url) + "evidence/" + rtDetails.MetadataUrl = utils.AddTrailingSlashIfNeeded(rtDetails.Url) + "metadata/" + log.Debug("Converted artifactory URL is", rtDetails.ArtifactoryUrl) + log.Debug("Converted evidence URL is", rtDetails.EvidenceUrl) +} diff --git a/evidence/create_base.go b/evidence/create_base.go index 59e54c75..5f8a0628 100644 --- a/evidence/create_base.go +++ b/evidence/create_base.go @@ -7,12 +7,15 @@ import ( "github.com/jfrog/gofrog/log" "github.com/jfrog/jfrog-cli-artifactory/evidence/cryptox" "github.com/jfrog/jfrog-cli-artifactory/evidence/dsse" + "github.com/jfrog/jfrog-cli-artifactory/evidence/evidenceproviders" + "github.com/jfrog/jfrog-cli-artifactory/evidence/evidenceproviders/sonarqube" "github.com/jfrog/jfrog-cli-artifactory/evidence/intoto" "github.com/jfrog/jfrog-cli-artifactory/evidence/model" "github.com/jfrog/jfrog-cli-core/v2/artifactory/utils" "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-client-go/artifactory" evidenceService "github.com/jfrog/jfrog-client-go/evidence/services" + "github.com/jfrog/jfrog-client-go/utils/errorutils" clientlog "github.com/jfrog/jfrog-client-go/utils/log" "os" "strings" @@ -48,13 +51,20 @@ func (c *createEvidenceBase) createEnvelope(subject, subjectSha256 string) ([]by return envelopeBytes, nil } -func (c *createEvidenceBase) buildIntotoStatementJson(subject, subjectSha256 string) ([]byte, error) { - predicate, err := os.ReadFile(c.predicateFilePath) - if err != nil { - log.Warn(fmt.Sprintf("failed to read predicate file '%s'", predicate)) - return nil, err +func (c *createEvidenceBase) buildIntotoStatementJson(subject, subjectSha256 string) (predicate []byte, err error) { + // Auto collect the predicate if the predicate is empty + if c.predicateFilePath == "" { + predicate, err = c.AutoCollectEvidence(predicate, err) + if err != nil { + return nil, err + } + } else { + predicate, err := os.ReadFile(c.predicateFilePath) + if err != nil { + log.Warn(fmt.Sprintf("failed to read predicate file '%s'", predicate)) + return nil, err + } } - artifactoryClient, err := c.createArtifactoryClient() if err != nil { return nil, err @@ -83,6 +93,26 @@ func (c *createEvidenceBase) buildIntotoStatementJson(subject, subjectSha256 str return statementJson, nil } +// AutoCollectEvidence tries to autopopulate the predicate if the predicate is empty. +// If predicate type is sonar then it will try to create the evidence using the sonar configuration. +// If the predicate type is not supported, it will return an error. +// Evidence Configuration is expected to be in the .jfrog/evidence/evidence.yaml file. +func (c *createEvidenceBase) AutoCollectEvidence(predicate []byte, err error) ([]byte, error) { + clientlog.Info("Auto populating the predicate from provided predicate type") + var evidenceProvider evidenceproviders.EvidenceProvider + switch c.predicateType { + case "https://jfrog.com/evidence/sonarqube/v1", "sonar", "sonarqube": + c.predicateType = "https://jfrog.com/evidence/sonarqube/v1" + evidenceProvider, err = sonarqube.CreateSonarEvidence() + if err != nil { + return nil, err + } + default: + return nil, errorutils.CheckError(fmt.Errorf("predicate type '%s' is not supported", c.predicateType)) + } + return evidenceProvider.GetEvidence() +} + func (c *createEvidenceBase) setMarkdown(statement *intoto.Statement) error { if c.markdownFilePath != "" { if !strings.HasSuffix(c.markdownFilePath, ".md") { @@ -103,7 +133,6 @@ func (c *createEvidenceBase) uploadEvidence(envelope []byte, repoPath string) er if err != nil { return err } - evidenceDetails := evidenceService.EvidenceDetails{ SubjectUri: repoPath, DSSEFileRaw: envelope, diff --git a/evidence/evidenceproviders/config.go b/evidence/evidenceproviders/config.go new file mode 100644 index 00000000..d4bf0962 --- /dev/null +++ b/evidence/evidenceproviders/config.go @@ -0,0 +1,138 @@ +package evidenceproviders + +import ( + "errors" + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/io/fileutils" + "github.com/jfrog/jfrog-client-go/utils/log" + "gopkg.in/yaml.v3" + "os" + "path/filepath" +) + +type EvidenceConfig struct { + Sonar *SonarConfig `yaml:"sonar,omitempty"` + BuildPublish *BuildPublishConfig `yaml:"buildPublish,omitempty"` +} + +type SonarConfig struct { + URL string `yaml:"url"` + ReportTaskFile string `yaml:"reportTaskFile"` + MaxRetries *int `yaml:"maxRetries"` + RetryInterval *int `yaml:"retryIntervalInSecs"` + Proxy string `yaml:"proxy"` +} + +type BuildPublishConfig struct { + Enable bool `yaml:"enabled" default:"true"` + EvidenceProvider string `yaml:"evidenceProvider"` + KeyAlias string `yaml:"keyAlias"` + KeyPath string `yaml:"keyPath"` +} + +func (bpc *BuildPublishConfig) IsEnabled() bool { + if bpc == nil { + return false + } + return bpc.Enable +} + +func (bpc *BuildPublishConfig) Validate() error { + if bpc.EvidenceProvider == "" { + return errorutils.CheckError(errors.New("evidence provider is not set, evidence provider is the name of the custom evidence provider that will be used to create predicate")) + } + if bpc.KeyAlias == "" { + return errorutils.CheckError(errors.New("key alias is not set, key alias is the name of the key in the keystore that will be used to sign the evidence file")) + } + if bpc.KeyPath == "" { + return errorutils.CheckError(errors.New("key path is not set, key path is the path to the private key file that will be used to sign the evidence file")) + } + if exists, err := fileutils.IsFileExists(bpc.KeyPath, false); err != nil || !exists { + return errorutils.CheckError(errors.New("key path " + bpc.KeyPath + " is not a valid file path")) + } + return nil +} + +func (bpc *BuildPublishConfig) CreateBuildPublishConfig(yamlNode *yaml.Node) (buildPublishConfig *BuildPublishConfig) { + buildPublishConfig = &BuildPublishConfig{} + if yamlNode == nil { + return &BuildPublishConfig{ + Enable: true, + EvidenceProvider: "", + KeyAlias: "", + KeyPath: "", + } + } + if err := yamlNode.Decode(buildPublishConfig); err != nil { + log.Warn(err) + return nil + } + log.Debug("Creating build publish configuration", buildPublishConfig) + return buildPublishConfig +} + +var ErrEvidenceDirNotExist = errors.New("evidence configuration does not exist") + +func LoadConfig(path string) (map[string]*yaml.Node, error) { + log.Debug("Loading external provider config", path) + _, err := fileutils.IsFileExists(path, false) + if err != nil { + return nil, errorutils.CheckError(err) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + return nil, err + } + evidenceConfig := make(map[string]*yaml.Node) + if root.Kind == yaml.DocumentNode && len(root.Content) > 0 { + root = *root.Content[0] + } + for i := 0; i < len(root.Content); i += 2 { + key := root.Content[i].Value + val := root.Content[i+1] + evidenceConfig[key] = val + } + return evidenceConfig, nil +} + +func GetConfig() (map[string]*yaml.Node, error) { + evidenceDir, err := GetEvidenceDir(false) + exists, err := fileutils.IsDirExists(evidenceDir, false) + if err != nil { + return nil, err + } + if !exists { + return nil, errorutils.CheckError(ErrEvidenceDirNotExist) + } + evidenceConfigFilePath := filepath.Join(evidenceDir, "evidence.yaml") + fileExists, err := fileutils.IsFileExists(evidenceConfigFilePath, false) + if err != nil { + return nil, err + } + if !fileExists { + return nil, err + } + evidenceConfig, err := LoadConfig(evidenceConfigFilePath) + return evidenceConfig, nil +} + +func GetEvidenceDir(global bool) (jfrogDir string, err error) { + if !global { + wd, err := os.Getwd() + if err != nil { + return "", err + } + jfrogDir = filepath.Join(wd, ".jfrog") + } else { + jfrogDir, err = coreutils.GetJfrogHomeDir() + if err != nil { + return "", nil + } + } + return filepath.Join(jfrogDir, "evidence"), nil +} diff --git a/evidence/evidenceproviders/config_test.go b/evidence/evidenceproviders/config_test.go new file mode 100644 index 00000000..4c5077b1 --- /dev/null +++ b/evidence/evidenceproviders/config_test.go @@ -0,0 +1,143 @@ +package evidenceproviders + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" +) + +func TestLoadConfig(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "test-config.yaml") + + validConfig := ` +sonar: + url: "http://sonar.example.com" + reportTaskFile: "/path/to/report" + maxRetries: 5 + retryIntervalInSecs: 10 + proxy: "http://proxy.example.com" +jira: + url: "http://jira.example.com" +` + err := os.WriteFile(configPath, []byte(validConfig), 0644) + assert.NoError(t, err) + + config, err := LoadConfig(configPath) + assert.NoError(t, err) + assert.NotNil(t, config) + assert.Equal(t, 2, len(config)) + assert.Contains(t, config, "sonar") + assert.Contains(t, config, "jira") + + // Test case 2: File doesn't exist + nonExistentPath := filepath.Join(tempDir, "non-existent.yaml") + config, err = LoadConfig(nonExistentPath) + assert.Error(t, err) + assert.Nil(t, config) + + // Test case 3: Invalid YAML + invalidYaml := "invalid: yaml: content: -" + err = os.WriteFile(configPath, []byte(invalidYaml), 0644) + assert.NoError(t, err) + + config, err = LoadConfig(configPath) + assert.Error(t, err) + assert.Nil(t, config) +} + +func TestGetConfig(t *testing.T) { + // Setup: Create temp directory structure and config file + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + + tempDir := t.TempDir() + os.Chdir(tempDir) + + evidenceDir := filepath.Join(tempDir, ".jfrog", "evidence") + err := os.MkdirAll(evidenceDir, 0755) + assert.NoError(t, err) + + configPath := filepath.Join(evidenceDir, "evidence.yaml") + validConfig := ` +sonar: + url: "http://sonar.example.com" + reportTaskFile: "/path/to/report" +` + err = os.WriteFile(configPath, []byte(validConfig), 0644) + assert.NoError(t, err) + + // Test GetConfig + config, err := GetConfig() + assert.NoError(t, err) + assert.NotNil(t, config) + assert.Contains(t, config, "sonar") +} + +func TestGetEvidenceDir(t *testing.T) { + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + + tempDir, err := os.Getwd() + os.Chdir(tempDir) + + localDir, err := GetEvidenceDir(false) + assert.NoError(t, err) + assert.Equal(t, filepath.Join(tempDir, ".jfrog", "evidence"), localDir) + + globalDir, err := GetEvidenceDir(true) + assert.NoError(t, err) + assert.NotEmpty(t, globalDir) + assert.Contains(t, globalDir, "evidence") +} + +func TestUnmarshalSonarConfig(t *testing.T) { + yamlStr := ` +url: "http://sonar.example.com" +reportTaskFile: "/path/to/report" +maxRetries: 5 +retryIntervalInSecs: 10 +proxy: "http://proxy.example.com" +` + var node yaml.Node + err := yaml.Unmarshal([]byte(yamlStr), &node) + assert.NoError(t, err) + + // Create evidence config and unmarshal sonar config + var evidenceConfig EvidenceConfig + err = node.Decode(&evidenceConfig.Sonar) + assert.NoError(t, err) + + // Verify fields + assert.Equal(t, "http://sonar.example.com", evidenceConfig.Sonar.URL) + assert.Equal(t, "/path/to/report", evidenceConfig.Sonar.ReportTaskFile) + assert.NotNil(t, evidenceConfig.Sonar.MaxRetries) + assert.Equal(t, 5, *evidenceConfig.Sonar.MaxRetries) + assert.NotNil(t, evidenceConfig.Sonar.RetryInterval) + assert.Equal(t, 10, *evidenceConfig.Sonar.RetryInterval) + assert.Equal(t, "http://proxy.example.com", evidenceConfig.Sonar.Proxy) +} + +func TestEvidenceConfig_Structures(t *testing.T) { + maxRetries := 5 + retryInterval := 10 + + config := EvidenceConfig{ + Sonar: &SonarConfig{ + URL: "http://sonar.example.com", + ReportTaskFile: "/path/to/report", + MaxRetries: &maxRetries, + RetryInterval: &retryInterval, + Proxy: "http://proxy.example.com", + }, + } + + assert.Equal(t, "http://sonar.example.com", config.Sonar.URL) + assert.Equal(t, "/path/to/report", config.Sonar.ReportTaskFile) + assert.Equal(t, 5, *config.Sonar.MaxRetries) + assert.Equal(t, 10, *config.Sonar.RetryInterval) + assert.Equal(t, "http://proxy.example.com", config.Sonar.Proxy) +} diff --git a/evidence/evidenceproviders/evidenceprovider.go b/evidence/evidenceproviders/evidenceprovider.go new file mode 100644 index 00000000..36b2ed23 --- /dev/null +++ b/evidence/evidenceproviders/evidenceprovider.go @@ -0,0 +1,5 @@ +package evidenceproviders + +type EvidenceProvider interface { + GetEvidence() ([]byte, error) +} diff --git a/evidence/evidenceproviders/sonarqube/autodetectclient.go b/evidence/evidenceproviders/sonarqube/autodetectclient.go new file mode 100644 index 00000000..5e8cc897 --- /dev/null +++ b/evidence/evidenceproviders/sonarqube/autodetectclient.go @@ -0,0 +1,38 @@ +package sonarqube + +import ( + "os" + "path/filepath" + + "github.com/jfrog/jfrog-client-go/utils/log" +) + +type ReportInfo struct { + Tool string + Path string +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +// DetectBuildToolAndReportFilePath checks for the presence of report-task.txt files in common locations for various build tools. +func DetectBuildToolAndReportFilePath() string { + candidates := []ReportInfo{ + {"maven", "target/sonar/report-task.txt"}, + {"gradle", "build/sonar/report-task.txt"}, + {"cli", ".scannerwork/report-task.txt"}, + {"msbuild", ".sonarqube/out/.sonar/report-task.txt"}, + } + + for _, c := range candidates { + if fileExists(c.Path) { + log.Debug("Found report for", c.Tool, "at", c.Path) + return c.Path + } + } + + log.Debug("No report-task.txt found. Falling back to sonar CLI default.") + return filepath.Join(".scannerwork/report-task.txt") +} diff --git a/evidence/evidenceproviders/sonarqube/autodetectclient_test.go b/evidence/evidenceproviders/sonarqube/autodetectclient_test.go new file mode 100644 index 00000000..304a1c7c --- /dev/null +++ b/evidence/evidenceproviders/sonarqube/autodetectclient_test.go @@ -0,0 +1,89 @@ +package sonarqube + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDetectBuildToolAndReportFilePath(t *testing.T) { + testCases := []struct { + name string + prepare func() string + expectedPath string + cleanup func() + }{ + { + name: "maven report present", + prepare: func() string { + path := filepath.Join("target/sonar/report-task.txt") + os.MkdirAll(filepath.Dir(path), 0755) + os.WriteFile(path, []byte("dummy"), 0644) + return path + }, + cleanup: func() { + path := filepath.Join("target") + os.RemoveAll(path) + }, + }, + { + name: "gradle report present", + prepare: func() string { + path := filepath.Join("build/sonar/report-task.txt") + os.MkdirAll(filepath.Dir(path), 0755) + os.WriteFile(path, []byte("dummy"), 0644) + return path + }, + cleanup: func() { + path := filepath.Join("build") + os.RemoveAll(path) + }, + }, + { + name: "cli report present", + prepare: func() string { + path := filepath.Join(".scannerwork/report-task.txt") + os.MkdirAll(filepath.Dir(path), 0755) + os.WriteFile(path, []byte("dummy"), 0644) + return path + }, + cleanup: func() { + path := filepath.Join(".scannerwork") + os.RemoveAll(path) + }, + }, + { + name: "msbuild report present", + prepare: func() string { + path := filepath.Join(".sonarqube/out/.sonar/report-task.txt") + os.MkdirAll(filepath.Dir(path), 0755) + os.WriteFile(path, []byte("dummy"), 0644) + return path + }, + cleanup: func() { + path := filepath.Join(".sonarqube") + os.RemoveAll(path) + }}, + { + name: "no report present, fallback to cli", + prepare: func() string { + return filepath.Join(".scannerwork/report-task.txt") + }, + cleanup: func() { + path := filepath.Join(".scannerwork") + os.RemoveAll(path) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + expectedPath := tc.prepare() + path := DetectBuildToolAndReportFilePath() + assert.Equal(t, expectedPath, path) + tc.cleanup() + }) + } +} diff --git a/evidence/evidenceproviders/sonarqube/sonarqube.go b/evidence/evidenceproviders/sonarqube/sonarqube.go new file mode 100644 index 00000000..1a4eed3a --- /dev/null +++ b/evidence/evidenceproviders/sonarqube/sonarqube.go @@ -0,0 +1,270 @@ +package sonarqube + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "github.com/jfrog/jfrog-cli-artifactory/evidence/evidenceproviders" + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/evidence" + "github.com/jfrog/jfrog-client-go/evidence/external/sonarqube" + "github.com/jfrog/jfrog-client-go/utils" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/log" + "gopkg.in/yaml.v3" + "net/url" + "os" + "strconv" + "strings" +) + +const ( + DefaultSonarHost = "https://sonarcloud.io" + DefaultReportTaskFile = ".scannerwork/report-task.txt" // target/sonar/report-task.txt + DefaultRetries = 3 + DefaultIntervalInSeconds = 10 + SonarTaskStatusSuccess = "SUCCESS" +) + +var ( + getConfigFunc = evidenceproviders.GetConfig + fetchSonarEvidenceWithRetryFunc = FetchSonarEvidenceWithRetry +) + +type SonarEvidence struct { + ServerDetails *config.ServerDetails + SonarConfig *evidenceproviders.SonarConfig +} + +type TaskReport struct { + Task Task `json:"task"` +} + +type Task struct { + ID string `json:"id"` + Type string `json:"type"` + ComponentID string `json:"componentId"` + ComponentKey string `json:"componentKey"` + ComponentName string `json:"componentName"` + ComponentQualifier string `json:"componentQualifier"` + AnalysisID string `json:"analysisId"` + Status string `json:"status"` + SubmittedAt string `json:"submittedAt"` + SubmitterLogin string `json:"submitterLogin"` + StartedAt string `json:"startedAt"` + ExecutedAt string `json:"executedAt"` + ExecutionTimeMs int `json:"executionTimeMs"` + HasScannerContext bool `json:"hasScannerContext"` + WarningCount int `json:"warningCount"` + Warnings []string `json:"warnings"` + InfoMessages []string `json:"infoMessages"` +} + +func NewSonarConfig(url, reportTaskFile, maxRetries, retryInterval, proxy string) *evidenceproviders.SonarConfig { + log.Debug("Creating sonarqube config: URL: " + url + " reportTaskFile: " + reportTaskFile + " maxRetries: " + maxRetries + " retryInterval: " + retryInterval) + var retriesAllowed, retryCoolingPeriodSecs *int + retries, err := strconv.Atoi(maxRetries) + if err != nil { + log.Warn("Invalid maxRetries config, using default of 0") + retries = 0 + } + retriesAllowed = &retries + retryIntervalSecs, err := strconv.Atoi(retryInterval) + if err != nil { + log.Warn("Invalid retryInterval config, using default of 0") + retryIntervalSecs = 0 + } + retryCoolingPeriodSecs = &retryIntervalSecs + return &evidenceproviders.SonarConfig{ + URL: url, + ReportTaskFile: reportTaskFile, + MaxRetries: retriesAllowed, + RetryInterval: retryCoolingPeriodSecs, + Proxy: proxy, + } +} + +// CreateSonarEvidence creates the evidence using the sonar configuration. +// It reads the sonar configuration from the evidence.yaml file in the .jfrog/evidence directory. +// It filters the sonar configuration to only include the fields that are needed for the sonar evidence. +func CreateSonarEvidence() (*SonarEvidence, error) { + externalEvidenceProviderConfig, err := evidenceproviders.GetConfig() + if err != nil || externalEvidenceProviderConfig["sonar"] == nil { + if errors.Is(err, evidenceproviders.ErrEvidenceDirNotExist) || externalEvidenceProviderConfig["sonar"] == nil { + log.Debug("No external evidence provider config found, using default sonar config") + return &SonarEvidence{SonarConfig: NewDefaultSonarConfig()}, nil + } + return nil, err + } + sonarConfig, err := CreateSonarConfiguration(externalEvidenceProviderConfig["sonar"]) + if err != nil { + return nil, err + } + return &SonarEvidence{SonarConfig: sonarConfig}, nil +} + +func NewDefaultSonarConfig() *evidenceproviders.SonarConfig { + retries := func() *int { v := DefaultRetries; return &v }() + interval := func() *int { v := DefaultIntervalInSeconds; return &v }() + reportTaskFilePath := DetectBuildToolAndReportFilePath() + taskURL, _ := GetSonarHostURLTaskIDFromReportTaskFile(reportTaskFilePath) + return &evidenceproviders.SonarConfig{ + URL: taskURL, + ReportTaskFile: reportTaskFilePath, + MaxRetries: retries, + RetryInterval: interval, + Proxy: "", + } +} + +func (se *SonarEvidence) GetEvidence() ([]byte, error) { + err := validateSonarConfig(se) + if err != nil { + return nil, err + } + log.Debug("Retrieving evidence from sonarqube server ", se.SonarConfig.URL, se.SonarConfig.Proxy) + sonarReport, err := fetchSonarEvidenceWithRetryFunc( + se.SonarConfig.URL, + se.SonarConfig.ReportTaskFile, + se.SonarConfig.Proxy, + *se.SonarConfig.MaxRetries, + *se.SonarConfig.RetryInterval, + ) + if err != nil { + return nil, err + } + log.Info("Fetched sonar evidence successfully") + return sonarReport, nil +} + +func validateSonarConfig(se *SonarEvidence) error { + if se.SonarConfig == nil { + se.SonarConfig = new(evidenceproviders.SonarConfig) + } + if se.SonarConfig.ReportTaskFile == "" { + se.SonarConfig.ReportTaskFile = DetectBuildToolAndReportFilePath() + } + if se.SonarConfig.MaxRetries == nil { + se.SonarConfig.MaxRetries = func() *int { v := DefaultRetries; return &v }() + } + if se.SonarConfig.RetryInterval == nil { + se.SonarConfig.RetryInterval = func() *int { v := DefaultIntervalInSeconds; return &v }() + } + if err := validateSonarAccessToken(); err != nil { + return err + } + return nil +} + +func validateSonarAccessToken() error { + sonarQubeToken := os.Getenv(sonarqube.SonarAccessTokenKey) + if sonarQubeToken == "" { + return errorutils.CheckErrorf("Sonar access token not found in environment variable " + sonarqube.SonarAccessTokenKey) + } + return nil +} + +func CreateSonarConfiguration(yamlNode *yaml.Node) (sonarConfig *evidenceproviders.SonarConfig, err error) { + if yamlNode == nil { + return nil, errorutils.CheckError(errors.New("sonar config is empty")) + } + if err := yamlNode.Decode(&sonarConfig); err != nil { + return nil, err + } + log.Debug("Read sonarqube config with these values", sonarConfig.URL, sonarConfig.ReportTaskFile, sonarConfig.MaxRetries, sonarConfig.RetryInterval) + return sonarConfig, nil +} + +// FetchSonarEvidenceWithRetry fetches the sonar evidence using the sonar configuration. +// Reads report-task.txt and fetches taskURL and taskID +// It retries the request if it fails or if the task is still in progress or pending depending on the sonar config. +// It returns the evidence data if the task is successful or an error if it fails. +func FetchSonarEvidenceWithRetry(sonarQubeURL, reportTaskFile, proxy string, maxRetries, retryInterval int) (data []byte, err error) { + taskURL, taskID := GetSonarHostURLTaskIDFromReportTaskFile(reportTaskFile) + if sonarQubeURL == "" { + sonarQubeURL = taskURL + } + log.Debug(fmt.Sprintf("Fetching sonarqube task status using taskID %s sonarqube URL %s", taskID, sonarQubeURL)) + if taskID == "" { + return nil, errorutils.CheckError(errors.New("unable to determine task ID from report task file: " + reportTaskFile)) + } + evd := &evidence.EvidenceServicesManager{} + var taskReport *TaskReport + retryExecutor := utils.RetryExecutor{ + Context: context.Background(), + MaxRetries: maxRetries, + RetriesIntervalMilliSecs: retryInterval * 1000, + ExecutionHandler: func() (shouldRetry bool, err error) { + taskReport = new(TaskReport) + evidenceData, err := evd.FetchSonarTaskStatus(taskID, sonarQubeURL, proxy) + if err != nil || evidenceData == nil { + return true, err + } + err = json.Unmarshal(evidenceData, &taskReport) + if err != nil { + return true, err + } + if taskReport.Task.Status == "PENDING" || taskReport.Task.Status == "IN-PROGRESS" { + return true, nil + } else if taskReport.Task.Status == SonarTaskStatusSuccess { + return false, nil + } + return true, nil + }, + } + err = retryExecutor.Execute() + if err != nil { + return nil, err + } + if taskReport.Task.Status != SonarTaskStatusSuccess { + return nil, errorutils.CheckError(errors.New("Sonar task with unexpected status: " + taskReport.Task.Status)) + } + return evd.GetSonarAnalysisReport(taskReport.Task.AnalysisID, sonarQubeURL, proxy) +} + +func parseSonarHostURLFromTaskURL(taskURL string) string { + parsedURL, err := url.Parse(taskURL) + if err != nil { + log.Debug("Failed to parse sonar URL from report-task", taskURL, "setting to default host") + return DefaultSonarHost + } + return parsedURL.Scheme + "://" + parsedURL.Host +} + +func getCeTaskIDAndURLFromReportTaskFile(filePath string) (string, string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", "", errorutils.CheckError(errors.New("failed to open file: " + err.Error())) + } + defer func(file *os.File) { + err := file.Close() + if err != nil { + log.Error("Failed to close file: " + err.Error()) + } + }(file) + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "ceTaskUrl=") { + taskIDs := strings.Split(line, "?id=") + if len(taskIDs) < 2 { + log.Error("Invalid ceTaskUrl format in file") + return "", "", errorutils.CheckError(errors.New("invalid ceTaskUrl format in file")) + } + taskIDs[0] = strings.TrimPrefix(taskIDs[0], "ceTaskUrl=") + return taskIDs[0], taskIDs[1], nil + } + } + return "", "", errorutils.CheckError(errors.New("ceTaskUrl not found in file")) +} + +func GetSonarHostURLTaskIDFromReportTaskFile(reportTaskFilePath string) (string, string) { + sonarURL, taskID, err := getCeTaskIDAndURLFromReportTaskFile(reportTaskFilePath) + if err != nil { + log.Warn(err.Error(), "falling back to default url") + return DefaultSonarHost, taskID + } + return parseSonarHostURLFromTaskURL(sonarURL), taskID +} diff --git a/evidence/evidenceproviders/sonarqube/sonarqube_test.go b/evidence/evidenceproviders/sonarqube/sonarqube_test.go new file mode 100644 index 00000000..5172b321 --- /dev/null +++ b/evidence/evidenceproviders/sonarqube/sonarqube_test.go @@ -0,0 +1,362 @@ +package sonarqube + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/jfrog/jfrog-cli-artifactory/evidence/evidenceproviders" + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" +) + +func TestNewSonarConfig(t *testing.T) { + config := NewSonarConfig("http://sonar.example.com", "/path/to/report", "5", "10", "http://proxy.example.com") + assert.Equal(t, "http://sonar.example.com", config.URL) + assert.Equal(t, "/path/to/report", config.ReportTaskFile) + assert.Equal(t, 5, *config.MaxRetries) + assert.Equal(t, 10, *config.RetryInterval) + assert.Equal(t, "http://proxy.example.com", config.Proxy) + + config = NewSonarConfig("http://sonar.example.com", "/path/to/report", "invalid", "10", "http://proxy.example.com") + assert.Equal(t, 0, *config.MaxRetries) + + config = NewSonarConfig("http://sonar.example.com", "/path/to/report", "5", "invalid", "http://proxy.example.com") + assert.Equal(t, 0, *config.RetryInterval) +} + +func TestNewDefaultSonarConfig(t *testing.T) { + config := NewDefaultSonarConfig() + assert.Equal(t, DefaultSonarHost, config.URL) + assert.Equal(t, DefaultReportTaskFile, config.ReportTaskFile) + assert.Equal(t, DefaultRetries, *config.MaxRetries) + assert.Equal(t, DefaultIntervalInSeconds, *config.RetryInterval) + assert.Equal(t, "", config.Proxy) +} + +func TestCreateSonarConfiguration(t *testing.T) { + yamlStr := ` +url: "http://sonar.example.com" +reportTaskFile: "/path/to/report" +maxRetries: 5 +retryIntervalInSecs: 10 +proxy: "http://proxy.example.com" +` + var node yaml.Node + err := yaml.Unmarshal([]byte(yamlStr), &node) + assert.NoError(t, err) + + config, err := CreateSonarConfiguration(&node) + assert.NoError(t, err) + assert.Equal(t, "http://sonar.example.com", config.URL) + assert.Equal(t, "/path/to/report", config.ReportTaskFile) + assert.Equal(t, 5, *config.MaxRetries) + assert.Equal(t, 10, *config.RetryInterval) + assert.Equal(t, "http://proxy.example.com", config.Proxy) + + _, err = CreateSonarConfiguration(nil) + assert.Error(t, err) +} + +func TestGetCeTaskUrlFromFile(t *testing.T) { + tempDir := t.TempDir() + validFilePath := filepath.Join(tempDir, "valid-report-task.txt") + validContent := ` +projectKey=my-project +serverUrl=https://sonarcloud.io +serverVersion=8.0 +dashboardUrl=https://sonarcloud.io/dashboard?id=my-project +ceTaskId=task-id-123 +ceTaskUrl=https://sonarcloud.io/api/ce/task?id=task-id-123 +` + err := os.WriteFile(validFilePath, []byte(validContent), 0644) + assert.NoError(t, err) + + _, taskID, err := getCeTaskIDAndURLFromReportTaskFile(validFilePath) + assert.NoError(t, err) + assert.Equal(t, "task-id-123", taskID) + + invalidFilePath := filepath.Join(tempDir, "invalid-report-task.txt") + invalidContent := ` +projectKey=my-project +serverUrl=https://sonarcloud.io +serverVersion=8.0 +dashboardUrl=https://sonarcloud.io/dashboard?id=my-project +` + err = os.WriteFile(invalidFilePath, []byte(invalidContent), 0644) + assert.NoError(t, err) + + _, _, err = getCeTaskIDAndURLFromReportTaskFile(invalidFilePath) + assert.Error(t, err) + + malformedFilePath := filepath.Join(tempDir, "malformed-report-task.txt") + malformedContent := ` +projectKey=my-project +serverUrl=https://sonarcloud.io +ceTaskUrl=malformed-url-without-id +` + err = os.WriteFile(malformedFilePath, []byte(malformedContent), 0644) + assert.NoError(t, err) + + _, _, err = getCeTaskIDAndURLFromReportTaskFile(malformedFilePath) + assert.Error(t, err) + + _, _, err = getCeTaskIDAndURLFromReportTaskFile(filepath.Join(tempDir, "non-existent.txt")) + assert.Error(t, err) +} + +func TestCreateSonarEvidence(t *testing.T) { + reportTaskFile, err := createReportTaskFile(t) + scannerworkDir := getScannerWorkingDirectory(t) + defer func(path string) { + err := deleteDirectoryIfExists(path) + if err != nil { + assert.NoError(t, err) + } + }(scannerworkDir) + yamlStr, _, err := createEvidenceYamlFile(t, reportTaskFile) + jfrogDir, _ := createJFrogDirectory(t) + defer func(path string) { + err := deleteDirectoryIfExists(path) + if err != nil { + assert.NoError(t, err) + } + }(jfrogDir) + + // Save original function and restore after test + originalGetConfig := getConfigFunc + defer func() { getConfigFunc = originalGetConfig }() + var node yaml.Node + err = yaml.Unmarshal([]byte(yamlStr), &node) + assert.NoError(t, err) + + getConfigFunc = func() (map[string]*yaml.Node, error) { + return map[string]*yaml.Node{"sonar": &node}, nil + } + + sonarEvidence, err := CreateSonarEvidence() + assert.NoError(t, err) + assert.NotNil(t, sonarEvidence) + assert.Equal(t, "http://sonar.example.com", sonarEvidence.SonarConfig.URL) + assert.Equal(t, reportTaskFile, sonarEvidence.SonarConfig.ReportTaskFile) + assert.Equal(t, 5, *sonarEvidence.SonarConfig.MaxRetries) + assert.Equal(t, 10, *sonarEvidence.SonarConfig.RetryInterval) + assert.Equal(t, "http://proxy.example.com", sonarEvidence.SonarConfig.Proxy) + + getConfigFunc = func() (map[string]*yaml.Node, error) { + return nil, assert.AnError + } +} + +func TestCreateSonarEvidenceIfEvidenceConfigAndReportTaskNotAvailable(t *testing.T) { + sonarEvidence, err := CreateSonarEvidence() + assert.NoError(t, err) + assert.NotNil(t, sonarEvidence) + assert.Equal(t, "https://sonarcloud.io", sonarEvidence.SonarConfig.URL) + assert.Equal(t, ".scannerwork/report-task.txt", sonarEvidence.SonarConfig.ReportTaskFile) + assert.Equal(t, 3, *sonarEvidence.SonarConfig.MaxRetries) + assert.Equal(t, 10, *sonarEvidence.SonarConfig.RetryInterval) + assert.Equal(t, "", sonarEvidence.SonarConfig.Proxy) + + getConfigFunc = func() (map[string]*yaml.Node, error) { + return nil, assert.AnError + } +} + +func deleteDirectoryIfExists(path string) error { + if _, err := os.Stat(path); err == nil { + return os.RemoveAll(path) + } + return nil +} + +func createReportTaskFile(t *testing.T) (string, error) { + scannerworkDir := getScannerWorkingDirectory(t) + reportTaskFile := filepath.Join(scannerworkDir, "report-task.yaml") + err := os.MkdirAll(scannerworkDir, 0755) + assert.NoError(t, err) + reportTaskContent := `ceTaskId=task-id-123 +ceTaskUrl=https://sonarcloud.io/api/ce/task?id=task-id-123 +` + err = os.WriteFile(reportTaskFile, []byte(reportTaskContent), 0644) + assert.NoError(t, err) + return reportTaskFile, err +} + +func getScannerWorkingDirectory(t *testing.T) string { + cwd := getCurrentWorkingDirectory(t) + scannerworkDir := filepath.Join(cwd, ".scannerwork") + return scannerworkDir +} + +func getCurrentWorkingDirectory(t *testing.T) string { + cwd, err := os.Getwd() + assert.NoError(t, err) + return cwd +} + +func createEvidenceYamlFile(t *testing.T, reportTaskFile string) (string, string, error) { + yamlStr := ` +sonar: + url: "http://sonar.example.com" + reportTaskFile: "` + reportTaskFile + `" + maxRetries: 5 + retryIntervalInSecs: 10 + proxy: "http://proxy.example.com" +` + evidenceDir, err := createEvidenceDirectory(t) + evidenceFile := filepath.Join(evidenceDir, "evidence.yaml") + err = os.MkdirAll(evidenceDir, 0755) + assert.NoError(t, err) + err = os.WriteFile(evidenceFile, []byte(yamlStr), 0644) + assert.NoError(t, err) + return yamlStr, evidenceFile, err +} + +func createEvidenceDirectory(t *testing.T) (string, error) { + // Create temporary directory for test + cwd, err := os.Getwd() + assert.NoError(t, err) + evidenceDir := filepath.Join(cwd, ".jfrog", "evidence") + return evidenceDir, err +} + +func createJFrogDirectory(t *testing.T) (string, error) { + // Create temporary directory for test + cwd, err := os.Getwd() + assert.NoError(t, err) + evidenceDir := filepath.Join(cwd, ".jfrog") + return evidenceDir, err +} + +func TestFetchSonarEvidenceWithRetry(t *testing.T) { + err := os.Setenv("JF_SONARQUBE_ACCESS_TOKEN", "test") + assert.NoError(t, err) + defer func() { + err = os.Unsetenv("JF_SONARQUBE_ACCESS_TOKEN") + if err != nil { + assert.NoError(t, err) + } + }() + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/ce/task" { + taskID := r.URL.Query().Get("id") + if taskID == "task-success" { + task := TaskReport{ + Task: Task{ + ID: "task-success", + Status: "SUCCESS", + AnalysisID: "analysis-123", + }, + } + err := json.NewEncoder(w).Encode(task) + if err != nil { + assert.NoError(t, err) + } + } else if taskID == "task-pending" { + task := TaskReport{ + Task: Task{ + ID: "task-pending", + Status: "PENDING", + }, + } + json.NewEncoder(w).Encode(task) + } else if taskID == "task-progress" { + task := TaskReport{ + Task: Task{ + ID: "task-progress", + Status: "IN-PROGRESS", + }, + } + json.NewEncoder(w).Encode(task) + } else { + w.WriteHeader(http.StatusNotFound) + } + } else if r.URL.Path == "/api/qualitygates/project_status" { + analysisID := r.URL.Query().Get("analysisId") + if analysisID == "analysis-123" { + w.Write([]byte(`{"projectStatus":{"status":"OK"}}`)) + } else { + w.WriteHeader(http.StatusNotFound) + } + } else { + w.WriteHeader(http.StatusNotFound) + } + })) + defer mockServer.Close() + + tempDir := t.TempDir() + + reportTaskPath := filepath.Join(tempDir, "report-task.txt") + reportContent := `ceTaskUrl=` + mockServer.URL + `/api/ce/task?id=task-success` + err = os.WriteFile(reportTaskPath, []byte(reportContent), 0644) + assert.NoError(t, err) + + data, err := FetchSonarEvidenceWithRetry(mockServer.URL, reportTaskPath, "", 1, 1) + assert.NoError(t, err) + assert.NotNil(t, data) + assert.Contains(t, string(data), `"status":"OK"`) + + pendingTaskPath := filepath.Join(tempDir, "pending-task.txt") + pendingContent := `ceTaskUrl=` + mockServer.URL + `/api/ce/task?id=task-pending` + err = os.WriteFile(pendingTaskPath, []byte(pendingContent), 0644) + assert.NoError(t, err) + + _, err = FetchSonarEvidenceWithRetry(mockServer.URL, pendingTaskPath, "", 2, 1) + assert.Error(t, err) + + _, err = FetchSonarEvidenceWithRetry(mockServer.URL, filepath.Join(tempDir, "non-existent.txt"), "", 1, 1) + assert.Error(t, err) +} + +func TestSonarEvidence_GetEvidence(t *testing.T) { + err := os.Setenv("JF_SONARQUBE_ACCESS_TOKEN", "test") + assert.NoError(t, err) + defer func() { + err = os.Unsetenv("JF_SONARQUBE_ACCESS_TOKEN") + if err != nil { + assert.NoError(t, err) + } + }() + reportTaskFile, err := createReportTaskFile(t) + assert.NoError(t, err) + scannerworkDir := getScannerWorkingDirectory(t) + defer deleteDirectoryIfExists(scannerworkDir) + _, _, err = createEvidenceYamlFile(t, reportTaskFile) + assert.NoError(t, err) + jfrogDir, err := createJFrogDirectory(t) + assert.NoError(t, err) + defer deleteDirectoryIfExists(jfrogDir) + maxRetries := 3 + retryInterval := 5 + sonarEvidence := &SonarEvidence{ + SonarConfig: &evidenceproviders.SonarConfig{ + URL: "http://sonar.example.com", + ReportTaskFile: reportTaskFile, + MaxRetries: &maxRetries, + RetryInterval: &retryInterval, + Proxy: "http://proxy.example.com", + }, + } + + originalFunc := fetchSonarEvidenceWithRetryFunc + defer func() { fetchSonarEvidenceWithRetryFunc = originalFunc }() + + fetchSonarEvidenceWithRetryFunc = func(sonarQubeURL, reportTaskFile, proxy string, maxRetries, retryInterval int) ([]byte, error) { + return []byte(`{"projectStatus":{"status":"OK"}}`), nil + } + + evidence, err := sonarEvidence.GetEvidence() + assert.NoError(t, err) + assert.Equal(t, `{"projectStatus":{"status":"OK"}}`, string(evidence)) + + fetchSonarEvidenceWithRetryFunc = func(sonarQubeURL, reportTaskFile, proxy string, maxRetries, retryInterval int) ([]byte, error) { + return nil, assert.AnError + } + + _, err = sonarEvidence.GetEvidence() + assert.Error(t, err) +} diff --git a/go.mod b/go.mod index 9e43458c..eaa399ff 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ toolchain go1.24.2 require ( github.com/c-bata/go-prompt v0.2.5 github.com/forPelevin/gomoji v1.3.0 - github.com/jfrog/build-info-go v1.10.10 + github.com/jfrog/build-info-go v1.10.11 github.com/jfrog/gofrog v1.7.6 github.com/jfrog/jfrog-cli-core/v2 v2.58.3 github.com/jfrog/jfrog-client-go v1.52.0 @@ -20,6 +20,7 @@ require ( golang.org/x/crypto v0.36.0 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 golang.org/x/mod v0.24.0 + gopkg.in/yaml.v3 v3.0.1 ) require golang.org/x/net v0.38.0 // indirect @@ -110,7 +111,6 @@ require ( gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/api v0.29.7 // indirect k8s.io/apimachinery v0.29.7 // indirect k8s.io/klog/v2 v2.130.1 // indirect @@ -123,7 +123,7 @@ require ( //replace github.com/jfrog/jfrog-cli-core/v2 => github.com/jfrog/jfrog-cli-core/v2 v2.31.1-0.20250410085750-f34f5feea93e -//replace github.com/jfrog/jfrog-client-go => github.com/jfrog/jfrog-client-go v1.28.1-0.20250406105605-ee90d11546f9 +replace github.com/jfrog/jfrog-client-go => github.com/bhanurp/jfrog-client-go v1.28.1-0.20250608133457-6a4cfafe1865 //replace github.com/jfrog/jfrog-cli-core/v2 => github.com/jfrog/jfrog-cli-core/v2 v2.31.1-0.20240811150357-12a9330a2d67 //replace github.com/jfrog/jfrog-client-go => github.com/jfrog/jfrog-client-go v1.28.1-0.20240811142930-ab9715567376 diff --git a/go.sum b/go.sum index 6ebc8f20..80c75d10 100644 --- a/go.sum +++ b/go.sum @@ -21,6 +21,8 @@ github.com/apache/camel-k/v2 v2.5.0 h1:voFPrxhuaedKn68RerS+QkXYXyZ+5tBfVaAc7QYOg github.com/apache/camel-k/v2 v2.5.0/go.mod h1:vLrJAJAp9EGxY54cUR7VHzIF70JHfFzk4OOaYRfLr44= 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/bhanurp/jfrog-client-go v1.28.1-0.20250608133457-6a4cfafe1865 h1:kilH1D7qR3aOv+pEfC1ErirRFiNXnYdYIwp01XLOvaI= +github.com/bhanurp/jfrog-client-go v1.28.1-0.20250608133457-6a4cfafe1865/go.mod h1:uRmT8Q1SJymIzId01v0W1o8mGqrRfrwUF53CgEMsH0U= github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= @@ -109,14 +111,12 @@ github.com/jedib0t/go-pretty/v6 v6.6.5 h1:9PgMJOVBedpgYLI56jQRJYqngxYAAzfEUua+3N github.com/jedib0t/go-pretty/v6 v6.6.5/go.mod h1:Uq/HrbhuFty5WSVNfjpQQe47x16RwVGXIveNGEyGtHs= github.com/jfrog/archiver/v3 v3.6.1 h1:LOxnkw9pOn45DzCbZNFV6K0+6dCsQ0L8mR3ZcujO5eI= github.com/jfrog/archiver/v3 v3.6.1/go.mod h1:VgR+3WZS4N+i9FaDwLZbq+jeU4B4zctXL+gL4EMzfLw= -github.com/jfrog/build-info-go v1.10.10 h1:2nOFjV7SX1uisi2rQK7fb4Evm7YkSOdmssrm6Tf4ipc= -github.com/jfrog/build-info-go v1.10.10/go.mod h1:JcISnovFXKx3wWf3p1fcMmlPdt6adxScXvoJN4WXqIE= +github.com/jfrog/build-info-go v1.10.11 h1:wAMGCAHa49+ec01HqzSidLAHNIub+glh4ksFp3pYy7o= +github.com/jfrog/build-info-go v1.10.11/go.mod h1:JcISnovFXKx3wWf3p1fcMmlPdt6adxScXvoJN4WXqIE= github.com/jfrog/gofrog v1.7.6 h1:QmfAiRzVyaI7JYGsB7cxfAJePAZTzFz0gRWZSE27c6s= github.com/jfrog/gofrog v1.7.6/go.mod h1:ntr1txqNOZtHplmaNd7rS4f8jpA5Apx8em70oYEe7+4= github.com/jfrog/jfrog-cli-core/v2 v2.58.3 h1:S3S8BLLiysox5OafGGjdsLy2BGrmM7UPu/AEedz4mpA= github.com/jfrog/jfrog-cli-core/v2 v2.58.3/go.mod h1:JbLdMCWL0xVZZ0FY7jd+iTi/gKYqRmRpeLBhAFtldfQ= -github.com/jfrog/jfrog-client-go v1.52.0 h1:MCmHviUqj3X7iqyOokTkyvV5yBWFwZYDPVXYikl4nf0= -github.com/jfrog/jfrog-client-go v1.52.0/go.mod h1:uRmT8Q1SJymIzId01v0W1o8mGqrRfrwUF53CgEMsH0U= 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/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=