From c7c68322b1d10c0493463c7313b77e81d2b27d32 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Mon, 3 Mar 2025 17:59:01 +0100 Subject: [PATCH] Alerting: Allow specifying a folder for Prometheus rule import (#101406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What is this feature? Allows the creation of alert rules with mimirtool in a specified folder. Why do we need this feature? Currently, the APIs for mimirtool create namespaces and rule groups in the root folder without the ability to set a custom folder. For example, it could be a special "Imported" folder, etc. This PR makes it possible with a special header: mimirtool ... --extra-headers="X-Grafana-Alerting-Folder-UID=123". If it's not present, the root folder is used, otherwise, the specified one is used. mimirtool does not support nested folder structures, while Grafana allows folder nesting. To keep compatibility, we return only direct child folders of the working folder (as namespaces) with rule groups and rules that are directly in these child folders as if there are no nested folders. For example, given this folder structure in Grafana: ``` grafana/ ├── production/ │ ├── service1/ │ │ └── alerts/ │ └── service2/ └── testing/ └── service3/ ``` If the working folder is "grafana": Only namespaces "production" and "testing" are returned Only rule groups directly within these folders are included If the working folder is "production": - Only namespaces "service1" and "service2" are returned Only rule groups directly within these folders are included --- .../ngalert/api/api_convert_prometheus.go | 141 +++++- .../api/api_convert_prometheus_test.go | 116 ++++- pkg/services/ngalert/api/authorization.go | 12 +- pkg/services/ngalert/api/persist.go | 6 +- pkg/services/ngalert/store/namespace.go | 36 +- pkg/services/ngalert/store/namespace_test.go | 221 ++++++++- pkg/services/ngalert/tests/fakes/rules.go | 36 +- .../alerting/api_convert_prometheus_test.go | 452 ++++++++++++++++-- pkg/tests/api/alerting/testing.go | 86 +++- 9 files changed, 997 insertions(+), 109 deletions(-) diff --git a/pkg/services/ngalert/api/api_convert_prometheus.go b/pkg/services/ngalert/api/api_convert_prometheus.go index 5dca89080a5..9f38b47db17 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus.go +++ b/pkg/services/ngalert/api/api_convert_prometheus.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "path/filepath" "strconv" "strings" "time" @@ -27,7 +28,14 @@ import ( ) const ( - datasourceUIDHeader = "X-Grafana-Alerting-Datasource-UID" + // datasourceUIDHeader is the name of the header that specifies the UID of the datasource to be used for the rules. + datasourceUIDHeader = "X-Grafana-Alerting-Datasource-UID" + + // If the folderUIDHeader is present, namespaces and rule groups will be created in the specified folder. + // If not, the root folder will be used as the default. + folderUIDHeader = "X-Grafana-Alerting-Folder-UID" + + // These headers control the paused state of newly created rules. By default, rules are not paused. recordingRulesPausedHeader = "X-Grafana-Alerting-Recording-Rules-Paused" alertRulesPausedHeader = "X-Grafana-Alerting-Alert-Rules-Paused" ) @@ -58,6 +66,32 @@ func errInvalidHeaderValue(header string) error { // Rule groups not imported from Prometheus are excluded because their original rule definitions are unavailable. // When a rule group is converted from Prometheus to Grafana, the original definition is preserved alongside // the Grafana rule and used for reading requests here. +// +// Folder Structure Handling: +// mimirtool does not support nested folder structures, while Grafana allows folder nesting. +// To keep compatibility, this service only returns direct child folders of the working folder +// as namespaces, and rule groups and rules that are directly in these child folders. +// +// For example, given this folder structure in Grafana: +// +// grafana/ +// ├── production/ +// │ ├── service1/ +// │ │ └── alerts/ +// │ └── service2/ +// └── testing/ +// └── service3/ +// +// If the working folder is "grafana": +// - Only namespaces "production" and "testing" are returned +// - Only rule groups directly within these folders are included +// +// If the working folder is "production": +// - Only namespaces "service1" and "service2" are returned +// - Only rule groups directly within these folders are included +// +// The "working folder" is specified by the X-Grafana-Alerting-Folder-UID header, which can be set to any folder UID, +// and defaults to the root folder if not provided. type ConvertPrometheusSrv struct { cfg *setting.UnifiedAlertingSettings logger log.Logger @@ -82,8 +116,27 @@ func NewConvertPrometheusSrv(cfg *setting.UnifiedAlertingSettings, logger log.Lo func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetRules(c *contextmodel.ReqContext) response.Response { logger := srv.logger.FromContext(c.Req.Context()) + workingFolderUID := getWorkingFolderUID(c) + logger = logger.New("working_folder_uid", workingFolderUID) + + folders, err := srv.ruleStore.GetNamespaceChildren(c.Req.Context(), workingFolderUID, c.SignedInUser.GetOrgID(), c.SignedInUser) + if len(folders) == 0 || errors.Is(err, dashboards.ErrFolderNotFound) { + // If there is no such folder or no children, return empty response + // because mimirtool expects 200 OK response in this case. + return response.YAML(http.StatusOK, map[string][]apimodels.PrometheusRuleGroup{}) + } + if err != nil { + logger.Error("Failed to get folders", "error", err) + return errorToResponse(err) + } + folderUIDs := make([]string, 0, len(folders)) + for _, f := range folders { + folderUIDs = append(folderUIDs, f.UID) + } + filterOpts := &provisioning.FilterOptions{ ImportedPrometheusRule: util.Pointer(true), + NamespaceUIDs: folderUIDs, } groups, err := srv.alertRuleService.GetAlertGroupsWithFolderFullpath(c.Req.Context(), c.SignedInUser, filterOpts) if err != nil { @@ -105,8 +158,11 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetRules(c *contextmodel. func (srv *ConvertPrometheusSrv) RouteConvertPrometheusDeleteNamespace(c *contextmodel.ReqContext, namespaceTitle string) response.Response { logger := srv.logger.FromContext(c.Req.Context()) - logger.Debug("Looking up folder in the root by title", "folder_title", namespaceTitle) - namespace, err := srv.ruleStore.GetNamespaceInRootByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser) + workingFolderUID := getWorkingFolderUID(c) + logger = logger.New("working_folder_uid", workingFolderUID) + + logger.Debug("Looking up folder by title", "folder_title", namespaceTitle) + namespace, err := srv.ruleStore.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser, workingFolderUID) if err != nil { return namespaceErrorResponse(err) } @@ -117,6 +173,9 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusDeleteNamespace(c *contex ImportedPrometheusRule: util.Pointer(true), } err = srv.alertRuleService.DeleteRuleGroups(c.Req.Context(), c.SignedInUser, models.ProvenanceConvertedPrometheus, filterOpts) + if errors.Is(err, models.ErrAlertRuleGroupNotFound) { + return response.Empty(http.StatusNotFound) + } if err != nil { logger.Error("Failed to delete rule groups", "folder_uid", namespace.UID, "error", err) return errorToResponse(err) @@ -129,14 +188,20 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusDeleteNamespace(c *contex func (srv *ConvertPrometheusSrv) RouteConvertPrometheusDeleteRuleGroup(c *contextmodel.ReqContext, namespaceTitle string, group string) response.Response { logger := srv.logger.FromContext(c.Req.Context()) - logger.Debug("Looking up folder in the root by title", "folder_title", namespaceTitle) - folder, err := srv.ruleStore.GetNamespaceInRootByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser) + workingFolderUID := getWorkingFolderUID(c) + logger = logger.New("working_folder_uid", workingFolderUID) + + logger.Debug("Looking up folder by title", "folder_title", namespaceTitle) + folder, err := srv.ruleStore.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser, workingFolderUID) if err != nil { return namespaceErrorResponse(err) } logger.Info("Deleting Prometheus-imported rule group", "folder_uid", folder.UID, "folder_title", namespaceTitle, "group", group) err = srv.alertRuleService.DeleteRuleGroup(c.Req.Context(), c.SignedInUser, folder.UID, group, models.ProvenanceConvertedPrometheus) + if errors.Is(err, models.ErrAlertRuleGroupNotFound) { + return response.Empty(http.StatusNotFound) + } if err != nil { logger.Error("Failed to delete rule group", "folder_uid", folder.UID, "group", group, "error", err) return errorToResponse(err) @@ -150,8 +215,11 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusDeleteRuleGroup(c *contex func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetNamespace(c *contextmodel.ReqContext, namespaceTitle string) response.Response { logger := srv.logger.FromContext(c.Req.Context()) - logger.Debug("Looking up folder in the root by title", "folder_title", namespaceTitle) - namespace, err := srv.ruleStore.GetNamespaceInRootByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser) + workingFolderUID := getWorkingFolderUID(c) + logger = logger.New("working_folder_uid", workingFolderUID) + + logger.Debug("Looking up folder by title", "folder_title", namespaceTitle) + namespace, err := srv.ruleStore.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser, workingFolderUID) if err != nil { logger.Error("Failed to get folder", "error", err) return namespaceErrorResponse(err) @@ -181,12 +249,18 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetNamespace(c *contextmo func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetRuleGroup(c *contextmodel.ReqContext, namespaceTitle string, group string) response.Response { logger := srv.logger.FromContext(c.Req.Context()) - logger.Debug("Looking up folder in the root by title", "folder_title", namespaceTitle) - namespace, err := srv.ruleStore.GetNamespaceInRootByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser) + workingFolderUID := getWorkingFolderUID(c) + logger = logger.New("working_folder_uid", workingFolderUID) + + logger.Debug("Looking up folder by title", "folder_title", namespaceTitle) + namespace, err := srv.ruleStore.GetNamespaceByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser, workingFolderUID) if err != nil { logger.Error("Failed to get folder", "error", err) return namespaceErrorResponse(err) } + if namespace == nil { + return response.Error(http.StatusNotFound, "Folder not found", nil) + } filterOpts := &provisioning.FilterOptions{ ImportedPrometheusRule: util.Pointer(true), @@ -223,11 +297,13 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetRuleGroup(c *contextmo // it will not be replaced and an error will be returned. func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroup(c *contextmodel.ReqContext, namespaceTitle string, promGroup apimodels.PrometheusRuleGroup) response.Response { logger := srv.logger.FromContext(c.Req.Context()) - logger = logger.New("folder_title", namespaceTitle, "group", promGroup.Name) + + workingFolderUID := getWorkingFolderUID(c) + logger = logger.New("folder_title", namespaceTitle, "group", promGroup.Name, "working_folder_uid", workingFolderUID) logger.Info("Converting Prometheus rule group", "rules", len(promGroup.Rules)) - ns, errResp := srv.getOrCreateNamespace(c, namespaceTitle, logger) + ns, errResp := srv.getOrCreateNamespace(c, namespaceTitle, logger, workingFolderUID) if errResp != nil { return errResp } @@ -257,18 +333,19 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroup(c *contextm return successfulResponse() } -func (srv *ConvertPrometheusSrv) getOrCreateNamespace(c *contextmodel.ReqContext, title string, logger log.Logger) (*folder.Folder, response.Response) { +func (srv *ConvertPrometheusSrv) getOrCreateNamespace(c *contextmodel.ReqContext, title string, logger log.Logger, workingFolderUID string) (*folder.Folder, response.Response) { logger.Debug("Getting or creating a new folder") - ns, err := srv.ruleStore.GetOrCreateNamespaceInRootByTitle( + ns, err := srv.ruleStore.GetOrCreateNamespaceByTitle( c.Req.Context(), title, c.SignedInUser.GetOrgID(), c.SignedInUser, + workingFolderUID, ) if err != nil { logger.Error("Failed to get or create a new folder", "error", err) - return nil, toNamespaceErrorResponse(err) + return nil, namespaceErrorResponse(err) } logger.Debug("Using folder for the converted rules", "folder_uid", ns.UID) @@ -351,11 +428,17 @@ func grafanaNamespacesToPrometheus(groups []models.AlertRuleGroupWithFolderFullp result := map[string][]apimodels.PrometheusRuleGroup{} for _, group := range groups { + // Since the folder can be nested but mimirtool does not support nested paths, + // we need to use only the last folder in the full path. + // For example, if the current working folder is "general" and the full path is "grafana/some folder/general/production", + // we should use the "production" folder. + folder := filepath.Base(group.FolderFullpath) + promGroup, err := grafanaRuleGroupToPrometheus(group.Title, group.Rules) if err != nil { return nil, err } - result[group.FolderFullpath] = append(result[group.FolderFullpath], promGroup) + result[folder] = append(result[folder], promGroup) } return result, nil @@ -388,18 +471,26 @@ func grafanaRuleGroupToPrometheus(group string, rules []models.AlertRule) (apimo return promGroup, nil } -func namespaceErrorResponse(err error) response.Response { - if errors.Is(err, dashboards.ErrFolderAccessDenied) { - // If there is no such folder, the error is ErrFolderAccessDenied. - // We should return 404 in this case, otherwise mimirtool does not work correctly. - return response.Empty(http.StatusNotFound) - } - - return toNamespaceErrorResponse(err) -} - func successfulResponse() response.Response { return response.JSON(http.StatusAccepted, apimodels.ConvertPrometheusResponse{ Status: "success", }) } + +// getWorkingFolderUID returns the value of the folderUIDHeader +// if present. Otherwise, it returns the UID of the root folder. +func getWorkingFolderUID(c *contextmodel.ReqContext) string { + folderUID := strings.TrimSpace(c.Req.Header.Get(folderUIDHeader)) + if folderUID != "" { + return folderUID + } + return folder.RootFolderUID +} + +func namespaceErrorResponse(err error) response.Response { + if errors.Is(err, dashboards.ErrFolderNotFound) { + return response.Empty(http.StatusNotFound) + } + + return toNamespaceErrorResponse(err) +} diff --git a/pkg/services/ngalert/api/api_convert_prometheus_test.go b/pkg/services/ngalert/api/api_convert_prometheus_test.go index 51b378d437c..945a0558365 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus_test.go +++ b/pkg/services/ngalert/api/api_convert_prometheus_test.go @@ -397,8 +397,8 @@ func TestRouteConvertPrometheusGetNamespace(t *testing.T) { require.NoError(t, err) require.Len(t, respNamespaces, 1) - require.Contains(t, respNamespaces, fldr.Fullpath) - require.ElementsMatch(t, respNamespaces[fldr.Fullpath], []apimodels.PrometheusRuleGroup{promGroup1, promGroup2}) + require.Contains(t, respNamespaces, fldr.Title) + require.ElementsMatch(t, respNamespaces[fldr.Title], []apimodels.PrometheusRuleGroup{promGroup1, promGroup2}) }) } @@ -442,17 +442,53 @@ func TestRouteConvertPrometheusGetRules(t *testing.T) { }, } - t.Run("with no rules should return empty response", func(t *testing.T) { - srv, _, _, _ := createConvertPrometheusSrv(t) - rc := createRequestCtx() + assertEmptyResponse := func(t *testing.T, srv *ConvertPrometheusSrv, reqCtx *contextmodel.ReqContext) { + t.Helper() - response := srv.RouteConvertPrometheusGetRules(rc) + response := srv.RouteConvertPrometheusGetRules(reqCtx) require.Equal(t, http.StatusOK, response.Status()) var respNamespaces map[string][]apimodels.PrometheusRuleGroup err := yaml.Unmarshal(response.Body(), &respNamespaces) require.NoError(t, err) require.Empty(t, respNamespaces) + } + + // testForEmptyResponses tests that RouteConvertPrometheusGetRules returns an empty response + // when there are no rules in the folder or the folder does not exist. + testForEmptyResponses := func(t *testing.T, withCustomFolderHeader bool) { + rc := createRequestCtx() + unknownFolderUID := "some unknown folder" + rootFolderUID := "" + if withCustomFolderHeader { + rootFolderUID = unknownFolderUID + rc.Context.Req.Header.Set(folderUIDHeader, unknownFolderUID) + } + + t.Run("for non-existent folder should return empty response", func(t *testing.T) { + srv, _, _, _ := createConvertPrometheusSrv(t) + assertEmptyResponse(t, srv, rc) + }) + + t.Run("for existing folder with no children should return empty response", func(t *testing.T) { + srv, _, ruleStore, folderService := createConvertPrometheusSrv(t) + + fldr := randFolder() + fldr.UID = unknownFolderUID + fldr.ParentUID = rootFolderUID + folderService.ExpectedFolders = []*folder.Folder{fldr} + ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr) + + assertEmptyResponse(t, srv, rc) + }) + } + + t.Run("without custom root folder", func(t *testing.T) { + testForEmptyResponses(t, false) + }) + + t.Run("with custom root folder", func(t *testing.T) { + testForEmptyResponses(t, true) }) t.Run("with rules should return 200 with rules", func(t *testing.T) { @@ -489,8 +525,8 @@ func TestRouteConvertPrometheusGetRules(t *testing.T) { require.NoError(t, err) require.Len(t, respNamespaces, 1) - require.Contains(t, respNamespaces, fldr.Fullpath) - require.ElementsMatch(t, respNamespaces[fldr.Fullpath], []apimodels.PrometheusRuleGroup{promGroup1, promGroup2}) + require.Contains(t, respNamespaces, fldr.Title) + require.ElementsMatch(t, respNamespaces[fldr.Title], []apimodels.PrometheusRuleGroup{promGroup1, promGroup2}) }) } @@ -503,6 +539,20 @@ func TestRouteConvertPrometheusDeleteNamespace(t *testing.T) { require.Equal(t, http.StatusNotFound, response.Status()) }) + t.Run("for existing folder with no groups should return 404", func(t *testing.T) { + srv, _, ruleStore, folderService := createConvertPrometheusSrv(t) + rc := createRequestCtx() + + fldr := randFolder() + fldr.ParentUID = "" + folderService.ExpectedFolder = fldr + folderService.ExpectedFolders = []*folder.Folder{fldr} + ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr) + + response := srv.RouteConvertPrometheusDeleteNamespace(rc, "non-existent") + require.Equal(t, http.StatusNotFound, response.Status()) + }) + t.Run("valid request should delete rules", func(t *testing.T) { initNamespace := func(promDefinition string, opts ...convertPrometheusSrvOptionsFunc) (*ConvertPrometheusSrv, *fakes.RuleStore, *folder.Folder, *models.AlertRule) { srv, _, ruleStore, folderService := createConvertPrometheusSrv(t, opts...) @@ -596,6 +646,20 @@ func TestRouteConvertPrometheusDeleteRuleGroup(t *testing.T) { require.Equal(t, http.StatusNotFound, response.Status()) }) + t.Run("for existing folder with no group should return 404", func(t *testing.T) { + srv, _, ruleStore, folderService := createConvertPrometheusSrv(t) + rc := createRequestCtx() + + fldr := randFolder() + fldr.ParentUID = "" + folderService.ExpectedFolder = fldr + folderService.ExpectedFolders = []*folder.Folder{fldr} + ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr) + + response := srv.RouteConvertPrometheusDeleteRuleGroup(rc, fldr.Title, "test-group") + require.Equal(t, http.StatusNotFound, response.Status()) + }) + const groupName = "test-group" t.Run("valid request should delete rules", func(t *testing.T) { @@ -758,3 +822,39 @@ func createRequestCtx() *contextmodel.ReqContext { SignedInUser: &user.SignedInUser{OrgID: 1}, } } + +func TestGetWorkingFolderUID(t *testing.T) { + t.Run("should return root folder UID when header is not present", func(t *testing.T) { + rc := createRequestCtx() + rc.Req.Header.Del(folderUIDHeader) + + folderUID := getWorkingFolderUID(rc) + require.Equal(t, folder.RootFolderUID, folderUID) + }) + + t.Run("should return specified folder UID when header is present", func(t *testing.T) { + rc := createRequestCtx() + specifiedFolderUID := "specified-folder-uid" + rc.Req.Header.Set(folderUIDHeader, specifiedFolderUID) + + folderUID := getWorkingFolderUID(rc) + require.Equal(t, specifiedFolderUID, folderUID) + }) + + t.Run("should return root folder UID when header is empty", func(t *testing.T) { + rc := createRequestCtx() + rc.Req.Header.Set(folderUIDHeader, "") + + folderUID := getWorkingFolderUID(rc) + require.Equal(t, folder.RootFolderUID, folderUID) + }) + + t.Run("should trim whitespace from header value", func(t *testing.T) { + rc := createRequestCtx() + specifiedFolderUID := "specified-folder-uid" + rc.Req.Header.Set(folderUIDHeader, " "+specifiedFolderUID+" ") + + folderUID := getWorkingFolderUID(rc) + require.Equal(t, specifiedFolderUID, folderUID) + }) +} diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index 4dce6e33015..42d85b03a32 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -151,13 +151,11 @@ func (api *API) authorize(method, path string) web.Handler { http.MethodDelete + "/api/convert/api/prom/rules/{NamespaceTitle}/{Group}", http.MethodDelete + "/api/convert/prometheus/config/v1/rules/{NamespaceTitle}", http.MethodDelete + "/api/convert/api/prom/rules/{NamespaceTitle}": - eval = ac.EvalAny( - ac.EvalAll( - ac.EvalPermission(ac.ActionAlertingRuleRead), - ac.EvalPermission(dashboards.ActionFoldersRead), - ac.EvalPermission(ac.ActionAlertingRuleDelete), - ac.EvalPermission(ac.ActionAlertingProvisioningSetStatus), - ), + eval = ac.EvalAll( + ac.EvalPermission(ac.ActionAlertingRuleRead), + ac.EvalPermission(dashboards.ActionFoldersRead), + ac.EvalPermission(ac.ActionAlertingRuleDelete), + ac.EvalPermission(ac.ActionAlertingProvisioningSetStatus), ) // Alert Instances and Silences diff --git a/pkg/services/ngalert/api/persist.go b/pkg/services/ngalert/api/persist.go index 228f62cf24e..d2e81d833e2 100644 --- a/pkg/services/ngalert/api/persist.go +++ b/pkg/services/ngalert/api/persist.go @@ -15,8 +15,10 @@ type RuleStore interface { // by returning map[string]struct{} instead of map[string]*folder.Folder GetUserVisibleNamespaces(context.Context, int64, identity.Requester) (map[string]*folder.Folder, error) GetNamespaceByUID(ctx context.Context, uid string, orgID int64, user identity.Requester) (*folder.Folder, error) - GetNamespaceInRootByTitle(ctx context.Context, fullpath string, orgID int64, user identity.Requester) (*folder.Folder, error) - GetOrCreateNamespaceInRootByTitle(ctx context.Context, title string, orgID int64, user identity.Requester) (*folder.Folder, error) + GetNamespaceByTitle(ctx context.Context, fullpath string, orgID int64, user identity.Requester, parentUID string) (*folder.Folder, error) + GetOrCreateNamespaceByTitle(ctx context.Context, title string, orgID int64, user identity.Requester, parentUID string) (*folder.Folder, error) + // GetNamespaceChildren returns all children (first level) of the namespace with the given id. + GetNamespaceChildren(ctx context.Context, uid string, orgID int64, user identity.Requester) ([]*folder.Folder, error) GetAlertRuleByUID(ctx context.Context, query *ngmodels.GetAlertRuleByUIDQuery) (*ngmodels.AlertRule, error) GetAlertRulesGroupByRuleUID(ctx context.Context, query *ngmodels.GetAlertRulesGroupByRuleUIDQuery) ([]*ngmodels.AlertRule, error) diff --git a/pkg/services/ngalert/store/namespace.go b/pkg/services/ngalert/store/namespace.go index 9279ff840f9..2e00ed40371 100644 --- a/pkg/services/ngalert/store/namespace.go +++ b/pkg/services/ngalert/store/namespace.go @@ -40,10 +40,10 @@ func (st DBstore) GetNamespaceByUID(ctx context.Context, uid string, orgID int64 return f[0], nil } -// GetNamespaceInRootByTitle gets namespace by its title in the root folder. -func (st DBstore) GetNamespaceInRootByTitle(ctx context.Context, title string, orgID int64, user identity.Requester) (*folder.Folder, error) { +// GetNamespaceChildren gets namespace (folder) children (first level) by its UID. +func (st DBstore) GetNamespaceChildren(ctx context.Context, uid string, orgID int64, user identity.Requester) ([]*folder.Folder, error) { q := &folder.GetChildrenQuery{ - UID: folder.RootFolderUID, + UID: uid, OrgID: orgID, SignedInUser: user, } @@ -52,15 +52,32 @@ func (st DBstore) GetNamespaceInRootByTitle(ctx context.Context, title string, o return nil, err } + found := make([]*folder.Folder, 0, len(folders)) + for _, f := range folders { + if f.ParentUID == uid { + found = append(found, f) + } + } + + return found, nil +} + +// GetNamespaceByTitle gets namespace by its title in the specified folder. +func (st DBstore) GetNamespaceByTitle(ctx context.Context, title string, orgID int64, user identity.Requester, parentUID string) (*folder.Folder, error) { + folders, err := st.GetNamespaceChildren(ctx, parentUID, orgID, user) + if err != nil { + return nil, err + } + foundByTitle := []*folder.Folder{} for _, f := range folders { - if f.Title == title && f.ParentUID == folder.RootFolderUID { + if f.Title == title { foundByTitle = append(foundByTitle, f) } } if len(foundByTitle) == 0 { - return nil, dashboards.ErrFolderAccessDenied + return nil, dashboards.ErrFolderNotFound } // Sort by UID to return the first folder in case of multiple folders with the same title @@ -71,13 +88,13 @@ func (st DBstore) GetNamespaceInRootByTitle(ctx context.Context, title string, o return foundByTitle[0], nil } -// GetOrCreateNamespaceInRootByTitle gets or creates a namespace by title in the _root_ folder. -func (st DBstore) GetOrCreateNamespaceInRootByTitle(ctx context.Context, title string, orgID int64, user identity.Requester) (*folder.Folder, error) { +// GetOrCreateNamespaceByTitle gets or creates a namespace by title in the specified folder. +func (st DBstore) GetOrCreateNamespaceByTitle(ctx context.Context, title string, orgID int64, user identity.Requester, parentUID string) (*folder.Folder, error) { var f *folder.Folder var err error - f, err = st.GetNamespaceInRootByTitle(ctx, title, orgID, user) - if err != nil && !errors.Is(err, dashboards.ErrFolderAccessDenied) { + f, err = st.GetNamespaceByTitle(ctx, title, orgID, user, parentUID) + if err != nil && !errors.Is(err, dashboards.ErrFolderNotFound) { return nil, err } @@ -86,6 +103,7 @@ func (st DBstore) GetOrCreateNamespaceInRootByTitle(ctx context.Context, title s OrgID: orgID, Title: title, SignedInUser: user, + ParentUID: parentUID, } f, err = st.FolderService.Create(ctx, cmd) if err != nil { diff --git a/pkg/services/ngalert/store/namespace_test.go b/pkg/services/ngalert/store/namespace_test.go index 8ba163a1080..7693122cee6 100644 --- a/pkg/services/ngalert/store/namespace_test.go +++ b/pkg/services/ngalert/store/namespace_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/setting" @@ -123,7 +124,7 @@ func TestIntegration_GetNamespaceByUID(t *testing.T) { }) } -func TestIntegration_GetNamespaceInRootByTitle(t *testing.T) { +func TestIntegration_GetNamespaceByTitle(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } @@ -143,17 +144,45 @@ func TestIntegration_GetNamespaceInRootByTitle(t *testing.T) { IsGrafanaAdmin: true, } - uid := uuid.NewString() - title := "folder-title" - createFolder(t, store, uid, title, 1, "") + // Create parent folder + parentUID := uuid.NewString() + parentTitle := "parent-folder" + createFolder(t, store, parentUID, parentTitle, 1, "") - actual, err := store.GetNamespaceInRootByTitle(context.Background(), title, 1, u) - require.NoError(t, err) - require.Equal(t, title, actual.Title) - require.Equal(t, uid, actual.UID) + // Create child folder under parent + childUID := uuid.NewString() + childTitle := "child-folder" + createFolder(t, store, childUID, childTitle, 1, parentUID) + + // Create another folder with same title but under root + sameTitleInRoot := uuid.NewString() + createFolder(t, store, sameTitleInRoot, childTitle, 1, "") + + t.Run("should find folder by title and parent UID", func(t *testing.T) { + actual, err := store.GetNamespaceByTitle(context.Background(), childTitle, 1, u, parentUID) + require.NoError(t, err) + require.Equal(t, childTitle, actual.Title) + require.Equal(t, childUID, actual.UID) + require.Equal(t, parentUID, actual.ParentUID) + }) + + t.Run("should find folder by title in root", func(t *testing.T) { + actual, err := store.GetNamespaceByTitle(context.Background(), childTitle, 1, u, folder.RootFolderUID) + require.NoError(t, err) + require.Equal(t, childTitle, actual.Title) + require.Equal(t, sameTitleInRoot, actual.UID) + require.Equal(t, folder.RootFolderUID, actual.ParentUID) + }) + + t.Run("should return ErrFolderNotFound when folder with title doesn't exist under specified parent", func(t *testing.T) { + nonExistentTitle := "non-existent-folder" + f, err := store.GetNamespaceByTitle(context.Background(), nonExistentTitle, 1, u, parentUID) + require.Nil(t, f) + require.ErrorIs(t, err, dashboards.ErrFolderNotFound) + }) } -func TestIntegration_GetOrCreateNamespaceInRootByTitle(t *testing.T) { +func TestIntegration_GetOrCreateNamespaceByTitle(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } @@ -180,10 +209,11 @@ func TestIntegration_GetOrCreateNamespaceInRootByTitle(t *testing.T) { t.Run("should create folder when it does not exist", func(t *testing.T) { store := setupStore(t) - f, err := store.GetOrCreateNamespaceInRootByTitle(context.Background(), "new folder", 1, u) + f, err := store.GetOrCreateNamespaceByTitle(context.Background(), "new folder", 1, u, folder.RootFolderUID) require.NoError(t, err) require.Equal(t, "new folder", f.Title) require.NotEmpty(t, f.UID) + require.Equal(t, folder.RootFolderUID, f.ParentUID) folders, err := store.FolderService.GetFolders( context.Background(), @@ -202,7 +232,7 @@ func TestIntegration_GetOrCreateNamespaceInRootByTitle(t *testing.T) { title := "existing folder" createFolder(t, store, "", title, 1, "") - f, err := store.GetOrCreateNamespaceInRootByTitle(context.Background(), title, 1, u) + f, err := store.GetOrCreateNamespaceByTitle(context.Background(), title, 1, u, folder.RootFolderUID) require.NoError(t, err) require.Equal(t, title, f.Title) @@ -217,4 +247,173 @@ func TestIntegration_GetOrCreateNamespaceInRootByTitle(t *testing.T) { require.NoError(t, err) require.Len(t, folders, 1) }) + + t.Run("should create folder under specified parent when it does not exist", func(t *testing.T) { + store := setupStore(t) + + // Create parent folder first + parentTitle := "parent folder" + parentFolder, err := store.GetOrCreateNamespaceByTitle(context.Background(), parentTitle, 1, u, folder.RootFolderUID) + require.NoError(t, err) + + // Now create a child folder under the parent + childTitle := "child folder" + childFolder, err := store.GetOrCreateNamespaceByTitle(context.Background(), childTitle, 1, u, parentFolder.UID) + require.NoError(t, err) + + // Verify the child folder was created under the parent + folders, err := store.FolderService.GetChildren(context.Background(), &folder.GetChildrenQuery{UID: parentFolder.UID, OrgID: 1, SignedInUser: u}) + require.NoError(t, err) + require.Len(t, folders, 1) + require.Equal(t, childFolder.UID, folders[0].UID) + + folders, err = store.FolderService.GetChildren(context.Background(), &folder.GetChildrenQuery{UID: folder.RootFolderUID, OrgID: 1, SignedInUser: u}) + require.NoError(t, err) + require.Len(t, folders, 1) + require.Equal(t, parentFolder.UID, folders[0].UID) + }) + + t.Run("should get correct folder when same title exists under different parents", func(t *testing.T) { + store := setupStore(t) + + // Create first parent folder + parent1Title := "parent folder 1" + parent1, err := store.GetOrCreateNamespaceByTitle(context.Background(), parent1Title, 1, u, folder.RootFolderUID) + require.NoError(t, err) + + // Create second parent folder + parent2Title := "parent folder 2" + parent2, err := store.GetOrCreateNamespaceByTitle(context.Background(), parent2Title, 1, u, folder.RootFolderUID) + require.NoError(t, err) + + // Create folders with same title under different parents + sameTitle := "same title folder" + + // Create under first parent + folder1, err := store.GetOrCreateNamespaceByTitle(context.Background(), sameTitle, 1, u, parent1.UID) + require.NoError(t, err) + + // Create under second parent + folder2, err := store.GetOrCreateNamespaceByTitle(context.Background(), sameTitle, 1, u, parent2.UID) + require.NoError(t, err) + + // Create under root + folder3, err := store.GetOrCreateNamespaceByTitle(context.Background(), sameTitle, 1, u, folder.RootFolderUID) + require.NoError(t, err) + + // Verify we get the correct folders when specifying the parent + gotFolder1, err := store.GetOrCreateNamespaceByTitle(context.Background(), sameTitle, 1, u, parent1.UID) + require.NoError(t, err) + require.Equal(t, folder1.UID, gotFolder1.UID) + require.Equal(t, parent1.UID, gotFolder1.ParentUID) + + gotFolder2, err := store.GetOrCreateNamespaceByTitle(context.Background(), sameTitle, 1, u, parent2.UID) + require.NoError(t, err) + require.Equal(t, folder2.UID, gotFolder2.UID) + require.Equal(t, parent2.UID, gotFolder2.ParentUID) + + gotFolder3, err := store.GetOrCreateNamespaceByTitle(context.Background(), sameTitle, 1, u, folder.RootFolderUID) + require.NoError(t, err) + require.Equal(t, folder3.UID, gotFolder3.UID) + require.Equal(t, folder.RootFolderUID, gotFolder3.ParentUID) + }) +} + +func TestIntegration_GetNamespaceChildren(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + sqlStore := db.InitTestDB(t) + cfg := setting.NewCfg() + folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) + b := &fakeBus{} + logger := log.New("test-dbstore") + store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b) + store.FolderService = setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) + + admin := &user.SignedInUser{ + UserID: 1, + OrgID: 1, + OrgRole: org.RoleAdmin, + IsGrafanaAdmin: true, + } + + // Create root folders + rootFolder1 := uuid.NewString() + rootFolder2 := uuid.NewString() + createFolder(t, store, rootFolder1, "Root Folder 1", 1, "") + createFolder(t, store, rootFolder2, "Root Folder 2", 1, "") + + // Create child folders under root folder 1 + child1 := uuid.NewString() + child2 := uuid.NewString() + createFolder(t, store, child1, "Child Folder 1", 1, rootFolder1) + createFolder(t, store, child2, "Child Folder 2", 1, rootFolder1) + + // Create nested child under child1 + nestedChild := uuid.NewString() + createFolder(t, store, nestedChild, "Nested Child", 1, child1) + + differentOrgID := int64(999) + createFolder(t, store, util.GenerateShortUID(), "Root Folder 1", differentOrgID, "") + + /* + * Folder structure: + * + * Root Folder 1 + * - Child Folder 1 + * - Nested Child + * - Child Folder 2 + * Root Folder 2 + */ + + t.Run("should return direct children of a folder", func(t *testing.T) { + children, err := store.GetNamespaceChildren(context.Background(), rootFolder1, 1, admin) + require.NoError(t, err) + require.Len(t, children, 2) + + require.ElementsMatch(t, []string{child1, child2}, []string{children[0].UID, children[1].UID}) + + // Verify parent UID + for _, child := range children { + require.Equal(t, rootFolder1, child.ParentUID) + } + }) + + t.Run("should return direct children of a nested folder", func(t *testing.T) { + children, err := store.GetNamespaceChildren(context.Background(), child1, 1, admin) + require.NoError(t, err) + require.Len(t, children, 1) + require.Equal(t, nestedChild, children[0].UID) + require.Equal(t, child1, children[0].ParentUID) + }) + + t.Run("should return nil when folder does not exist", func(t *testing.T) { + nonExistentUID := uuid.NewString() + children, err := store.GetNamespaceChildren(context.Background(), nonExistentUID, 1, admin) + require.NotNil(t, children) + require.Empty(t, children) + require.Nil(t, err) + }) + + t.Run("should return empty array for folders with no children", func(t *testing.T) { + children, err := store.GetNamespaceChildren(context.Background(), rootFolder2, 1, admin) + require.Empty(t, children) + require.NotNil(t, children) + require.Nil(t, err) + }) + + t.Run("should return no children for a different org", func(t *testing.T) { + children, err := store.GetNamespaceChildren(context.Background(), rootFolder1, differentOrgID, admin) + require.Empty(t, children) + require.Nil(t, err) + }) + + t.Run("should return children from root folder", func(t *testing.T) { + children, err := store.GetNamespaceChildren(context.Background(), "", 1, admin) + require.NoError(t, err) + require.Equal(t, len(children), 2) + require.ElementsMatch(t, []string{rootFolder1, rootFolder2}, []string{children[0].UID, children[1].UID}) + }) } diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index 1a3e4ce9a63..cb90d3bfb77 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -268,33 +268,34 @@ func (f *RuleStore) GetNamespaceByUID(_ context.Context, uid string, orgID int64 return nil, fmt.Errorf("not found") } -func (f *RuleStore) GetOrCreateNamespaceInRootByTitle(ctx context.Context, title string, orgID int64, user identity.Requester) (*folder.Folder, error) { +func (f *RuleStore) GetOrCreateNamespaceByTitle(ctx context.Context, title string, orgID int64, user identity.Requester, parentUID string) (*folder.Folder, error) { f.mtx.Lock() defer f.mtx.Unlock() for _, folder := range f.Folders[orgID] { - if folder.Title == title { + if folder.Title == title && folder.ParentUID == parentUID { return folder, nil } } newFolder := &folder.Folder{ - ID: rand.Int63(), // nolint:staticcheck - UID: util.GenerateShortUID(), - Title: title, - Fullpath: "fullpath_" + title, + ID: rand.Int63(), // nolint:staticcheck + UID: util.GenerateShortUID(), + Title: title, + ParentUID: parentUID, + Fullpath: "fullpath_" + title, } f.Folders[orgID] = append(f.Folders[orgID], newFolder) return newFolder, nil } -func (f *RuleStore) GetNamespaceInRootByTitle(ctx context.Context, title string, orgID int64, user identity.Requester) (*folder.Folder, error) { +func (f *RuleStore) GetNamespaceByTitle(ctx context.Context, title string, orgID int64, user identity.Requester, parentUID string) (*folder.Folder, error) { f.mtx.Lock() defer f.mtx.Unlock() for _, folder := range f.Folders[orgID] { - if folder.Title == title && folder.ParentUID == "" { + if folder.Title == title && folder.ParentUID == parentUID { return folder, nil } } @@ -302,6 +303,25 @@ func (f *RuleStore) GetNamespaceInRootByTitle(ctx context.Context, title string, return nil, dashboards.ErrFolderNotFound } +func (f *RuleStore) GetNamespaceChildren(ctx context.Context, uid string, orgID int64, user identity.Requester) ([]*folder.Folder, error) { + f.mtx.Lock() + defer f.mtx.Unlock() + + result := []*folder.Folder{} + + for _, folder := range f.Folders[orgID] { + if folder.ParentUID == uid { + result = append(result, folder) + } + } + + if len(result) == 0 { + return nil, dashboards.ErrFolderNotFound + } + + return result, nil +} + func (f *RuleStore) UpdateAlertRules(_ context.Context, _ *models.UserUID, q []models.UpdateRule) error { f.mtx.Lock() defer f.mtx.Unlock() diff --git a/pkg/tests/api/alerting/api_convert_prometheus_test.go b/pkg/tests/api/alerting/api_convert_prometheus_test.go index 780e11a1d15..48f09332ebb 100644 --- a/pkg/tests/api/alerting/api_convert_prometheus_test.go +++ b/pkg/tests/api/alerting/api_convert_prometheus_test.go @@ -135,14 +135,11 @@ func TestIntegrationConvertPrometheusEndpoints(t *testing.T) { ds := apiClient.CreateDatasource(t, datasources.DS_PROMETHEUS) t.Run("create rule groups and get them back", func(t *testing.T) { - _, status, body := apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup1, nil) - requireStatusCode(t, http.StatusAccepted, status, body) - _, status, body = apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup2, nil) - requireStatusCode(t, http.StatusAccepted, status, body) + apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup1, nil) + apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup2, nil) // create a third group in a different namespace - _, status, body = apiClient.ConvertPrometheusPostRuleGroup(t, namespace2, ds.Body.Datasource.UID, promGroup3, nil) - requireStatusCode(t, http.StatusAccepted, status, body) + apiClient.ConvertPrometheusPostRuleGroup(t, namespace2, ds.Body.Datasource.UID, promGroup3, nil) // And a non-provisioned rule in another namespace namespace3UID := util.GenerateShortUID() @@ -150,18 +147,18 @@ func TestIntegrationConvertPrometheusEndpoints(t *testing.T) { createRule(t, apiClient, namespace3UID) // Now get the first group - group1 := apiClient.ConvertPrometheusGetRuleGroupRules(t, namespace1, promGroup1.Name) + group1 := apiClient.ConvertPrometheusGetRuleGroupRules(t, namespace1, promGroup1.Name, nil) require.Equal(t, promGroup1, group1) // Get namespace1 - ns1 := apiClient.ConvertPrometheusGetNamespaceRules(t, namespace1) + ns1 := apiClient.ConvertPrometheusGetNamespaceRules(t, namespace1, nil) expectedNs1 := map[string][]apimodels.PrometheusRuleGroup{ namespace1: {promGroup1, promGroup2}, } require.Equal(t, expectedNs1, ns1) // Get all namespaces - namespaces := apiClient.ConvertPrometheusGetAllRules(t) + namespaces := apiClient.ConvertPrometheusGetAllRules(t, nil) expectedNamespaces := map[string][]apimodels.PrometheusRuleGroup{ namespace1: {promGroup1, promGroup2}, namespace2: {promGroup3}, @@ -170,22 +167,21 @@ func TestIntegrationConvertPrometheusEndpoints(t *testing.T) { }) t.Run("without permissions to create folders cannot create rule groups either", func(t *testing.T) { - _, status, raw := viewerClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup1, nil) + _, status, raw := viewerClient.RawConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup1, nil) requireStatusCode(t, http.StatusForbidden, status, raw) }) t.Run("delete one rule group", func(t *testing.T) { - _, status, body := apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup1, nil) - requireStatusCode(t, http.StatusAccepted, status, body) - _, status, body = apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup2, nil) - requireStatusCode(t, http.StatusAccepted, status, body) - _, status, body = apiClient.ConvertPrometheusPostRuleGroup(t, namespace2, ds.Body.Datasource.UID, promGroup3, nil) - requireStatusCode(t, http.StatusAccepted, status, body) + // Create three groups + apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup1, nil) + apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup2, nil) + apiClient.ConvertPrometheusPostRuleGroup(t, namespace2, ds.Body.Datasource.UID, promGroup3, nil) - apiClient.ConvertPrometheusDeleteRuleGroup(t, namespace1, promGroup1.Name) + // delete the first one + apiClient.ConvertPrometheusDeleteRuleGroup(t, namespace1, promGroup1.Name, nil) // Check that the promGroup2 and promGroup3 are still there - namespaces := apiClient.ConvertPrometheusGetAllRules(t) + namespaces := apiClient.ConvertPrometheusGetAllRules(t, nil) expectedNamespaces := map[string][]apimodels.PrometheusRuleGroup{ namespace1: {promGroup2}, namespace2: {promGroup3}, @@ -193,10 +189,10 @@ func TestIntegrationConvertPrometheusEndpoints(t *testing.T) { require.Equal(t, expectedNamespaces, namespaces) // Delete the second namespace - apiClient.ConvertPrometheusDeleteNamespace(t, namespace2) + apiClient.ConvertPrometheusDeleteNamespace(t, namespace2, nil) // Check that only the first namespace is left - namespaces = apiClient.ConvertPrometheusGetAllRules(t) + namespaces = apiClient.ConvertPrometheusGetAllRules(t, nil) expectedNamespaces = map[string][]apimodels.PrometheusRuleGroup{ namespace1: {promGroup2}, } @@ -268,11 +264,10 @@ func TestIntegrationConvertPrometheusEndpoints_UpdateRule(t *testing.T) { t.Run("update a rule", func(t *testing.T) { // Create the rule group - _, status, body := apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup, nil) - requireStatusCode(t, http.StatusAccepted, status, body) + apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup, nil) // Now get the group - group1 := apiClient.ConvertPrometheusGetRuleGroupRules(t, namespace1, promGroup.Name) + group1 := apiClient.ConvertPrometheusGetRuleGroupRules(t, namespace1, promGroup.Name, nil) require.Equal(t, promGroup, group1) // Update the rule group interval @@ -283,11 +278,10 @@ func TestIntegrationConvertPrometheusEndpoints_UpdateRule(t *testing.T) { promGroup.Rules[0].Labels["another-label"] = "something" promGroup.Rules[0].Annotations["another-annotation"] = "also-something" // Update the group - _, status, body = apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup, nil) - requireStatusCode(t, http.StatusAccepted, status, body) + apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup, nil) // Now get the group again and check that the rule group has been updated - group1 = apiClient.ConvertPrometheusGetRuleGroupRules(t, namespace1, promGroup.Name) + group1 = apiClient.ConvertPrometheusGetRuleGroupRules(t, namespace1, promGroup.Name, nil) require.Equal(t, promGroup, group1) }) } @@ -374,7 +368,7 @@ func TestIntegrationConvertPrometheusEndpoints_Conflict(t *testing.T) { require.Equalf(t, http.StatusOK, status, response) // Should fail to post the group - _, status, body := apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup1, nil) + _, status, body := apiClient.RawConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup1, nil) requireStatusCode(t, http.StatusConflict, status, body) }) } @@ -410,6 +404,7 @@ func TestIntegrationConvertPrometheusEndpoints_CreatePausedRules(t *testing.T) { Login: "admin", }) apiClient := newAlertingApiClient(grafanaListedAddr, "admin", "password") + apiClient.prometheusConversionUseLokiPaths = enableLokiPaths ds := apiClient.CreateDatasource(t, datasources.DS_PROMETHEUS) @@ -499,3 +494,406 @@ func TestIntegrationConvertPrometheusEndpoints_CreatePausedRules(t *testing.T) { runTest(t, true) }) } + +func TestIntegrationConvertPrometheusEndpoints_FolderUIDHeader(t *testing.T) { + runTest := func(t *testing.T, enableLokiPaths bool) { + testinfra.SQLiteIntegrationTest(t) + + folderUIDHeader := "X-Grafana-Alerting-Folder-UID" + + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + EnableFeatureToggles: []string{"alertingConversionAPI"}, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Password: "password", + Login: "admin", + }) + apiClient := newAlertingApiClient(grafanaListedAddr, "admin", "password") + apiClient.prometheusConversionUseLokiPaths = enableLokiPaths + + ds := apiClient.CreateDatasource(t, datasources.DS_PROMETHEUS) + + // Create a parent folder + parentFolderUID := util.GenerateShortUID() + parentFolderTitle := "parent-folder" + apiClient.CreateFolder(t, parentFolderUID, parentFolderTitle) + + // Create a child folder inside the parent folder + childFolderUID := util.GenerateShortUID() + childFolderTitle := "child-folder" + apiClient.CreateFolder(t, childFolderUID, childFolderTitle, parentFolderUID) + + // Create another folder in root + otherFolderUID := util.GenerateShortUID() + otherFolderTitle := "other-folder" + apiClient.CreateFolder(t, otherFolderUID, otherFolderTitle) + + t.Run("create and delete rule groups with folder UID header", func(t *testing.T) { + // Post the namespace to parentFolderUID, it should create a new folder with the namespace name, + // and put the rule group in it. + headers := map[string]string{ + folderUIDHeader: parentFolderUID, + } + apiClient.ConvertPrometheusPostRuleGroup(t, childFolderTitle, ds.Body.Datasource.UID, promGroup1, headers) + + // Check that it's not visible when we get all namespaces from the root. + namespaces := apiClient.ConvertPrometheusGetAllRules(t, nil) + require.Empty(t, namespaces) + + // Post the group2 to the root, it should create a new folder with the namespace name. + apiClient.ConvertPrometheusPostRuleGroup(t, otherFolderTitle, ds.Body.Datasource.UID, promGroup2, nil) + + // Now we should have: + // - parentFolderUID/child-folder/test-group-1 + // - other-folder/test-group-2 + + // Verify the rule group was created in the child folder + // + // First try to get the group in the root folder, it should not be found + _, status, resp := apiClient.RawConvertPrometheusGetRuleGroupRules(t, childFolderTitle, promGroup1.Name, nil) + require.Equal(t, http.StatusNotFound, status, resp) + // Now try to get the group in the child folder + group1 := apiClient.ConvertPrometheusGetRuleGroupRules(t, childFolderTitle, promGroup1.Name, headers) + require.Equal(t, promGroup1, group1) + + // Verify the rule group was created in the other folder + group2 := apiClient.ConvertPrometheusGetRuleGroupRules(t, otherFolderTitle, promGroup2.Name, nil) + require.Equal(t, promGroup2, group2) + }) + + t.Run("empty folder UID header defaults to root", func(t *testing.T) { + // Create a folder at root level + rootFolderUID := util.GenerateShortUID() + rootFolderTitle := "root-folder" + apiClient.CreateFolder(t, rootFolderUID, rootFolderTitle) + + // Use empty folder UID header which should default to root + headers := map[string]string{ + folderUIDHeader: "", + } + + apiClient.ConvertPrometheusPostRuleGroup(t, rootFolderTitle, ds.Body.Datasource.UID, promGroup3, headers) + + // Verify the rule group was created + group := apiClient.ConvertPrometheusGetRuleGroupRules(t, rootFolderTitle, promGroup3.Name, headers) + require.Equal(t, promGroup3, group) + }) + } + + t.Run("with the mimirtool paths", func(t *testing.T) { + runTest(t, false) + }) + + t.Run("with the cortextool Loki paths", func(t *testing.T) { + runTest(t, true) + }) +} + +func TestIntegrationConvertPrometheusEndpoints_Delete(t *testing.T) { + runTest := func(t *testing.T, enableLokiPaths bool) { + testinfra.SQLiteIntegrationTest(t) + + // Setup Grafana and its Database + dir, gpath := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + EnableFeatureToggles: []string{"alertingConversionAPI"}, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, gpath) + + // Create users with different permissions + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Password: "password", + Login: "admin", + }) + adminClient := newAlertingApiClient(grafanaListedAddr, "admin", "password") + adminClient.prometheusConversionUseLokiPaths = enableLokiPaths + + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleEditor), + Password: "password", + Login: "editor", + }) + editorClient := newAlertingApiClient(grafanaListedAddr, "editor", "password") + editorClient.prometheusConversionUseLokiPaths = enableLokiPaths + + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleViewer), + Password: "password", + Login: "viewer", + }) + viewerClient := newAlertingApiClient(grafanaListedAddr, "viewer", "password") + viewerClient.prometheusConversionUseLokiPaths = enableLokiPaths + + // Create a user with no access + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleNone), + Password: "password", + Login: "no-role-user", + }) + noRoleClient := newAlertingApiClient(grafanaListedAddr, "no-role-user", "password") + noRoleClient.prometheusConversionUseLokiPaths = enableLokiPaths + + ds := adminClient.CreateDatasource(t, datasources.DS_PROMETHEUS) + + t.Run("delete non-existent namespace returns 404", func(t *testing.T) { + nonExistentNamespace := "non-existent-namespace-" + util.GenerateShortUID() + _, status, raw := adminClient.RawConvertPrometheusDeleteNamespace(t, nonExistentNamespace, nil) + requireStatusCode(t, http.StatusNotFound, status, raw) + }) + + t.Run("delete non-existent rule group returns not found", func(t *testing.T) { + nonExistentNamespace := "non-existent-namespace-" + util.GenerateShortUID() + nonExistentGroup := "non-existent-group-" + util.GenerateShortUID() + _, status, raw := adminClient.RawConvertPrometheusDeleteRuleGroup(t, nonExistentNamespace, nonExistentGroup, nil) + requireStatusCode(t, http.StatusNotFound, status, raw) + }) + + t.Run("delete rule group from existing namespace that has no rule groups", func(t *testing.T) { + // Create a namespace but don't add any rule groups to it + emptyNamespace := "empty-namespace-" + util.GenerateShortUID() + emptyNamespaceUID := util.GenerateShortUID() + adminClient.CreateFolder(t, emptyNamespaceUID, emptyNamespace) + + // Try to delete a non-existent rule group from that namespace + nonExistentGroup := "non-existent-group-" + util.GenerateShortUID() + _, status, raw := adminClient.RawConvertPrometheusDeleteRuleGroup(t, emptyNamespace, nonExistentGroup, nil) + requireStatusCode(t, http.StatusNotFound, status, raw) + }) + + t.Run("delete rule group then verify it's gone", func(t *testing.T) { + // Create namespace and rule group + namespace := "test-namespace-delete-" + util.GenerateShortUID() + namespaceUID := util.GenerateShortUID() + adminClient.CreateFolder(t, namespaceUID, namespace) + + // Create rule group + adminClient.ConvertPrometheusPostRuleGroup(t, namespace, ds.Body.Datasource.UID, promGroup1, nil) + + // Verify the rule group exists + group := adminClient.ConvertPrometheusGetRuleGroupRules(t, namespace, promGroup1.Name, nil) + require.Equal(t, promGroup1.Name, group.Name) + + // Delete the rule group + adminClient.ConvertPrometheusDeleteRuleGroup(t, namespace, promGroup1.Name, nil) + + // Verify the rule group is gone + _, status, _ := adminClient.RawConvertPrometheusGetRuleGroupRules(t, namespace, promGroup1.Name, nil) + require.Equal(t, http.StatusNotFound, status) + }) + + t.Run("delete namespace then verify it's empty", func(t *testing.T) { + // Create namespace with two rule groups + namespace := "test-namespace-delete-all-" + util.GenerateShortUID() + namespaceUID := util.GenerateShortUID() + adminClient.CreateFolder(t, namespaceUID, namespace) + + // Create rule groups + adminClient.ConvertPrometheusPostRuleGroup(t, namespace, ds.Body.Datasource.UID, promGroup1, nil) + adminClient.ConvertPrometheusPostRuleGroup(t, namespace, ds.Body.Datasource.UID, promGroup2, nil) + + // Verify the namespace has rule groups + groups := adminClient.ConvertPrometheusGetNamespaceRules(t, namespace, nil) + require.ElementsMatch(t, groups[namespace], []apimodels.PrometheusRuleGroup{promGroup1, promGroup2}) + + // Delete the namespace + adminClient.ConvertPrometheusDeleteNamespace(t, namespace, nil) + + // Verify the namespace is empty + namespaces := adminClient.ConvertPrometheusGetAllRules(t, nil) + _, exists := namespaces[namespace] + require.False(t, exists) + }) + + t.Run("delete specific rule group leaves other groups intact", func(t *testing.T) { + // Create namespace with two rule groups + namespace := "test-namespace-delete-one-" + util.GenerateShortUID() + namespaceUID := util.GenerateShortUID() + adminClient.CreateFolder(t, namespaceUID, namespace) + + // Create rule groups + adminClient.ConvertPrometheusPostRuleGroup(t, namespace, ds.Body.Datasource.UID, promGroup1, nil) + adminClient.ConvertPrometheusPostRuleGroup(t, namespace, ds.Body.Datasource.UID, promGroup2, nil) + + // Delete one rule group + adminClient.ConvertPrometheusDeleteRuleGroup(t, namespace, promGroup1.Name, nil) + + // Verify the other rule group still exists + groups := adminClient.ConvertPrometheusGetNamespaceRules(t, namespace, nil) + require.ElementsMatch(t, groups[namespace], []apimodels.PrometheusRuleGroup{promGroup2}) + }) + + t.Run("viewer cannot delete rule groups", func(t *testing.T) { + // Create namespace and rule group as admin + namespace := "test-namespace-viewer-" + util.GenerateShortUID() + namespaceUID := util.GenerateShortUID() + adminClient.CreateFolder(t, namespaceUID, namespace) + + adminClient.ConvertPrometheusPostRuleGroup(t, namespace, ds.Body.Datasource.UID, promGroup1, nil) + + // Try to delete as viewer - this should return 403 Forbidden + _, status, body := viewerClient.RawConvertPrometheusDeleteRuleGroup(t, namespace, promGroup1.Name, nil) + requireStatusCode(t, http.StatusForbidden, status, body) + + // Verify the rule group still exists + group := adminClient.ConvertPrometheusGetRuleGroupRules(t, namespace, promGroup1.Name, nil) + require.Equal(t, promGroup1.Name, group.Name) + }) + + t.Run("viewer cannot delete namespaces", func(t *testing.T) { + // Create namespace and rule group as admin + namespace := "test-namespace-viewer-ns-" + util.GenerateShortUID() + namespaceUID := util.GenerateShortUID() + adminClient.CreateFolder(t, namespaceUID, namespace) + + adminClient.ConvertPrometheusPostRuleGroup(t, namespace, ds.Body.Datasource.UID, promGroup1, nil) + + // Try to delete as viewer - this should return 403 Forbidden + _, status, body := viewerClient.RawConvertPrometheusDeleteNamespace(t, namespace, nil) + requireStatusCode(t, http.StatusForbidden, status, body) + + // Verify the namespace still exists + namespaces := adminClient.ConvertPrometheusGetAllRules(t, nil) + _, exists := namespaces[namespace] + require.True(t, exists) + }) + + t.Run("deleting rule group with nested folder structure using header", func(t *testing.T) { + // Create parent folder + parentFolder := "parent-folder-" + util.GenerateShortUID() + parentFolderUID := util.GenerateShortUID() + adminClient.CreateFolder(t, parentFolderUID, parentFolder) + + // Create child folder inside parent + childFolder := "child-folder-" + util.GenerateShortUID() + childFolderUID := util.GenerateShortUID() + adminClient.CreateFolder(t, childFolderUID, childFolder, parentFolderUID) + + // Create rule group in child folder + headers := map[string]string{ + "X-Grafana-Alerting-Folder-UID": parentFolderUID, + } + adminClient.ConvertPrometheusPostRuleGroup(t, childFolder, ds.Body.Datasource.UID, promGroup1, headers) + + // Verify the rule group exists + group := adminClient.ConvertPrometheusGetRuleGroupRules(t, childFolder, promGroup1.Name, headers) + require.Equal(t, promGroup1.Name, group.Name) + + // Delete the rule group + adminClient.ConvertPrometheusDeleteRuleGroup(t, childFolder, promGroup1.Name, headers) + + // Verify the rule group is gone + _, status, _ := adminClient.RawConvertPrometheusGetRuleGroupRules(t, childFolder, promGroup1.Name, headers) + require.Equal(t, http.StatusNotFound, status) + }) + + t.Run("deleting namespace with nested folder structure using header", func(t *testing.T) { + // Create parent folder + parentFolder := "parent-folder-ns-" + util.GenerateShortUID() + parentFolderUID := util.GenerateShortUID() + adminClient.CreateFolder(t, parentFolderUID, parentFolder) + + // Create child folder inside parent + childFolder := "child-folder-ns-" + util.GenerateShortUID() + childFolderUID := util.GenerateShortUID() + adminClient.CreateFolder(t, childFolderUID, childFolder, parentFolderUID) + + // Create rule groups in child folder + headers := map[string]string{ + "X-Grafana-Alerting-Folder-UID": parentFolderUID, + } + adminClient.ConvertPrometheusPostRuleGroup(t, childFolder, ds.Body.Datasource.UID, promGroup1, headers) + adminClient.ConvertPrometheusPostRuleGroup(t, childFolder, ds.Body.Datasource.UID, promGroup2, headers) + + // And a rule group in the parent folder + adminClient.ConvertPrometheusPostRuleGroup(t, parentFolder, ds.Body.Datasource.UID, promGroup3, nil) + + // Verify both namespaces have rule groups + groups := adminClient.ConvertPrometheusGetNamespaceRules(t, childFolder, headers) + require.ElementsMatch(t, groups[childFolder], []apimodels.PrometheusRuleGroup{promGroup1, promGroup2}) + require.Empty(t, groups[parentFolder]) + + parentGroups := adminClient.ConvertPrometheusGetNamespaceRules(t, parentFolder, nil) + require.Empty(t, parentGroups[childFolder]) + require.ElementsMatch(t, parentGroups[parentFolder], []apimodels.PrometheusRuleGroup{promGroup3}) + + // Delete the child namespace + adminClient.ConvertPrometheusDeleteNamespace(t, childFolder, headers) + + // Verify the namespace is empty + namespaces := adminClient.ConvertPrometheusGetAllRules(t, headers) + _, exists := namespaces[childFolder] + require.False(t, exists) + + // But the parent folder still has its rule group + parentGroups = adminClient.ConvertPrometheusGetNamespaceRules(t, parentFolder, nil) + require.ElementsMatch(t, parentGroups[parentFolder], []apimodels.PrometheusRuleGroup{promGroup3}) + }) + + t.Run("editor can delete rule group they created", func(t *testing.T) { + // Create namespace as admin + namespace := "test-namespace-editor-" + util.GenerateShortUID() + namespaceUID := util.GenerateShortUID() + adminClient.CreateFolder(t, namespaceUID, namespace) + + // Create rule group as editor + editorClient.ConvertPrometheusPostRuleGroup(t, namespace, ds.Body.Datasource.UID, promGroup1, nil) + + // Verify the rule group exists + group := editorClient.ConvertPrometheusGetRuleGroupRules(t, namespace, promGroup1.Name, nil) + require.Equal(t, promGroup1.Name, group.Name) + + // Delete as editor + editorClient.ConvertPrometheusDeleteRuleGroup(t, namespace, promGroup1.Name, nil) + + // Verify the rule group is gone + _, status, _ := editorClient.RawConvertPrometheusGetRuleGroupRules(t, namespace, promGroup1.Name, nil) + require.Equal(t, http.StatusNotFound, status) + }) + + t.Run("user with no role cannot delete rule groups", func(t *testing.T) { + // Create namespace and rule group as admin + namespace := "test-namespace-no-role-" + util.GenerateShortUID() + namespaceUID := util.GenerateShortUID() + adminClient.CreateFolder(t, namespaceUID, namespace) + + adminClient.ConvertPrometheusPostRuleGroup(t, namespace, ds.Body.Datasource.UID, promGroup1, nil) + + _, status, body := noRoleClient.RawConvertPrometheusDeleteRuleGroup(t, namespace, promGroup1.Name, nil) + requireStatusCode(t, http.StatusForbidden, status, body) + }) + + t.Run("user with no role cannot delete namespaces", func(t *testing.T) { + // Create namespace and rule group as admin + namespace := "test-namespace-no-role-ns-" + util.GenerateShortUID() + namespaceUID := util.GenerateShortUID() + adminClient.CreateFolder(t, namespaceUID, namespace) + + adminClient.ConvertPrometheusPostRuleGroup(t, namespace, ds.Body.Datasource.UID, promGroup1, nil) + + _, status, body := noRoleClient.RawConvertPrometheusDeleteNamespace(t, namespace, nil) + requireStatusCode(t, http.StatusForbidden, status, body) + }) + } + + t.Run("with the mimirtool paths", func(t *testing.T) { + runTest(t, false) + }) + + t.Run("with the cortextool Loki paths", func(t *testing.T) { + runTest(t, true) + }) +} diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index 5e1e34f63bf..f613d7a5547 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -1102,14 +1102,28 @@ func (a apiClient) GetRuleByUID(t *testing.T, ruleUID string) apimodels.Gettable return rule } -func (a apiClient) ConvertPrometheusPostRuleGroup(t *testing.T, namespaceTitle, datasourceUID string, promGroup apimodels.PrometheusRuleGroup, headers map[string]string) (apimodels.ConvertPrometheusResponse, int, string) { +func (a apiClient) ConvertPrometheusPostRuleGroup(t *testing.T, namespaceTitle, datasourceUID string, promGroup apimodels.PrometheusRuleGroup, headers map[string]string) apimodels.ConvertPrometheusResponse { t.Helper() + resp, status, body := a.RawConvertPrometheusPostRuleGroup(t, namespaceTitle, datasourceUID, promGroup, headers) + requireStatusCode(t, http.StatusAccepted, status, body) + + return resp +} + +func (a apiClient) RawConvertPrometheusPostRuleGroup(t *testing.T, namespaceTitle, datasourceUID string, promGroup apimodels.PrometheusRuleGroup, headers map[string]string) (apimodels.ConvertPrometheusResponse, int, string) { + t.Helper() + + path := "%s/api/convert/prometheus/config/v1/rules/%s" + if a.prometheusConversionUseLokiPaths { + path = "%s/api/convert/api/prom/rules/%s" + } + data, err := yaml.Marshal(promGroup) require.NoError(t, err) buf := bytes.NewReader(data) - req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/convert/prometheus/config/v1/rules/%s", a.url, namespaceTitle), buf) + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf(path, a.url, namespaceTitle), buf) require.NoError(t, err) req.Header.Add("X-Grafana-Alerting-Datasource-UID", datasourceUID) @@ -1120,7 +1134,16 @@ func (a apiClient) ConvertPrometheusPostRuleGroup(t *testing.T, namespaceTitle, return sendRequestJSON[apimodels.ConvertPrometheusResponse](t, req, http.StatusAccepted) } -func (a apiClient) ConvertPrometheusGetRuleGroupRules(t *testing.T, namespaceTitle, groupName string) apimodels.PrometheusRuleGroup { +func (a apiClient) ConvertPrometheusGetRuleGroupRules(t *testing.T, namespaceTitle, groupName string, headers map[string]string) apimodels.PrometheusRuleGroup { + t.Helper() + + rule, status, raw := a.RawConvertPrometheusGetRuleGroupRules(t, namespaceTitle, groupName, headers) + requireStatusCode(t, http.StatusOK, status, raw) + + return rule +} + +func (a apiClient) RawConvertPrometheusGetRuleGroupRules(t *testing.T, namespaceTitle, groupName string, headers map[string]string) (apimodels.PrometheusRuleGroup, int, string) { t.Helper() path := "%s/api/convert/prometheus/config/v1/rules/%s/%s" @@ -1130,12 +1153,17 @@ func (a apiClient) ConvertPrometheusGetRuleGroupRules(t *testing.T, namespaceTit req, err := http.NewRequest(http.MethodGet, fmt.Sprintf(path, a.url, namespaceTitle, groupName), nil) require.NoError(t, err) + + for key, value := range headers { + req.Header.Add(key, value) + } + rule, status, raw := sendRequestYAML[apimodels.PrometheusRuleGroup](t, req, http.StatusOK) - requireStatusCode(t, http.StatusOK, status, raw) - return rule + + return rule, status, raw } -func (a apiClient) ConvertPrometheusGetNamespaceRules(t *testing.T, namespaceTitle string) map[string][]apimodels.PrometheusRuleGroup { +func (a apiClient) ConvertPrometheusGetNamespaceRules(t *testing.T, namespaceTitle string, headers map[string]string) map[string][]apimodels.PrometheusRuleGroup { t.Helper() path := "%s/api/convert/prometheus/config/v1/rules/%s" @@ -1145,12 +1173,18 @@ func (a apiClient) ConvertPrometheusGetNamespaceRules(t *testing.T, namespaceTit req, err := http.NewRequest(http.MethodGet, fmt.Sprintf(path, a.url, namespaceTitle), nil) require.NoError(t, err) + + for key, value := range headers { + req.Header.Add(key, value) + } + ns, status, raw := sendRequestYAML[map[string][]apimodels.PrometheusRuleGroup](t, req, http.StatusOK) requireStatusCode(t, http.StatusOK, status, raw) + return ns } -func (a apiClient) ConvertPrometheusGetAllRules(t *testing.T) map[string][]apimodels.PrometheusRuleGroup { +func (a apiClient) ConvertPrometheusGetAllRules(t *testing.T, headers map[string]string) map[string][]apimodels.PrometheusRuleGroup { t.Helper() path := "%s/api/convert/prometheus/config/v1/rules" @@ -1160,12 +1194,25 @@ func (a apiClient) ConvertPrometheusGetAllRules(t *testing.T) map[string][]apimo req, err := http.NewRequest(http.MethodGet, fmt.Sprintf(path, a.url), nil) require.NoError(t, err) + + for key, value := range headers { + req.Header.Add(key, value) + } + result, status, raw := sendRequestYAML[map[string][]apimodels.PrometheusRuleGroup](t, req, http.StatusOK) requireStatusCode(t, http.StatusOK, status, raw) + return result } -func (a apiClient) ConvertPrometheusDeleteRuleGroup(t *testing.T, namespaceTitle, groupName string) { +func (a apiClient) ConvertPrometheusDeleteRuleGroup(t *testing.T, namespaceTitle, groupName string, headers map[string]string) { + t.Helper() + + _, status, raw := a.RawConvertPrometheusDeleteRuleGroup(t, namespaceTitle, groupName, headers) + requireStatusCode(t, http.StatusAccepted, status, raw) +} + +func (a apiClient) RawConvertPrometheusDeleteRuleGroup(t *testing.T, namespaceTitle, groupName string, headers map[string]string) (apimodels.ConvertPrometheusResponse, int, string) { t.Helper() path := "%s/api/convert/prometheus/config/v1/rules/%s/%s" @@ -1175,11 +1222,22 @@ func (a apiClient) ConvertPrometheusDeleteRuleGroup(t *testing.T, namespaceTitle req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf(path, a.url, namespaceTitle, groupName), nil) require.NoError(t, err) - _, status, raw := sendRequestJSON[apimodels.ConvertPrometheusResponse](t, req, http.StatusAccepted) + + for key, value := range headers { + req.Header.Add(key, value) + } + + return sendRequestJSON[apimodels.ConvertPrometheusResponse](t, req, http.StatusAccepted) +} + +func (a apiClient) ConvertPrometheusDeleteNamespace(t *testing.T, namespaceTitle string, headers map[string]string) { + t.Helper() + + _, status, raw := a.RawConvertPrometheusDeleteNamespace(t, namespaceTitle, headers) requireStatusCode(t, http.StatusAccepted, status, raw) } -func (a apiClient) ConvertPrometheusDeleteNamespace(t *testing.T, namespaceTitle string) { +func (a apiClient) RawConvertPrometheusDeleteNamespace(t *testing.T, namespaceTitle string, headers map[string]string) (apimodels.ConvertPrometheusResponse, int, string) { t.Helper() path := "%s/api/convert/prometheus/config/v1/rules/%s" @@ -1189,8 +1247,12 @@ func (a apiClient) ConvertPrometheusDeleteNamespace(t *testing.T, namespaceTitle req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf(path, a.url, namespaceTitle), nil) require.NoError(t, err) - _, status, raw := sendRequestJSON[apimodels.ConvertPrometheusResponse](t, req, http.StatusAccepted) - requireStatusCode(t, http.StatusAccepted, status, raw) + + for key, value := range headers { + req.Header.Add(key, value) + } + + return sendRequestJSON[apimodels.ConvertPrometheusResponse](t, req, http.StatusAccepted) } func sendRequestRaw(t *testing.T, req *http.Request) ([]byte, int, error) {