Alerting: API to pause all alert rules in a folder (#104674)

This commit is contained in:
Alexander Akhmetov
2025-05-13 17:04:01 +02:00
committed by GitHub
parent 81d72bcfc7
commit 9fe523b9e6
14 changed files with 686 additions and 38 deletions
+99 -26
View File
@@ -452,9 +452,38 @@ func (srv RulerSrv) checkGroupLimits(group apimodels.PostableRuleGroupConfig) er
//
//nolint:gocyclo
func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey ngmodels.AlertRuleGroupKey, rules []*ngmodels.AlertRuleWithOptionals, deletePermanently bool) response.Response {
finalChanges, amConfig, err := srv.performUpdateAlertRules(c.Req.Context(), c, groupKey, rules, deletePermanently)
if err != nil {
if errors.As(err, &errutil.Error{}) {
return response.Err(err)
} else if errors.Is(err, ngmodels.ErrAlertRuleNotFound) {
return ErrResp(http.StatusNotFound, err, "failed to update rule group")
} else if errors.Is(err, ngmodels.ErrAlertRuleFailedValidation) || errors.Is(err, errProvisionedResource) {
return ErrResp(http.StatusBadRequest, err, "failed to update rule group")
} else if errors.Is(err, ngmodels.ErrQuotaReached) {
return ErrResp(http.StatusForbidden, err, "")
} else if errors.Is(err, store.ErrOptimisticLock) {
return ErrResp(http.StatusConflict, err, "")
}
return ErrResp(http.StatusInternalServerError, err, "failed to update rule group")
}
if amConfig != nil {
// This isn't strictly necessary since the alertmanager config is periodically synced.
err := srv.amRefresher.ApplyConfig(c.Req.Context(), groupKey.OrgID, amConfig)
if err != nil {
srv.log.Warn("Failed to refresh Alertmanager config for org after change in notification settings", "org", c.GetOrgID(), "error", err)
}
}
return changesToResponse(finalChanges)
}
func (srv RulerSrv) performUpdateAlertRules(ctx context.Context, c *contextmodel.ReqContext, groupKey ngmodels.AlertRuleGroupKey, rules []*ngmodels.AlertRuleWithOptionals, deletePermanently bool) (*store.GroupDelta, *ngmodels.AlertConfiguration, error) {
var finalChanges *store.GroupDelta
var dbConfig *ngmodels.AlertConfiguration
err := srv.xactManager.InTransaction(c.Req.Context(), func(tranCtx context.Context) error {
err := srv.xactManager.InTransaction(ctx, func(tranCtx context.Context) error {
id, _ := c.GetInternalID()
userNamespace := c.GetIdentityType()
@@ -471,18 +500,18 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey
return nil
}
err = srv.authz.AuthorizeRuleChanges(c.Req.Context(), c.SignedInUser, groupChanges)
err = srv.authz.AuthorizeRuleChanges(tranCtx, c.SignedInUser, groupChanges)
if err != nil {
return err
}
if err := validateQueries(c.Req.Context(), groupChanges, srv.conditionValidator, c.SignedInUser); err != nil {
if err := validateQueries(tranCtx, groupChanges, srv.conditionValidator, c.SignedInUser); err != nil {
return err
}
newOrUpdatedNotificationSettings := groupChanges.NewOrUpdatedNotificationSettings()
if len(newOrUpdatedNotificationSettings) > 0 {
dbConfig, err = srv.amConfigStore.GetLatestAlertmanagerConfiguration(c.Req.Context(), groupChanges.GroupKey.OrgID)
dbConfig, err = srv.amConfigStore.GetLatestAlertmanagerConfiguration(tranCtx, groupChanges.GroupKey.OrgID)
if err != nil {
return fmt.Errorf("failed to get latest configuration: %w", err)
}
@@ -498,7 +527,7 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey
}
}
if err := verifyProvisionedRulesNotAffected(c.Req.Context(), srv.provenanceStore, c.GetOrgID(), groupChanges); err != nil {
if err := verifyProvisionedRulesNotAffected(tranCtx, srv.provenanceStore, c.GetOrgID(), groupChanges); err != nil {
return err
}
@@ -568,29 +597,10 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey
})
if err != nil {
if errors.As(err, &errutil.Error{}) {
return response.Err(err)
} else if errors.Is(err, ngmodels.ErrAlertRuleNotFound) {
return ErrResp(http.StatusNotFound, err, "failed to update rule group")
} else if errors.Is(err, ngmodels.ErrAlertRuleFailedValidation) || errors.Is(err, errProvisionedResource) {
return ErrResp(http.StatusBadRequest, err, "failed to update rule group")
} else if errors.Is(err, ngmodels.ErrQuotaReached) {
return ErrResp(http.StatusForbidden, err, "")
} else if errors.Is(err, store.ErrOptimisticLock) {
return ErrResp(http.StatusConflict, err, "")
}
return ErrResp(http.StatusInternalServerError, err, "failed to update rule group")
return nil, nil, err
}
if dbConfig != nil {
// This isn't strictly necessary since the alertmanager config is periodically synced.
err := srv.amRefresher.ApplyConfig(c.Req.Context(), groupKey.OrgID, dbConfig)
if err != nil {
srv.log.Warn("Failed to refresh Alertmanager config for org after change in notification settings", "org", c.GetOrgID(), "error", err)
}
}
return changesToResponse(finalChanges)
return finalChanges, dbConfig, nil
}
func changesToResponse(finalChanges *store.GroupDelta) response.Response {
@@ -811,6 +821,69 @@ func (srv RulerSrv) searchAuthorizedAlertRules(ctx context.Context, q authorized
return byGroupKey, totalGroups, nil
}
// RouteUpdateNamespaceRules updates all alert rules in a namespace.
func (srv RulerSrv) RouteUpdateNamespaceRules(c *contextmodel.ReqContext, body apimodels.UpdateNamespaceRulesRequest, namespaceUID string) response.Response {
if body == (apimodels.UpdateNamespaceRulesRequest{}) {
return ErrResp(http.StatusBadRequest, errors.New("missing request body"), "")
}
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.GetOrgID(), c.SignedInUser)
if err != nil {
return toNamespaceErrorResponse(err)
}
ruleGroups, _, err := srv.searchAuthorizedAlertRules(c.Req.Context(), authorizedRuleGroupQuery{
User: c.SignedInUser,
NamespaceUIDs: []string{namespace.UID},
})
if err != nil {
return errorToResponse(err)
}
if len(ruleGroups) == 0 {
return response.JSON(http.StatusAccepted, apimodels.UpdateNamespaceRulesResponse{
Message: "no rules to update in namespace",
})
}
err = srv.xactManager.InTransaction(c.Req.Context(), func(ctx context.Context) error {
for groupKey, rules := range ruleGroups {
rulesToUpdate := make([]*ngmodels.AlertRuleWithOptionals, 0, len(rules))
for _, rule := range rules {
r := ngmodels.AlertRuleWithOptionals{
AlertRule: *rule,
HasPause: true,
HasEditorSettings: true,
}
if body.IsPaused != nil {
paused := *body.IsPaused
r.IsPaused = paused
}
rulesToUpdate = append(rulesToUpdate, &r)
}
_, _, err := srv.performUpdateAlertRules(ctx, c, groupKey, rulesToUpdate, false)
if errors.Is(err, errProvisionedResource) {
continue
}
if err != nil {
return err
}
}
return nil
})
if err != nil {
return errorToResponse(err)
}
return response.JSON(http.StatusAccepted, apimodels.UpdateNamespaceRulesResponse{
Message: "rules updated successfully",
})
}
// getUserUIDmaping returns a UserUID->UserInfo mapping from the UpdatedBy users in the RulesGroup
func (srv RulerSrv) getUserUIDmapping(ctx context.Context, rules []*ngmodels.AlertRule) map[ngmodels.UserUID]*apimodels.UserInfo {
mapping := map[ngmodels.UserUID]*apimodels.UserInfo{
+215 -5
View File
@@ -971,11 +971,12 @@ func createService(store *fakes.RuleStore, _userService *usertest.FakeUserServic
cfg: &setting.UnifiedAlertingSettings{
BaseInterval: 10 * time.Second,
},
authz: accesscontrol.NewRuleService(acimpl.ProvideAccessControl(featuremgmt.WithFeatures())),
amConfigStore: &fakeAMRefresher{},
amRefresher: &fakeAMRefresher{},
featureManager: featuremgmt.WithFeatures(featuremgmt.FlagGrafanaManagedRecordingRules),
userService: userService,
authz: accesscontrol.NewRuleService(acimpl.ProvideAccessControl(featuremgmt.WithFeatures())),
amConfigStore: &fakeAMRefresher{},
amRefresher: &fakeAMRefresher{},
featureManager: featuremgmt.WithFeatures(featuremgmt.FlagGrafanaManagedRecordingRules),
userService: userService,
conditionValidator: &recordingConditionValidator{},
}
}
@@ -1027,6 +1028,7 @@ func createPermissionsForRules(rules []*models.AlertRule, orgID int64) map[int64
scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(rule.NamespaceUID)
permissions[dashboards.ActionFoldersRead] = append(permissions[dashboards.ActionFoldersRead], scope)
permissions[ac.ActionAlertingRuleRead] = append(permissions[ac.ActionAlertingRuleRead], scope)
permissions[ac.ActionAlertingRuleUpdate] = append(permissions[ac.ActionAlertingRuleUpdate], scope)
ns[rule.NamespaceUID] = struct{}{}
}
for _, query := range rule.Data {
@@ -1049,3 +1051,211 @@ func createPermissionsForRulesWithoutDS(rules []*models.AlertRule, orgID int64)
}
return map[int64]map[string][]string{orgID: permissions}
}
func TestRouteUpdateNamespaceRules(t *testing.T) {
orgID := rand.Int63()
folder := randFolder()
gen := models.RuleGen.With(
models.RuleGen.WithOrgID(orgID),
models.RuleGen.WithNamespaceUID(folder.UID),
)
initFakeRuleStore := func(t *testing.T) *fakes.RuleStore {
ruleStore := fakes.NewRuleStore(t)
ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], folder)
return ruleStore
}
getRecordedUpdatedRules := func(ruleStore *fakes.RuleStore) []models.UpdateRule {
raw := ruleStore.GetRecordedCommands(func(cmd any) (any, bool) {
if u, ok := cmd.([]models.UpdateRule); ok {
return u, true
}
return nil, false
})
updates := []models.UpdateRule{}
for _, cmd := range raw {
updates = append(updates, cmd.([]models.UpdateRule)...)
}
return updates
}
t.Run("should pause all non-provisioned rules in namespace", func(t *testing.T) {
ruleStore := initFakeRuleStore(t)
provisioningStore := fakes.NewFakeProvisioningStore()
// Create 3 types of rules: paused, provisioned paused, and unpaused
pausedRules := gen.With(gen.WithGroupPrefix("paused-"), gen.WithIsPaused(true)).GenerateManyRef(2)
unpausedRules := gen.With(gen.WithGroupPrefix("unpaused-"), gen.WithIsPaused(false)).GenerateManyRef(1)
provisionedRules := gen.With(
gen.WithGroupPrefix("provisioned-"),
gen.WithIsPaused(false),
).GenerateManyRef(3)
for _, r := range provisionedRules {
err := provisioningStore.SetProvenance(context.Background(), r, orgID, models.ProvenanceAPI)
require.NoError(t, err)
}
ruleStore.PutRule(context.Background(), unpausedRules...)
ruleStore.PutRule(context.Background(), provisionedRules...)
ruleStore.PutRule(context.Background(), pausedRules...)
allRules := append(append(unpausedRules, provisionedRules...), pausedRules...)
permissions := createPermissionsForRules(allRules, orgID)
requestCtx := createRequestContextWithPerms(orgID, permissions, nil)
svc := createServiceWithProvenanceStore(ruleStore, provisioningStore)
response := svc.RouteUpdateNamespaceRules(requestCtx, apimodels.UpdateNamespaceRulesRequest{
IsPaused: util.Pointer(true),
}, folder.UID)
require.Equal(t, http.StatusAccepted, response.Status())
result := &apimodels.UpdateNamespaceRulesResponse{}
require.NoError(t, json.Unmarshal(response.Body(), result))
require.Equal(t, "rules updated successfully", result.Message)
updatedRules := getRecordedUpdatedRules(ruleStore)
require.Len(t, updatedRules, len(unpausedRules))
for _, update := range updatedRules {
require.True(t, update.New.IsPaused)
}
})
t.Run("should unpause all non-provisioned rules in namespace", func(t *testing.T) {
ruleStore := initFakeRuleStore(t)
provisioningStore := fakes.NewFakeProvisioningStore()
// Create 3 types of rules: paused, provisioned paused, and unpaused
pausedRules := gen.With(gen.WithGroupPrefix("paused-"), gen.WithIsPaused(true)).GenerateManyRef(4)
unpausedRules := gen.With(gen.WithGroupPrefix("unpaused-"), gen.WithIsPaused(false)).GenerateManyRef(3)
provisionedRules := gen.With(
gen.WithGroupPrefix("provisioned-"),
gen.WithIsPaused(false),
).GenerateManyRef(2)
for _, r := range provisionedRules {
err := provisioningStore.SetProvenance(context.Background(), r, orgID, models.ProvenanceAPI)
require.NoError(t, err)
}
ruleStore.PutRule(context.Background(), pausedRules...)
ruleStore.PutRule(context.Background(), provisionedRules...)
ruleStore.PutRule(context.Background(), unpausedRules...)
allRules := append(append(pausedRules, provisionedRules...), unpausedRules...)
permissions := createPermissionsForRules(allRules, orgID)
requestCtx := createRequestContextWithPerms(orgID, permissions, nil)
svc := createServiceWithProvenanceStore(ruleStore, provisioningStore)
response := svc.RouteUpdateNamespaceRules(requestCtx, apimodels.UpdateNamespaceRulesRequest{
IsPaused: util.Pointer(false),
}, folder.UID)
require.Equal(t, http.StatusAccepted, response.Status())
result := &apimodels.UpdateNamespaceRulesResponse{}
require.NoError(t, json.Unmarshal(response.Body(), result))
require.Equal(t, "rules updated successfully", result.Message)
updatedRules := getRecordedUpdatedRules(ruleStore)
require.Len(t, updatedRules, len(pausedRules))
// all rules are now unpaused
for _, update := range updatedRules {
require.False(t, update.New.IsPaused)
}
})
t.Run("returns 202 when no rules need updating", func(t *testing.T) {
ruleStore := initFakeRuleStore(t)
provisioningStore := fakes.NewFakeProvisioningStore()
// Create already unpaused rules
rules := gen.With(gen.WithGroupPrefix("paused-"), gen.WithIsPaused(false)).GenerateManyRef(5)
ruleStore.PutRule(context.Background(), rules...)
permissions := createPermissionsForRules(rules, orgID)
requestCtx := createRequestContextWithPerms(orgID, permissions, nil)
// Create request to unpause rules (they are already unpaused)
svc := createServiceWithProvenanceStore(ruleStore, provisioningStore)
response := svc.RouteUpdateNamespaceRules(requestCtx, apimodels.UpdateNamespaceRulesRequest{
IsPaused: util.Pointer(false),
}, folder.UID)
require.Equal(t, http.StatusAccepted, response.Status())
result := &apimodels.UpdateNamespaceRulesResponse{}
require.NoError(t, json.Unmarshal(response.Body(), result))
require.Equal(t, "rules updated successfully", result.Message)
// Verify no rules were updated
updatedRules := getRecordedUpdatedRules(ruleStore)
require.Empty(t, updatedRules)
})
t.Run("should return 202 with 'no rules to update in namespace' when namespace is empty", func(t *testing.T) {
ruleStore := initFakeRuleStore(t)
provisioningStore := fakes.NewFakeProvisioningStore()
requestCtx := createRequestContextWithPerms(orgID, map[int64]map[string][]string{}, nil)
svc := createServiceWithProvenanceStore(ruleStore, provisioningStore)
response := svc.RouteUpdateNamespaceRules(requestCtx, apimodels.UpdateNamespaceRulesRequest{
IsPaused: util.Pointer(true),
}, folder.UID)
require.Equal(t, http.StatusAccepted, response.Status())
result := &apimodels.UpdateNamespaceRulesResponse{}
require.NoError(t, json.Unmarshal(response.Body(), result))
require.Equal(t, "no rules to update in namespace", result.Message)
updatedRules := getRecordedUpdatedRules(ruleStore)
require.Empty(t, updatedRules)
})
t.Run("should handle folder not found", func(t *testing.T) {
ruleStore := initFakeRuleStore(t)
provisioningStore := fakes.NewFakeProvisioningStore()
requestCtx := createRequestContextWithPerms(orgID, map[int64]map[string][]string{}, nil)
svc := createServiceWithProvenanceStore(ruleStore, provisioningStore)
response := svc.RouteUpdateNamespaceRules(requestCtx, apimodels.UpdateNamespaceRulesRequest{
IsPaused: util.Pointer(true),
}, "non-existent-folder-uid")
require.Equal(t, http.StatusNotFound, response.Status())
updatedRules := getRecordedUpdatedRules(ruleStore)
require.Empty(t, updatedRules)
})
t.Run("should return 202 with no updates when the user does not see any rules", func(t *testing.T) {
ruleStore := initFakeRuleStore(t)
provisioningStore := fakes.NewFakeProvisioningStore()
rules := gen.GenerateManyRef(2)
ruleStore.PutRule(context.Background(), rules...)
permissions := map[int64]map[string][]string{orgID: {}}
requestCtx := createRequestContextWithPerms(orgID, permissions, nil)
svc := createServiceWithProvenanceStore(ruleStore, provisioningStore)
response := svc.RouteUpdateNamespaceRules(requestCtx, apimodels.UpdateNamespaceRulesRequest{
IsPaused: util.Pointer(true),
}, folder.UID)
require.Equal(t, http.StatusAccepted, response.Status())
result := &apimodels.UpdateNamespaceRulesResponse{}
require.NoError(t, json.Unmarshal(response.Body(), result))
require.Equal(t, "no rules to update in namespace", result.Message)
updatedRules := getRecordedUpdatedRules(ruleStore)
require.Empty(t, updatedRules)
})
}
+2 -1
View File
@@ -52,7 +52,8 @@ func (api *API) authorize(method, path string) web.Handler {
eval = ac.EvalAll(ac.EvalPermission(ac.ActionAlertingRuleRead, scope),
ac.EvalPermission(dashboards.ActionFoldersRead, scope),
)
case http.MethodPost + "/api/ruler/grafana/api/v1/rules/{Namespace}":
case http.MethodPost + "/api/ruler/grafana/api/v1/rules/{Namespace}",
http.MethodPatch + "/api/ruler/grafana/api/v1/rules/{Namespace}":
scope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.Parameter(":Namespace"))
// more granular permissions are enforced by the handler via "authorizeRuleChanges"
eval = ac.EvalAll(
@@ -132,3 +132,7 @@ func (f *RulerApiHandler) handleRouteGetRuleVersionsByUID(ctx *contextmodel.ReqC
func (f *RulerApiHandler) handleRouteDeleteRuleFromTrashByGUID(ctx *contextmodel.ReqContext, ruleGUID string) response.Response {
return f.GrafanaRuler.RouteDeleteAlertRuleFromTrashByGUID(ctx, ruleGUID)
}
func (f *RulerApiHandler) handleRouteUpdateNamespaceRules(ctx *contextmodel.ReqContext, body apimodels.UpdateNamespaceRulesRequest, namespace string) response.Response {
return f.GrafanaRuler.RouteUpdateNamespaceRules(ctx, body, namespace)
}
@@ -37,6 +37,7 @@ type RulerApi interface {
RoutePostNameGrafanaRulesConfig(*contextmodel.ReqContext) response.Response
RoutePostNameRulesConfig(*contextmodel.ReqContext) response.Response
RoutePostRulesGroupForExport(*contextmodel.ReqContext) response.Response
RouteUpdateNamespaceRules(*contextmodel.ReqContext) response.Response
}
func (f *RulerApiHandler) RouteDeleteGrafanaRuleGroupConfig(ctx *contextmodel.ReqContext) response.Response {
@@ -144,6 +145,16 @@ func (f *RulerApiHandler) RoutePostRulesGroupForExport(ctx *contextmodel.ReqCont
}
return f.handleRoutePostRulesGroupForExport(ctx, conf, namespaceParam)
}
func (f *RulerApiHandler) RouteUpdateNamespaceRules(ctx *contextmodel.ReqContext) response.Response {
// Parse Path Parameters
namespaceParam := web.Params(ctx.Req)[":Namespace"]
// Parse Request Body
conf := apimodels.UpdateNamespaceRulesRequest{}
if err := web.Bind(ctx.Req, &conf); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
return f.handleRouteUpdateNamespaceRules(ctx, conf, namespaceParam)
}
func (api *API) RegisterRulerApiEndpoints(srv RulerApi, m *metrics.API) {
api.RouteRegister.Group("", func(group routing.RouteRegister) {
@@ -351,5 +362,17 @@ func (api *API) RegisterRulerApiEndpoints(srv RulerApi, m *metrics.API) {
m,
),
)
group.Patch(
toMacaronPath("/api/ruler/grafana/api/v1/rules/{Namespace}"),
requestmeta.SetOwner(requestmeta.TeamAlerting),
requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow),
api.authorize(http.MethodPatch, "/api/ruler/grafana/api/v1/rules/{Namespace}"),
metrics.Instrument(
http.MethodPatch,
"/api/ruler/grafana/api/v1/rules/{Namespace}",
api.Hooks.Wrap(srv.RouteUpdateNamespaceRules),
m,
),
)
}, middleware.ReqSignedIn)
}
+17 -2
View File
@@ -4636,7 +4636,6 @@
"type": "object"
},
"URL": {
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nThe Host field contains the host and port subcomponents of the URL.\nWhen the port is present, it is separated from the host with a colon.\nWhen the host is an IPv6 address, it must be enclosed in square brackets:\n\"[fe80::1]:80\". The [net.JoinHostPort] function combines a host and port\ninto a string suitable for the Host field, adding square brackets to\nthe host when necessary.\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the [URL.EscapedPath] method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.",
"properties": {
"ForceQuery": {
"type": "boolean"
@@ -4672,7 +4671,23 @@
"$ref": "#/definitions/Userinfo"
}
},
"title": "A URL represents a parsed URL (technically, a URI reference).",
"title": "URL is a custom URL type that allows validation at configuration load time.",
"type": "object"
},
"UpdateNamespaceRulesRequest": {
"properties": {
"is_paused": {
"type": "boolean"
}
},
"type": "object"
},
"UpdateNamespaceRulesResponse": {
"properties": {
"message": {
"type": "string"
}
},
"type": "object"
},
"UpdateRuleGroupResponse": {
@@ -97,6 +97,19 @@ import (
// 403: ForbiddenError
//
// swagger:route PATCH /ruler/grafana/api/v1/rules/{Namespace} ruler RouteUpdateNamespaceRules
//
// Update all rules in a namespace
//
// Consumes:
// - application/json
//
// Responses:
// 202: UpdateNamespaceRulesResponse
// 403: ForbiddenError
// 404: NotFound.
//
// swagger:route POST /ruler/grafana/api/v1/rules/{Namespace}/export ruler RoutePostRulesGroupForExport
//
// Converts submitted rule group to provisioning format
@@ -686,3 +699,22 @@ type UpdateRuleGroupResponse struct {
Updated []string `json:"updated,omitempty"`
Deleted []string `json:"deleted,omitempty"`
}
// swagger:parameters RouteUpdateNamespaceRules
type UpdateNamespaceRulesParams struct {
// The UID of the rule folder
// in:path
Namespace string
// in:body
Body UpdateNamespaceRulesRequest
}
// swagger:model
type UpdateNamespaceRulesRequest struct {
IsPaused *bool `json:"is_paused"`
}
// swagger:model
type UpdateNamespaceRulesResponse struct {
Message string `json:"message"`
}
+61 -1
View File
@@ -4636,6 +4636,7 @@
"type": "object"
},
"URL": {
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nThe Host field contains the host and port subcomponents of the URL.\nWhen the port is present, it is separated from the host with a colon.\nWhen the host is an IPv6 address, it must be enclosed in square brackets:\n\"[fe80::1]:80\". The [net.JoinHostPort] function combines a host and port\ninto a string suitable for the Host field, adding square brackets to\nthe host when necessary.\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the [URL.EscapedPath] method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.",
"properties": {
"ForceQuery": {
"type": "boolean"
@@ -4671,7 +4672,23 @@
"$ref": "#/definitions/Userinfo"
}
},
"title": "URL is a custom URL type that allows validation at configuration load time.",
"title": "A URL represents a parsed URL (technically, a URI reference).",
"type": "object"
},
"UpdateNamespaceRulesRequest": {
"properties": {
"is_paused": {
"type": "boolean"
}
},
"type": "object"
},
"UpdateNamespaceRulesResponse": {
"properties": {
"message": {
"type": "string"
}
},
"type": "object"
},
"UpdateRuleGroupResponse": {
@@ -7473,6 +7490,49 @@
"ruler"
]
},
"patch": {
"consumes": [
"application/json"
],
"description": "Update all rules in a namespace",
"operationId": "RouteUpdateNamespaceRules",
"parameters": [
{
"description": "The UID of the rule folder",
"in": "path",
"name": "Namespace",
"required": true,
"type": "string"
},
{
"in": "body",
"name": "Body",
"schema": {
"$ref": "#/definitions/UpdateNamespaceRulesRequest"
}
}
],
"responses": {
"202": {
"description": "UpdateNamespaceRulesResponse",
"schema": {
"$ref": "#/definitions/UpdateNamespaceRulesResponse"
}
},
"403": {
"description": "ForbiddenError",
"schema": {
"$ref": "#/definitions/ForbiddenError"
}
},
"404": {
"$ref": "#/responses/NotFound."
}
},
"tags": [
"ruler"
]
},
"post": {
"consumes": [
"application/json",
+61 -1
View File
@@ -2071,6 +2071,49 @@
}
}
}
},
"patch": {
"description": "Update all rules in a namespace",
"consumes": [
"application/json"
],
"tags": [
"ruler"
],
"operationId": "RouteUpdateNamespaceRules",
"parameters": [
{
"type": "string",
"description": "The UID of the rule folder",
"name": "Namespace",
"in": "path",
"required": true
},
{
"name": "Body",
"in": "body",
"schema": {
"$ref": "#/definitions/UpdateNamespaceRulesRequest"
}
}
],
"responses": {
"202": {
"description": "UpdateNamespaceRulesResponse",
"schema": {
"$ref": "#/definitions/UpdateNamespaceRulesResponse"
}
},
"403": {
"description": "ForbiddenError",
"schema": {
"$ref": "#/definitions/ForbiddenError"
}
},
"404": {
"$ref": "#/responses/NotFound."
}
}
}
},
"/ruler/grafana/api/v1/rules/{Namespace}/export": {
@@ -8766,8 +8809,9 @@
}
},
"URL": {
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nThe Host field contains the host and port subcomponents of the URL.\nWhen the port is present, it is separated from the host with a colon.\nWhen the host is an IPv6 address, it must be enclosed in square brackets:\n\"[fe80::1]:80\". The [net.JoinHostPort] function combines a host and port\ninto a string suitable for the Host field, adding square brackets to\nthe host when necessary.\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the [URL.EscapedPath] method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.",
"type": "object",
"title": "URL is a custom URL type that allows validation at configuration load time.",
"title": "A URL represents a parsed URL (technically, a URI reference).",
"properties": {
"ForceQuery": {
"type": "boolean"
@@ -8804,6 +8848,22 @@
}
}
},
"UpdateNamespaceRulesRequest": {
"type": "object",
"properties": {
"is_paused": {
"type": "boolean"
}
}
},
"UpdateNamespaceRulesResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
}
},
"UpdateRuleGroupResponse": {
"type": "object",
"properties": {
+1 -2
View File
@@ -2,7 +2,6 @@ package fakes
import (
"context"
"fmt"
"math/rand"
"slices"
"sync"
@@ -270,7 +269,7 @@ func (f *RuleStore) GetNamespaceByUID(_ context.Context, uid string, orgID int64
return folder, nil
}
}
return nil, fmt.Errorf("not found")
return nil, dashboards.ErrFolderNotFound
}
func (f *RuleStore) GetOrCreateNamespaceByTitle(ctx context.Context, title string, orgID int64, user identity.Requester, parentUID string) (*folder.FolderReference, error) {
@@ -0,0 +1,107 @@
package alerting
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/util"
)
func TestIntegrationAlertRulePauseNamespace(t *testing.T) {
testinfra.SQLiteIntegrationTest(t)
// Setup Grafana and its Database
dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p)
// Create a user to make authenticated requests
createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{
DefaultOrgRole: string(org.RoleAdmin),
Password: "admin",
Login: "admin",
})
createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{
DefaultOrgRole: string(org.RoleViewer),
Password: "viewer",
Login: "viewer",
})
apiClient := newAlertingApiClient(grafanaListedAddr, "admin", "admin")
viewerClient := newAlertingApiClient(grafanaListedAddr, "viewer", "viewer")
// Create the folder we'll save our alerts to
folderUID := util.GenerateShortUID()
apiClient.CreateFolder(t, folderUID, "folder1")
// Create multiple rule groups in the folder
group1 := generateAlertRuleGroup(2, alertRuleGen())
apiClient.PostRulesGroup(t, folderUID, &group1, false)
group2 := generateAlertRuleGroup(3, alertRuleGen())
apiClient.PostRulesGroup(t, folderUID, &group2, false)
t.Run("pause all rules in namespace", func(t *testing.T) {
req := &apimodels.UpdateNamespaceRulesRequest{
IsPaused: util.Pointer(true),
}
response, status, _ := apiClient.UpdateNamespaceRules(t, folderUID, req)
require.Equal(t, http.StatusAccepted, status)
assert.Equal(t, "rules updated successfully", response.Message)
// Verify all rules are now paused
allRules, status, _ := apiClient.GetAllRulesWithStatus(t)
require.Equal(t, http.StatusOK, status)
for _, group := range allRules[folderUID] {
for _, rule := range group.Rules {
assert.True(t, rule.GrafanaManagedAlert.IsPaused, "Rule should be paused")
}
}
})
t.Run("unpause all rules in namespace", func(t *testing.T) {
req := &apimodels.UpdateNamespaceRulesRequest{
IsPaused: util.Pointer(false),
}
response, status, _ := apiClient.UpdateNamespaceRules(t, folderUID, req)
require.Equal(t, http.StatusAccepted, status)
assert.Equal(t, "rules updated successfully", response.Message)
// Verify all rules are now unpaused
allRules, status, _ := apiClient.GetAllRulesWithStatus(t)
require.Equal(t, http.StatusOK, status)
for _, group := range allRules[folderUID] {
for _, rule := range group.Rules {
assert.False(t, rule.GrafanaManagedAlert.IsPaused, "Rule should be unpaused")
}
}
})
t.Run("returns 403 for non-existent folder", func(t *testing.T) {
req := &apimodels.UpdateNamespaceRulesRequest{
IsPaused: util.Pointer(false),
}
_, status, _ := apiClient.UpdateNamespaceRules(t, "non-existent-folder", req)
require.Equal(t, http.StatusForbidden, status)
})
t.Run("viewer cannot pause rules", func(t *testing.T) {
req := &apimodels.UpdateNamespaceRulesRequest{
IsPaused: util.Pointer(false),
}
_, status, _ := viewerClient.UpdateNamespaceRules(t, folderUID, req)
require.Equal(t, http.StatusForbidden, status)
})
}
+32
View File
@@ -1280,6 +1280,38 @@ func (a apiClient) RawConvertPrometheusDeleteNamespace(t *testing.T, namespaceTi
return sendRequestJSON[apimodels.ConvertPrometheusResponse](t, req, http.StatusAccepted)
}
func (a apiClient) UpdateNamespaceRules(t *testing.T, folder string, body *apimodels.UpdateNamespaceRulesRequest) (apimodels.UpdateNamespaceRulesResponse, int, string) {
t.Helper()
client := &http.Client{}
buf := bytes.Buffer{}
enc := json.NewEncoder(&buf)
err := enc.Encode(body)
require.NoError(t, err)
u := fmt.Sprintf("%s/api/ruler/grafana/api/v1/rules/%s", a.url, folder)
req, err := http.NewRequest(http.MethodPatch, u, &buf)
req.Header.Set("Content-Type", "application/json")
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
resp, err := client.Do(req)
require.NoError(t, err)
defer func() {
_ = resp.Body.Close()
}()
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var m apimodels.UpdateNamespaceRulesResponse
if resp.StatusCode == http.StatusAccepted {
require.NoError(t, json.Unmarshal(b, &m))
}
return m, resp.StatusCode, string(b)
}
func sendRequestRaw(t *testing.T, req *http.Request) ([]byte, int, error) {
t.Helper()
client := &http.Client{}
+16
View File
@@ -22205,6 +22205,22 @@
}
}
},
"UpdateNamespaceRulesRequest": {
"type": "object",
"properties": {
"is_paused": {
"type": "boolean"
}
}
},
"UpdateNamespaceRulesResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
}
},
"UpdateOrgAddressForm": {
"type": "object",
"properties": {
+16
View File
@@ -12233,6 +12233,22 @@
},
"type": "object"
},
"UpdateNamespaceRulesRequest": {
"properties": {
"is_paused": {
"type": "boolean"
}
},
"type": "object"
},
"UpdateNamespaceRulesResponse": {
"properties": {
"message": {
"type": "string"
}
},
"type": "object"
},
"UpdateOrgAddressForm": {
"properties": {
"address1": {