pkg/client/sonar/sonar.go (564 lines of code) (raw):

package sonar import ( "context" "encoding/json" "errors" "fmt" "net/url" "reflect" "strings" "time" "github.com/go-resty/resty/v2" "golang.org/x/text/cases" "golang.org/x/text/language" ctrl "sigs.k8s.io/controller-runtime" "github.com/epam/edp-sonar-operator/pkg/helper" ) const ( cantUnmarshalMsg = "failed to unmarshal %s: %w" nameField = "name" loginField = "login" jsonContentType = "application/json" contentTypeField = "Content-Type" retryCount = 60 timeOut = 10 ) // SystemHealthResponse provides status of SonarQube. // https://next.sonarqube.com/sonarqube/web_api/api/system/health type SystemHealthResponse struct { // GREEN: SonarQube is fully operational // YELLOW: SonarQube is usable, but it needs attention in order to be fully operational // RED: SonarQube is not operational Health string `json:"health"` Causes []any `json:"causes"` Nodes []any `json:"nodes"` } var log = ctrl.Log.WithName("sonar_client") type Client struct { resty *resty.Client } func NewClient(url string, user string, password string) *Client { u := strings.TrimSuffix(url, "/") if !strings.HasSuffix(url, "api") { u = fmt.Sprintf("%s/api", u) } return &Client{ resty: resty.New().SetBaseURL(u).SetBasicAuth(user, password), } } func (sc *Client) jsonTypeRequest() *resty.Request { return sc.resty.R().SetHeader(contentTypeField, jsonContentType) } func (sc *Client) startRequest(ctx context.Context) *resty.Request { return sc.resty.R(). SetHeaders(map[string]string{ contentTypeField: "application/x-www-form-urlencoded", "Accept": jsonContentType, }). SetContext(ctx) } func (sc *Client) checkError(response *resty.Response, err error) error { if err != nil { return fmt.Errorf("response error: %w", err) } if response == nil { return errors.New("empty response") } if response.IsError() { return HTTPError{message: response.String(), code: response.StatusCode()} } return nil } func (sc *Client) ChangePassword(ctx context.Context, user string, oldPassword string, newPassword string) error { resp, err := sc.startRequest(ctx).Get("/system/health") if err != nil { return fmt.Errorf("failed check sonar health: %w", err) } if err = sc.checkError(resp, err); err != nil { return fmt.Errorf("failed to check sonar health: %w", err) } var systemHealthResponse SystemHealthResponse if err = json.Unmarshal(resp.Body(), &systemHealthResponse); err != nil { return fmt.Errorf(cantUnmarshalMsg, resp.Body(), err) } // make sure that Sonar is up and running if systemHealthResponse.Health != "GREEN" { return fmt.Errorf("sonar is not in green state, current state - %s; %v", systemHealthResponse.Health, systemHealthResponse) } resp, err = sc.startRequest(ctx). SetFormData(map[string]string{ loginField: user, "password": newPassword, "previousPassword": oldPassword, }). Post("/users/change_password") if err = sc.checkError(resp, err); err != nil { return fmt.Errorf("failed to change password: %w", err) } // so starting from SonarQube 8.9.9 they changed flow of "/api/users/change_password" endpoint // after successful change of password, Sonar refresh JWT token in cookie, // so we need to update cookie to get new token // https://github.com/SonarSource/sonarqube/commit/eb6741754b2b35172012bc5b30f5b0d53a61f7be#diff-be83bcff4cfc3fb4d04542ca6eea91cfe7738b7bd754d86eb366ce3e18b0aa34 sc.resty.SetCookies(resp.Cookies()) return nil } func (sc *Client) Reboot() error { resp, err := sc.resty.R(). Post("/system/restart") if err != nil { return fmt.Errorf("failed to send reboot request to Sonar: %w", err) } if resp.IsError() { return fmt.Errorf("failed to reboot sonar with response %s", resp.Status()) } return nil } type SystemStatusResponse struct { ID string `json:"id"` Version string `json:"version"` Status string `json:"status"` } // WaitForStatusIsUp waits for Sonar to be up // It retries the request for the specified number of times with the specified timeout func (sc *Client) WaitForStatusIsUp(retryCount int, timeout time.Duration) error { var systemStatusResponse SystemStatusResponse sc.resty.SetRetryCount(retryCount). SetRetryWaitTime(timeout). AddRetryCondition( func(response *resty.Response, err error) bool { if response.IsError() || !response.IsSuccess() { return response.IsError() } if err := json.Unmarshal([]byte(response.String()), &systemStatusResponse); err != nil { return true } log.Info(fmt.Sprintf("Current Sonar status - %s", systemStatusResponse.Status)) return systemStatusResponse.Status != "UP" }, ) defer sc.resty.SetRetryCount(0) resp, err := sc.resty.R(). Get("/system/status") if err != nil { return fmt.Errorf("failed to send request for current Sonar status!: %w", err) } if resp.IsError() { return fmt.Errorf("checking Sonar status failed. Response - %s", resp.Status()) } return nil } func (sc Client) InstallPlugins(plugins []string) error { installedPlugins, err := sc.GetInstalledPlugins() if err != nil { return fmt.Errorf("failed to get list of installed plugins: %w", err) } log.Info("List of installed plugins", "plugins", installedPlugins) needReboot := false for _, plugin := range plugins { if helper.CheckPluginInstalled(installedPlugins, plugin) { continue } needReboot = true resp, errPost := sc.resty.R(). SetBody(fmt.Sprintf("key=%s", plugin)). SetHeader(contentTypeField, "application/x-www-form-urlencoded"). Post("/plugins/install") if errPost != nil { return fmt.Errorf("failed to send plugin installation request for %s: %w", plugin, errPost) } if resp.IsError() { return fmt.Errorf("failed to install plugin %s, response: %s", plugin, resp.Status()) } log.Info(fmt.Sprintf("Plugin %s has been installed", plugin)) } if needReboot { if err = sc.Reboot(); err != nil { return err } if err = sc.WaitForStatusIsUp(retryCount, timeOut*time.Second); err != nil { return err } } log.Info("Plugins have been installed") return nil } type InstalledPluginsResponse struct { Plugins []Plugin `json:"plugins"` } type Plugin struct { Key string `json:"key"` Name string `json:"name"` Description string `json:"description"` Version string `json:"version"` License string `json:"license"` OrganizationName string `json:"organizationName"` OrganizationURL string `json:"organizationUrl"` EditionBundled bool `json:"editionBundled"` HomepageURL string `json:"homepageUrl"` IssueTrackerURL string `json:"issueTrackerUrl"` ImplementationBuild string `json:"implementationBuild"` Filename string `json:"filename"` Hash string `json:"hash"` SonarLintSupported bool `json:"sonarLintSupported"` DocumentationPath string `json:"documentationPath,omitempty"` UpdatedAt int `json:"updatedAt"` } func (sc Client) GetInstalledPlugins() ([]string, error) { resp, err := sc.resty.R().Get("/plugins/installed") if err = sc.checkError(resp, err); err != nil { return nil, err } var installedPluginsResponse InstalledPluginsResponse if err = json.Unmarshal(resp.Body(), &installedPluginsResponse); err != nil { return nil, fmt.Errorf(cantUnmarshalMsg, resp.Body(), err) } var installedPlugins []string for index := range installedPluginsResponse.Plugins { installedPlugins = append(installedPlugins, installedPluginsResponse.Plugins[index].Key) } return installedPlugins, nil } type QualityGatesCreateResponse struct { ID string `json:"id"` Name string `json:"name"` } type QualityGatesListResponse struct { QualityGates []QualityGate `json:"qualitygates"` Default interface{} `json:"default"` Actions QualityActions `json:"actions"` } type QualityActions struct { Create bool `json:"create"` } func (sc Client) UploadProfile(profileName string, profilePath string) (string, error) { log.Info("attempting to upload quality profile...", "profileName", profileName, "profilePath", profilePath) profileExist, profileId, isDefault, err := sc.checkProfileExist(profileName) if err != nil { return "", fmt.Errorf("failed to get quality profile: %w", err) } if profileExist && isDefault { return profileId, nil } if profileExist && !isDefault { if err = sc.setDefaultProfile("java", profileName); err != nil { return "", err } return profileId, nil } if !helper.FileExists(profilePath) { return "", fmt.Errorf("file %s does not exist in path provided: %s", profileName, profilePath) } log.Info(fmt.Sprintf("Uploading profile %s from path %s", profileName, profilePath)) resp, err := sc.resty.R(). SetHeader(contentTypeField, "multipart/form-data"). SetFile("backup", profilePath). Post("/qualityprofiles/restore") if err != nil { return "", fmt.Errorf("failed to send upload profile request!: %w", err) } if resp.IsError() { errMsg := fmt.Sprintf("Uploading profile %s failed. Response - %s", profileName, resp.Status()) return "", errors.New(errMsg) } _, profileId, _, err = sc.checkProfileExist(profileName) if err != nil { return "", err } err = sc.setDefaultProfile("java", profileName) if err != nil { return "", err } log.Info(fmt.Sprintf("Profile %s in Sonar from path %v has been uploaded and is set as default", profileName, profilePath)) return profileId, nil } type QualityProfilesSearchResponse struct { Profiles []Profiles `json:"profiles"` Actions Actions `json:"actions,omitempty"` } type Profiles struct { Key string `json:"key"` Name string `json:"name"` Language string `json:"language"` LanguageName string `json:"languageName,omitempty"` IsInherited bool `json:"isInherited,omitempty"` IsBuiltIn bool `json:"isBuiltIn,omitempty"` ActiveRuleCount int `json:"activeRuleCount,omitempty"` ActiveDeprecatedRuleCount int `json:"activeDeprecatedRuleCount,omitempty"` IsDefault bool `json:"isDefault"` RuleUpdatedAt string `json:"ruleUpdatedAt,omitempty"` LastUsed string `json:"lastUsed,omitempty"` Actions ProfileActions `json:"actions,omitempty"` ParentKey string `json:"parentKey,omitempty"` ParentName string `json:"parentName,omitempty"` ProjectCount int `json:"projectCount,omitempty"` UserUpdatedAt string `json:"userUpdatedAt,omitempty"` } type ProfileActions struct { Edit bool `json:"edit"` SetAsDefault bool `json:"setAsDefault"` Copy bool `json:"copy"` Delete bool `json:"delete"` AssociateProjects bool `json:"associateProjects"` } func (sc Client) checkProfileExist(requiredProfileName string) (exits bool, profileId string, isDefault bool, error error) { resp, err := sc.resty.R(). Get(fmt.Sprintf("/qualityprofiles/search?qualityProfile=%v", strings.ReplaceAll(requiredProfileName, " ", "+"))) if err != nil { return false, "", false, fmt.Errorf("failed to get default quality profile!: %w", err) } if resp.IsError() { errMsg := fmt.Sprintf("Request for quality profile failed! Response - %v", resp.StatusCode()) return false, "", false, errors.New(errMsg) } var qualityProfilesSearchResponse QualityProfilesSearchResponse err = json.Unmarshal(resp.Body(), &qualityProfilesSearchResponse) if err != nil { return false, "", false, fmt.Errorf("%s: %w", resp.Body(), err) } if qualityProfilesSearchResponse.Profiles == nil { return false, "", false, nil } profiles := qualityProfilesSearchResponse.Profiles for index := range profiles { if profiles[index].Name == requiredProfileName { return true, profiles[index].Key, profiles[index].IsDefault, nil } } return false, "", false, nil } func (sc Client) setDefaultProfile(language string, profileName string) error { resp, err := sc.jsonTypeRequest(). SetQueryParams(map[string]string{ "qualityProfile": profileName, "language": language, }). Post("/qualityprofiles/set_default") if err != nil { return errors.New("failed to send request to set default quality profile!") } if resp.IsError() { errMsg := fmt.Sprintf("Setting profile %s as default failed. Response - %s", profileName, resp.Status()) return errors.New(errMsg) } return nil } func (sc Client) AddPermissionsToGroup(groupName string, permissions string) error { log.Info(fmt.Sprintf("Start adding permissions %v to group %v", permissions, groupName)) resp, err := sc.jsonTypeRequest(). SetQueryParams(map[string]string{ "groupName": groupName, "permission": permissions, }). Post("/permissions/add_group") if err != nil { return fmt.Errorf("failed to send request to add permissions to group!: %w", err) } if resp.IsError() { errMsg := fmt.Sprintf("Adding permission %s to group %s failed. Response - %s", permissions, groupName, resp.Status()) return errors.New(errMsg) } log.Info(fmt.Sprintf("Permissions %v to group %v has been added", permissions, groupName)) return nil } type UserTokensGenerateResponse struct { Login string `json:"login"` Name string `json:"name"` CreatedAt string `json:"createdAt"` Token string `json:"token"` } func (sc Client) GenerateUserToken(userName string) (*string, error) { emptyString := "" log.Info(fmt.Sprintf("Start generating token for user %v in Sonar", userName)) resp, err := sc.jsonTypeRequest(). SetQueryParams(map[string]string{ loginField: userName, nameField: cases.Title(language.English).String(userName), }). Post("/user_tokens/generate") if err != nil { return &emptyString, fmt.Errorf("failed to send request for user token generation: %w", err) } if resp.IsError() { return nil, fmt.Errorf("failed to generate token for user %s with response %s", userName, resp.Status()) } log.Info(fmt.Sprintf("Token for user %v has been generated", userName)) var userTokensGenerateResponse UserTokensGenerateResponse if err = json.Unmarshal(resp.Body(), &userTokensGenerateResponse); err != nil { return nil, fmt.Errorf(cantUnmarshalMsg, resp.Body(), err) } token := userTokensGenerateResponse.Token return &token, nil } func (sc Client) AddWebhook(webhookName string, webhookUrl string) error { webHookExist, err := sc.checkWebhookExist(webhookName) if err != nil { return err } if webHookExist { return nil } log.Info(fmt.Sprintf("Start creating webhook %v in Sonar", webhookName)) resp, err := sc.jsonTypeRequest(). SetQueryParams(map[string]string{ nameField: webhookName, "url": webhookUrl, }). Post("/webhooks/create") if err != nil { return fmt.Errorf("failed to send request to add webhook: %w", err) } if resp.IsError() { return fmt.Errorf("failed to add webhook %s with response %s", webhookName, resp.Status()) } log.Info(fmt.Sprintf("Webhook %v has been created", webhookName)) return nil } type WebhooksListResponse struct { Webhooks []Webhook `json:"webhooks"` } type Webhook struct { Key string `json:"key"` Name string `json:"name"` URL string `json:"url"` Secret string `json:"secret,omitempty"` } func (sc Client) checkWebhookExist(webhookName string) (bool, error) { resp, err := sc.resty.R(). Get("/webhooks/list") if err != nil { return false, fmt.Errorf("failed to send request to list all webhooks!: %w", err) } if resp.IsError() { errMsg := fmt.Sprintf("failed to list webhooks on server! Response code - %v", resp.StatusCode()) return false, errors.New(errMsg) } var raw WebhooksListResponse err = json.Unmarshal(resp.Body(), &raw) if err != nil { return false, fmt.Errorf(cantUnmarshalMsg, resp.Body(), err) } for _, v := range raw.Webhooks { if v.Name == webhookName { return true, nil } } return false, nil } func (sc Client) ConfigureGeneralSettings(settings ...SettingRequest) error { for _, setting := range settings { if err := sc.configureGeneralSetting(setting); err != nil { return fmt.Errorf("failed to configure sonar sonar setting: %w", err) } } return nil } func (sc Client) configureGeneralSetting(setting SettingRequest) error { generalSettingsExist, err := sc.checkGeneralSetting(setting.Key, setting.Value) if err != nil { return err } if generalSettingsExist { return nil } resp, err := sc.jsonTypeRequest(). SetQueryParams( map[string]string{ "key": setting.Key, setting.ValueType: setting.Value, }). Post("/settings/set") if err != nil { return fmt.Errorf("failed to send request to configure general settings: %w", err) } if resp.IsError() { return fmt.Errorf("failed to configure %s: response code - %v", setting.Key, resp.StatusCode()) } log.Info(fmt.Sprintf("Setting %v has been set to %v", setting.Key, setting.Value)) return nil } type SettingsValuesResponse struct { Settings []Setting `json:"settings"` } type SettingRequest struct { Key string `json:"key"` Value string `json:"value,omitempty"` ValueType string `json:"type"` } type Setting struct { Key string `json:"key"` Value string `json:"value,omitempty"` Inherited bool `json:"inherited"` Values []string `json:"values,omitempty"` FieldValues []SettingFieldValue `json:"fieldValues,omitempty"` } type SettingFieldValue struct { Boolean string `json:"boolean"` Text string `json:"text"` } func (sc Client) checkGeneralSetting(key string, valueToCheck string) (bool, error) { resp, err := sc.resty.R(). Get("/settings/values") if err != nil || resp.IsError() { return false, err } var settingsValuesResponse SettingsValuesResponse err = json.Unmarshal(resp.Body(), &settingsValuesResponse) if err != nil { return false, fmt.Errorf("%s: %w", resp.Body(), err) } for _, v := range settingsValuesResponse.Settings { if v.Key == key { if v.Values != nil { if checkValue(v.Values, valueToCheck) { return true, nil } } else if v.Value != "" { if checkValue(v.Value, valueToCheck) { return true, nil } } } } return false, nil } func checkValue(value interface{}, valueToCheck string) bool { switch reflect.TypeOf(value).Kind() { case reflect.Slice: s := reflect.ValueOf(value) for i := 0; i < s.Len(); i++ { if valueToCheck == s.Index(i).Interface() { return true } } case reflect.String: if valueToCheck == value { return true } default: return false } return false } func (sc Client) SetProjectsDefaultVisibility(visibility string) error { resp, err := sc.resty.R(). SetBody(fmt.Sprintf("organization=default-organization&projectVisibility=%v", visibility)). SetHeader(contentTypeField, "application/x-www-form-urlencoded"). Post("/projects/update_default_visibility") if err != nil { return err } if resp.IsError() { errMsg := fmt.Sprintf("setting project visibility failed. Response - %s", resp.Status()) return errors.New(errMsg) } return nil } func (sc *Client) SetSetting(ctx context.Context, setting url.Values) error { rsp, err := sc.startRequest(ctx). SetFormDataFromValues(setting). Post("/settings/set") if err = sc.checkError(rsp, err); err != nil { return fmt.Errorf("failed to set setting: %w", err) } return nil } func (sc *Client) ResetSettings(ctx context.Context, settingsKeys []string) error { keys := strings.Join(settingsKeys, ",") rsp, err := sc.startRequest(ctx). SetFormData(map[string]string{ "keys": keys, }). Post("/settings/reset") if err = sc.checkError(rsp, err); err != nil { return fmt.Errorf("failed to reset settings %s: %w", keys, err) } return nil }