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) {