Alerting: Hook up GMA silence APIs to new authentication handler (#86625)

This PR connects the new RBAC authentication service to existing alertmanager API silence endpoints.
This commit is contained in:
Matthew Jacobson
2024-05-03 15:32:30 -04:00
committed by GitHub
parent e9b932c8f6
commit babfa2beac
25 changed files with 1192 additions and 230 deletions

View File

@@ -5,15 +5,18 @@ import (
"math/rand"
"testing"
amv2 "github.com/prometheus/alertmanager/api/v2/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
alertingModels "github.com/grafana/alerting/models"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/utils"
"github.com/grafana/grafana/pkg/util"
)
var orgID = rand.Int63()
@@ -474,7 +477,16 @@ func TestAuthorizeUpdateSilence(t *testing.T) {
}
func testSilence(id string, ruleUID *string) *models.Silence {
return &models.Silence{ID: &id, RuleUID: ruleUID}
s := &models.Silence{ID: &id}
if ruleUID != nil {
s.Matchers = amv2.Matchers{{
IsEqual: util.Pointer(true),
IsRegex: util.Pointer(false),
Name: util.Pointer(alertingModels.RuleUIDLabel),
Value: ruleUID,
}}
}
return s
}
type fakeRuleUIDToNamespaceStore struct {

View File

@@ -92,7 +92,13 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
api.RegisterAlertmanagerApiEndpoints(NewForkingAM(
api.DatasourceCache,
NewLotexAM(proxy, logger),
&AlertmanagerSrv{crypto: api.MultiOrgAlertmanager.Crypto, log: logger, ac: api.AccessControl, mam: api.MultiOrgAlertmanager},
&AlertmanagerSrv{
crypto: api.MultiOrgAlertmanager.Crypto,
log: logger,
ac: api.AccessControl,
mam: api.MultiOrgAlertmanager,
silenceSvc: notifier.NewSilenceService(accesscontrol.NewSilenceService(api.AccessControl, api.RuleStore), api.TransactionManager, logger, api.MultiOrgAlertmanager),
},
), m)
// Register endpoints for proxying to Prometheus-compatible backends.
api.RegisterPrometheusApiEndpoints(NewForkingProm(

View File

@@ -28,10 +28,11 @@ const (
)
type AlertmanagerSrv struct {
log log.Logger
ac accesscontrol.AccessControl
mam *notifier.MultiOrgAlertmanager
crypto notifier.Crypto
log log.Logger
ac accesscontrol.AccessControl
mam *notifier.MultiOrgAlertmanager
crypto notifier.Crypto
silenceSvc SilenceService
}
type UnknownReceiverError struct {

View File

@@ -1,112 +1,71 @@
package api
import (
"errors"
"fmt"
"context"
"net/http"
"github.com/go-openapi/strfmt"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth/identity"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
authz "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/util"
)
// SilenceService is the service for managing and authenticating silences access in Grafana AM.
type SilenceService interface {
GetSilence(ctx context.Context, user identity.Requester, silenceID string) (*models.Silence, error)
ListSilences(ctx context.Context, user identity.Requester, filter []string) ([]*models.Silence, error)
CreateSilence(ctx context.Context, user identity.Requester, ps models.Silence) (string, error)
UpdateSilence(ctx context.Context, user identity.Requester, ps models.Silence) (string, error)
DeleteSilence(ctx context.Context, user identity.Requester, silenceID string) error
}
// RouteGetSilence is the single silence GET endpoint for Grafana AM.
func (srv AlertmanagerSrv) RouteGetSilence(c *contextmodel.ReqContext, silenceID string) response.Response {
am, errResp := srv.AlertmanagerFor(c.SignedInUser.GetOrgID())
if errResp != nil {
return errResp
}
gettableSilence, err := am.GetSilence(c.Req.Context(), silenceID)
silence, err := srv.silenceSvc.GetSilence(c.Req.Context(), c.SignedInUser, silenceID)
if err != nil {
if errors.Is(err, alertingNotify.ErrSilenceNotFound) {
return ErrResp(http.StatusNotFound, err, "")
}
// any other error here should be an unexpected failure and thus an internal error
return ErrResp(http.StatusInternalServerError, err, "")
return response.ErrOrFallback(http.StatusInternalServerError, "failed to get silence", err)
}
return response.JSON(http.StatusOK, gettableSilence)
return response.JSON(http.StatusOK, SilenceToGettableSilence(*silence))
}
// RouteGetSilences is the silence list GET endpoint for Grafana AM.
func (srv AlertmanagerSrv) RouteGetSilences(c *contextmodel.ReqContext) response.Response {
am, errResp := srv.AlertmanagerFor(c.SignedInUser.GetOrgID())
if errResp != nil {
return errResp
}
gettableSilences, err := am.ListSilences(c.Req.Context(), c.QueryStrings("filter"))
silences, err := srv.silenceSvc.ListSilences(c.Req.Context(), c.SignedInUser, c.QueryStrings("filter"))
if err != nil {
if errors.Is(err, alertingNotify.ErrListSilencesBadPayload) {
return ErrResp(http.StatusBadRequest, err, "")
}
// any other error here should be an unexpected failure and thus an internal error
return ErrResp(http.StatusInternalServerError, err, "")
return response.ErrOrFallback(http.StatusInternalServerError, "failed to list silence", err)
}
return response.JSON(http.StatusOK, gettableSilences)
return response.JSON(http.StatusOK, SilencesToGettableSilences(silences))
}
// RouteCreateSilence is the silence POST (create + update) endpoint for Grafana AM.
func (srv AlertmanagerSrv) RouteCreateSilence(c *contextmodel.ReqContext, postableSilence apimodels.PostableSilence) response.Response {
err := postableSilence.Validate(strfmt.Default)
if err != nil {
srv.log.Error("Silence failed validation", "error", err)
return ErrResp(http.StatusBadRequest, err, "silence failed validation")
}
action := accesscontrol.ActionAlertingInstanceUpdate
action := srv.silenceSvc.UpdateSilence
if postableSilence.ID == "" {
action = accesscontrol.ActionAlertingInstanceCreate
action = srv.silenceSvc.CreateSilence
}
evaluator := accesscontrol.EvalPermission(action)
if !accesscontrol.HasAccess(srv.ac, c)(evaluator) {
errAction := "update"
if postableSilence.ID == "" {
errAction = "create"
}
return response.Err(authz.NewAuthorizationErrorWithPermissions(fmt.Sprintf("%s silences", errAction), evaluator))
}
silenceID, err := srv.mam.CreateSilence(c.Req.Context(), c.SignedInUser.GetOrgID(), &postableSilence)
silenceID, err := action(c.Req.Context(), c.SignedInUser, PostableSilenceToSilence(postableSilence))
if err != nil {
if errors.Is(err, notifier.ErrNoAlertmanagerForOrg) {
return ErrResp(http.StatusNotFound, err, "")
}
if errors.Is(err, notifier.ErrAlertmanagerNotReady) {
return ErrResp(http.StatusConflict, err, "")
}
if errors.Is(err, alertingNotify.ErrSilenceNotFound) {
return ErrResp(http.StatusNotFound, err, "")
}
if errors.Is(err, alertingNotify.ErrCreateSilenceBadPayload) {
return ErrResp(http.StatusBadRequest, err, "")
}
return ErrResp(http.StatusInternalServerError, err, "failed to create silence")
return response.ErrOrFallback(http.StatusInternalServerError, "failed to create/update silence", err)
}
return response.JSON(http.StatusAccepted, apimodels.PostSilencesOKBody{
SilenceID: silenceID,
})
}
// RouteDeleteSilence is the silence DELETE endpoint for Grafana AM.
func (srv AlertmanagerSrv) RouteDeleteSilence(c *contextmodel.ReqContext, silenceID string) response.Response {
if err := srv.mam.DeleteSilence(c.Req.Context(), c.SignedInUser.GetOrgID(), silenceID); err != nil {
if errors.Is(err, notifier.ErrNoAlertmanagerForOrg) {
return ErrResp(http.StatusNotFound, err, "")
}
if errors.Is(err, notifier.ErrAlertmanagerNotReady) {
return ErrResp(http.StatusConflict, err, "")
}
if errors.Is(err, alertingNotify.ErrSilenceNotFound) {
return ErrResp(http.StatusNotFound, err, "")
}
return ErrResp(http.StatusInternalServerError, err, "")
if err := srv.silenceSvc.DeleteSilence(c.Req.Context(), c.SignedInUser, silenceID); err != nil {
return response.ErrOrFallback(http.StatusInternalServerError, "failed to delete silence", err)
}
return response.JSON(http.StatusOK, util.DynMap{"message": "silence deleted"})
}

View File

@@ -2,7 +2,6 @@ package api
import (
"context"
"math/rand"
"net/http"
"testing"
"time"
@@ -13,10 +12,10 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
"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/web"
)
@@ -67,7 +66,10 @@ func TestSilenceCreate(t *testing.T) {
OrgRole: org.RoleEditor,
OrgID: 1,
Permissions: map[int64]map[string][]string{
1: {accesscontrol.ActionAlertingInstanceCreate: {}},
1: {
accesscontrol.ActionAlertingInstanceRead: {},
accesscontrol.ActionAlertingInstanceCreate: {},
},
},
},
}
@@ -86,13 +88,13 @@ func TestSilenceCreate(t *testing.T) {
func TestRouteCreateSilence(t *testing.T) {
tesCases := []struct {
name string
silence func() apimodels.PostableSilence
silence func() ngmodels.Silence
permissions map[int64]map[string][]string
expectedStatus int
}{
{
name: "new silence, role-based access control is enabled, not authorized",
silence: silenceGen(withEmptyID),
silence: ngmodels.SilenceGen(ngmodels.SilenceMuts.WithEmptyId()),
permissions: map[int64]map[string][]string{
1: {},
},
@@ -100,15 +102,18 @@ func TestRouteCreateSilence(t *testing.T) {
},
{
name: "new silence, role-based access control is enabled, authorized",
silence: silenceGen(withEmptyID),
silence: ngmodels.SilenceGen(ngmodels.SilenceMuts.WithEmptyId()),
permissions: map[int64]map[string][]string{
1: {accesscontrol.ActionAlertingInstanceCreate: {}},
1: {
accesscontrol.ActionAlertingInstanceRead: {},
accesscontrol.ActionAlertingInstanceCreate: {},
},
},
expectedStatus: http.StatusAccepted,
},
{
name: "update silence, role-based access control is enabled, not authorized",
silence: silenceGen(),
silence: ngmodels.SilenceGen(),
permissions: map[int64]map[string][]string{
1: {accesscontrol.ActionAlertingInstanceCreate: {}},
},
@@ -116,9 +121,12 @@ func TestRouteCreateSilence(t *testing.T) {
},
{
name: "update silence, role-based access control is enabled, authorized",
silence: silenceGen(),
silence: ngmodels.SilenceGen(),
permissions: map[int64]map[string][]string{
1: {accesscontrol.ActionAlertingInstanceUpdate: {}},
1: {
accesscontrol.ActionAlertingInstanceRead: {},
accesscontrol.ActionAlertingInstanceUpdate: {},
},
},
expectedStatus: http.StatusAccepted,
},
@@ -138,57 +146,19 @@ func TestRouteCreateSilence(t *testing.T) {
},
}
silence := tesCase.silence()
silence := notifier.SilenceToPostableSilence(tesCase.silence())
if silence.ID != "" {
alertmanagerFor, err := sut.mam.AlertmanagerFor(1)
require.NoError(t, err)
silence.ID = ""
newID, err := alertmanagerFor.CreateSilence(context.Background(), &silence)
newID, err := alertmanagerFor.CreateSilence(context.Background(), silence)
require.NoError(t, err)
silence.ID = newID
}
response := sut.RouteCreateSilence(&rc, silence)
response := sut.RouteCreateSilence(&rc, *silence)
require.Equal(t, tesCase.expectedStatus, response.Status())
})
}
}
func silenceGen(mutatorFuncs ...func(*apimodels.PostableSilence)) func() apimodels.PostableSilence {
return func() apimodels.PostableSilence {
testString := util.GenerateShortUID()
isEqual := rand.Int()%2 == 0
isRegex := rand.Int()%2 == 0
value := util.GenerateShortUID()
if isRegex {
value = ".*" + util.GenerateShortUID()
}
matchers := amv2.Matchers{&amv2.Matcher{Name: &testString, IsEqual: &isEqual, IsRegex: &isRegex, Value: &value}}
comment := util.GenerateShortUID()
starts := strfmt.DateTime(timeNow().Add(-time.Duration(rand.Int63n(9)+1) * time.Second))
ends := strfmt.DateTime(timeNow().Add(time.Duration(rand.Int63n(9)+1) * time.Second))
createdBy := "User-" + util.GenerateShortUID()
s := apimodels.PostableSilence{
ID: util.GenerateShortUID(),
Silence: amv2.Silence{
Comment: &comment,
CreatedBy: &createdBy,
EndsAt: &ends,
Matchers: matchers,
StartsAt: &starts,
},
}
for _, mutator := range mutatorFuncs {
mutator(&s)
}
return s
}
}
func withEmptyID(silence *apimodels.PostableSilence) {
silence.ID = ""
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/stretchr/testify/require"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/infra/log"
@@ -630,11 +631,14 @@ func createSut(t *testing.T) AlertmanagerSrv {
}
mam := createMultiOrgAlertmanager(t, configs)
log := log.NewNopLogger()
ac := acimpl.ProvideAccessControl(setting.NewCfg())
ruleStore := ngfakes.NewRuleStore(t)
return AlertmanagerSrv{
mam: mam,
crypto: mam.Crypto,
ac: acimpl.ProvideAccessControl(setting.NewCfg()),
log: log,
mam: mam,
crypto: mam.Crypto,
ac: ac,
log: log,
silenceSvc: notifier.NewSilenceService(accesscontrol.NewSilenceService(ac, ruleStore), ruleStore, log, mam),
}
}

View File

@@ -124,16 +124,42 @@ func (api *API) authorize(method, path string) web.Handler {
// Alert Instances and Silences
// Silences. Grafana Paths
case http.MethodDelete + "/api/alertmanager/grafana/api/v2/silence/{SilenceId}":
eval = ac.EvalPermission(ac.ActionAlertingInstanceUpdate) // delete endpoint actually expires silence
// Silences for Grafana paths.
// These permissions are required but not sufficient, further authorization is done in the request handler.
case http.MethodDelete + "/api/alertmanager/grafana/api/v2/silence/{SilenceId}": // Delete endpoint is used for silence expiration.
eval = ac.EvalAll(
ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingInstanceRead),
ac.EvalPermission(ac.ActionAlertingSilencesRead),
),
ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingInstanceUpdate),
ac.EvalPermission(ac.ActionAlertingSilencesWrite),
),
)
case http.MethodGet + "/api/alertmanager/grafana/api/v2/silence/{SilenceId}":
eval = ac.EvalPermission(ac.ActionAlertingInstanceRead)
eval = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingInstanceRead),
ac.EvalPermission(ac.ActionAlertingSilencesRead),
)
case http.MethodGet + "/api/alertmanager/grafana/api/v2/silences":
eval = ac.EvalPermission(ac.ActionAlertingInstanceRead)
eval = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingInstanceRead),
ac.EvalPermission(ac.ActionAlertingSilencesRead),
)
case http.MethodPost + "/api/alertmanager/grafana/api/v2/silences":
// additional authorization is done in the request handler
eval = ac.EvalAny(ac.EvalPermission(ac.ActionAlertingInstanceCreate), ac.EvalPermission(ac.ActionAlertingInstanceUpdate))
eval = ac.EvalAll(
ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingInstanceRead),
ac.EvalPermission(ac.ActionAlertingSilencesRead),
),
ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingInstanceCreate),
ac.EvalPermission(ac.ActionAlertingInstanceUpdate),
ac.EvalPermission(ac.ActionAlertingSilencesCreate),
ac.EvalPermission(ac.ActionAlertingSilencesWrite),
),
)
// Alert Instances. Grafana Paths
case http.MethodGet + "/api/alertmanager/grafana/api/v2/alerts/groups":

View File

@@ -0,0 +1,30 @@
package api
import (
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
// Silence-specific compat functions to convert between API and model types.
func SilenceToGettableSilence(s models.Silence) definitions.GettableSilence {
return definitions.GettableSilence(s)
}
func SilencesToGettableSilences(silences []*models.Silence) definitions.GettableSilences {
res := make(definitions.GettableSilences, 0, len(silences))
for _, sil := range silences {
apiSil := SilenceToGettableSilence(*sil)
res = append(res, &apiSil)
}
return res
}
func PostableSilenceToSilence(s definitions.PostableSilence) models.Silence {
return models.Silence{
ID: &s.ID,
Status: nil,
UpdatedAt: nil,
Silence: s.Silence,
}
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
)
@@ -26,4 +27,6 @@ type RuleStore interface {
// IncreaseVersionForAllRulesInNamespace Increases version for all rules that have specified namespace. Returns all rules that belong to the namespace
IncreaseVersionForAllRulesInNamespace(ctx context.Context, orgID int64, namespaceUID string) ([]ngmodels.AlertRuleKeyWithVersion, error)
accesscontrol.RuleUIDToNamespaceStore
}

View File

@@ -1,11 +1,37 @@
package models
// Silence is the model-layer representation of an alertmanager silence.
type Silence struct { // TODO implement using matchers
ID *string
RuleUID *string
import (
amv2 "github.com/prometheus/alertmanager/api/v2/models"
alertingModels "github.com/grafana/alerting/models"
"github.com/grafana/alerting/notify"
)
// Silence is the model-layer representation of an alertmanager silence. Currently just a wrapper around the
// alerting notify.Silence.
type Silence notify.GettableSilence
// GetRuleUID returns the rule UID of the silence if the silence is associated with a rule, otherwise nil.
// Currently, this works by looking for a matcher with the RuleUIDLabel name and returning its value.
func (s Silence) GetRuleUID() *string {
return getRuleUIDLabelValue(s.Silence)
}
func (s *Silence) GetRuleUID() *string {
return s.RuleUID
// getRuleUIDLabelValue returns the value of the RuleUIDLabel matcher in the given silence, if it exists.
func getRuleUIDLabelValue(silence notify.Silence) *string {
for _, m := range silence.Matchers {
if m != nil && isRuleUIDMatcher(*m) {
return m.Value
}
}
return nil
}
func isRuleUIDMatcher(m amv2.Matcher) bool {
return isEqualMatcher(m) && m.Name != nil && *m.Name == alertingModels.RuleUIDLabel
}
func isEqualMatcher(m amv2.Matcher) bool {
// If IsEqual is nil, it is considered to be true.
return (m.IsEqual == nil || *m.IsEqual) && (m.IsRegex == nil || !*m.IsRegex)
}

View File

@@ -0,0 +1,50 @@
package models
import (
"testing"
"github.com/prometheus/alertmanager/pkg/labels"
"github.com/stretchr/testify/assert"
"github.com/grafana/alerting/models"
"github.com/grafana/grafana/pkg/util"
)
func TestSilenceGetRuleUID(t *testing.T) {
testCases := []struct {
name string
silence Silence
expectedRuleUID *string
}{
{
name: "silence with no rule UID",
silence: SilenceGen()(),
expectedRuleUID: nil,
},
{
name: "silence with rule UID",
silence: SilenceGen(SilenceMuts.WithMatcher(models.RuleUIDLabel, "someuid", labels.MatchEqual))(),
expectedRuleUID: util.Pointer("someuid"),
},
{
name: "silence with rule UID Matcher but MatchNotEqual",
silence: SilenceGen(SilenceMuts.WithMatcher(models.RuleUIDLabel, "someuid", labels.MatchNotEqual))(),
expectedRuleUID: nil,
},
{
name: "silence with rule UID Matcher but MatchRegexp",
silence: SilenceGen(SilenceMuts.WithMatcher(models.RuleUIDLabel, "someuid", labels.MatchRegexp))(),
expectedRuleUID: nil,
},
{
name: "silence with rule UID Matcher but MatchNotRegexp",
silence: SilenceGen(SilenceMuts.WithMatcher(models.RuleUIDLabel, "someuid", labels.MatchNotRegexp))(),
expectedRuleUID: nil,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expectedRuleUID, tt.silence.GetRuleUID(), "unexpected rule UID")
})
}
}

View File

@@ -9,7 +9,10 @@ import (
"testing"
"time"
"github.com/go-openapi/strfmt"
"github.com/grafana/grafana-plugin-sdk-go/data"
amv2 "github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/alertmanager/pkg/labels"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
@@ -903,3 +906,117 @@ func (n NotificationSettingsMutators) WithMuteTimeIntervals(muteTimeIntervals ..
ns.MuteTimeIntervals = muteTimeIntervals
}
}
// Silences
// CopySilenceWith creates a deep copy of Silence and then applies mutators to it.
func CopySilenceWith(s Silence, mutators ...Mutator[Silence]) Silence {
c := CopySilence(s)
for _, mutator := range mutators {
mutator(&c)
}
return c
}
// CopySilence creates a deep copy of Silence.
func CopySilence(s Silence) Silence {
c := Silence{
Silence: amv2.Silence{},
}
if s.ID != nil {
c.ID = util.Pointer(*s.ID)
}
if s.Status != nil {
c.Status = util.Pointer(*s.Status)
}
if s.UpdatedAt != nil {
c.UpdatedAt = util.Pointer(*s.UpdatedAt)
}
if s.Silence.Comment != nil {
c.Silence.Comment = util.Pointer(*s.Silence.Comment)
}
if s.Silence.CreatedBy != nil {
c.Silence.CreatedBy = util.Pointer(*s.Silence.CreatedBy)
}
if s.Silence.EndsAt != nil {
c.Silence.EndsAt = util.Pointer(*s.Silence.EndsAt)
}
if s.Silence.StartsAt != nil {
c.Silence.StartsAt = util.Pointer(*s.Silence.StartsAt)
}
if s.Silence.Matchers != nil {
c.Silence.Matchers = CopyMatchers(s.Silence.Matchers)
}
return c
}
// CopyMatchers creates a deep copy of Matchers.
func CopyMatchers(matchers []*amv2.Matcher) []*amv2.Matcher {
copies := make([]*amv2.Matcher, len(matchers))
for i, m := range matchers {
c := amv2.Matcher{}
if m.IsEqual != nil {
c.IsEqual = util.Pointer(*m.IsEqual)
}
if m.IsRegex != nil {
c.IsRegex = util.Pointer(*m.IsRegex)
}
if m.Name != nil {
c.Name = util.Pointer(*m.Name)
}
if m.Value != nil {
c.Value = util.Pointer(*m.Value)
}
copies[i] = &c
}
return copies
}
// SilenceGen generates Silence using a base and mutators.
func SilenceGen(mutators ...Mutator[Silence]) func() Silence {
return func() Silence {
now := time.Now()
c := Silence{
ID: util.Pointer(util.GenerateShortUID()),
Status: util.Pointer(amv2.SilenceStatus{State: util.Pointer(amv2.SilenceStatusStateActive)}),
UpdatedAt: util.Pointer(strfmt.DateTime(now.Add(time.Minute))),
Silence: amv2.Silence{
Comment: util.Pointer(util.GenerateShortUID()),
CreatedBy: util.Pointer(util.GenerateShortUID()),
StartsAt: util.Pointer(strfmt.DateTime(now.Add(-time.Minute))),
EndsAt: util.Pointer(strfmt.DateTime(now.Add(time.Minute))),
Matchers: []*amv2.Matcher{{Name: util.Pointer(util.GenerateShortUID()), Value: util.Pointer(util.GenerateShortUID()), IsRegex: util.Pointer(false), IsEqual: util.Pointer(true)}},
},
}
for _, mutator := range mutators {
mutator(&c)
}
return c
}
}
var (
SilenceMuts = SilenceMutators{}
)
type SilenceMutators struct{}
func (n SilenceMutators) WithMatcher(name, value string, matchType labels.MatchType) Mutator[Silence] {
return func(s *Silence) {
m := amv2.Matcher{
Name: &name,
Value: &value,
IsRegex: util.Pointer(matchType == labels.MatchRegexp || matchType == labels.MatchNotRegexp),
IsEqual: util.Pointer(matchType == labels.MatchRegexp || matchType == labels.MatchEqual),
}
s.Silence.Matchers = append(s.Silence.Matchers, &m)
}
}
func (n SilenceMutators) WithEmptyId() Mutator[Silence] {
return func(s *Silence) {
s.ID = util.Pointer("")
}
}

View File

@@ -3,9 +3,10 @@ package notifier
import (
"encoding/json"
"github.com/prometheus/alertmanager/config"
alertingNotify "github.com/grafana/alerting/notify"
alertingTemplates "github.com/grafana/alerting/templates"
"github.com/prometheus/alertmanager/config"
"github.com/grafana/grafana/pkg/components/simplejson"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
@@ -122,3 +123,29 @@ func ToTemplateDefinitions(cfg *apimodels.PostableUserConfig) []alertingTemplate
}
return out
}
// Silence-specific compat functions to convert between grafana/alerting and model types.
func GettableSilenceToSilence(s alertingNotify.GettableSilence) *models.Silence {
sil := models.Silence(s)
return &sil
}
func GettableSilencesToSilences(silences alertingNotify.GettableSilences) []*models.Silence {
res := make([]*models.Silence, 0, len(silences))
for _, sil := range silences {
res = append(res, GettableSilenceToSilence(*sil))
}
return res
}
func SilenceToPostableSilence(s models.Silence) *alertingNotify.PostableSilence {
var id string
if s.ID != nil {
id = *s.ID
}
return &alertingNotify.PostableSilence{
ID: id,
Silence: s.Silence,
}
}

View File

@@ -0,0 +1,9 @@
package notifier
import "github.com/grafana/grafana/pkg/util/errutil"
// WithPublicError sets the public message of an errutil error to the error message.
func WithPublicError(err errutil.Error) error {
err.PublicMessage = err.Error()
return err
}

View File

@@ -2,6 +2,7 @@ package notifier
import (
"context"
"errors"
"fmt"
"sync"
"time"
@@ -9,6 +10,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
alertingCluster "github.com/grafana/alerting/cluster"
"github.com/grafana/grafana/pkg/util/errutil"
alertingNotify "github.com/grafana/alerting/notify"
@@ -29,6 +31,17 @@ var (
ErrAlertmanagerNotReady = fmt.Errorf("Alertmanager is not ready yet")
)
// errutil-based errors.
// TODO: Should completely replace the fmt.Errorf-based errors.
var (
ErrAlertmanagerNotFound = errutil.NotFound("alerting.notifications.alertmanager.notFound")
ErrAlertmanagerConflict = errutil.Conflict("alerting.notifications.alertmanager.conflict")
ErrSilenceNotFound = errutil.NotFound("alerting.notifications.silences.notFound")
ErrSilencesBadRequest = errutil.BadRequest("alerting.notifications.silences.badRequest")
ErrSilenceInternal = errutil.Internal("alerting.notifications.silences.internal")
)
//go:generate mockery --name Alertmanager --structname AlertmanagerMock --with-expecter --output alertmanager_mock --outpkg alertmanager_mock
type Alertmanager interface {
// Configuration
@@ -400,25 +413,88 @@ func (moa *MultiOrgAlertmanager) AlertmanagerFor(orgID int64) (Alertmanager, err
return orgAM, nil
}
// CreateSilence creates a silence in the Alertmanager for the organization provided, returning the silence ID. It will
// also persist the silence state to the kvstore immediately after creating the silence.
func (moa *MultiOrgAlertmanager) CreateSilence(ctx context.Context, orgID int64, ps *alertingNotify.PostableSilence) (string, error) {
moa.alertmanagersMtx.RLock()
defer moa.alertmanagersMtx.RUnlock()
// alertmanagerForOrg returns the Alertmanager instance for the organization provided. Should only be called when the
// caller has already locked the alertmanagersMtx.
// TODO: This should eventually replace AlertmanagerFor once the API layer has been refactored to not access the alertmanagers directly
// and AM route error handling has been fully moved to errorutil.
func (moa *MultiOrgAlertmanager) alertmanagerForOrg(orgID int64) (Alertmanager, error) {
orgAM, existing := moa.alertmanagers[orgID]
if !existing {
return "", ErrNoAlertmanagerForOrg
return nil, WithPublicError(ErrAlertmanagerNotFound.Errorf("Alertmanager does not exist for org %d", orgID))
}
if !orgAM.Ready() {
return "", ErrAlertmanagerNotReady
return nil, WithPublicError(ErrAlertmanagerConflict.Errorf("Alertmanager is not ready for org %d", orgID))
}
return orgAM, nil
}
// ListSilences lists silences for the organization provided. Currently, this is a pass-through to the Alertmanager
// implementation.
func (moa *MultiOrgAlertmanager) ListSilences(ctx context.Context, orgID int64, filter []string) ([]*models.Silence, error) {
moa.alertmanagersMtx.RLock()
defer moa.alertmanagersMtx.RUnlock()
orgAM, err := moa.alertmanagerForOrg(orgID)
if err != nil {
return nil, err
}
silences, err := orgAM.ListSilences(ctx, filter)
if err != nil {
if errors.Is(err, alertingNotify.ErrListSilencesBadPayload) {
return nil, WithPublicError(ErrSilencesBadRequest.Errorf("invalid filters: %w", err))
}
return nil, WithPublicError(ErrSilenceInternal.Errorf("failed to list silences: %w", err))
}
return GettableSilencesToSilences(silences), nil
}
// GetSilence gets a silence for the organization and silence id provided. Currently, this is a pass-through to the
// Alertmanager implementation.
func (moa *MultiOrgAlertmanager) GetSilence(ctx context.Context, orgID int64, id string) (*models.Silence, error) {
moa.alertmanagersMtx.RLock()
defer moa.alertmanagersMtx.RUnlock()
orgAM, err := moa.alertmanagerForOrg(orgID)
if err != nil {
return nil, err
}
s, err := orgAM.GetSilence(ctx, id)
if err != nil {
if errors.Is(err, alertingNotify.ErrSilenceNotFound) {
return nil, WithPublicError(ErrSilenceNotFound.Errorf("silence %s not found", id))
}
return nil, WithPublicError(ErrSilenceInternal.Errorf("failed to get silence: %w", err))
}
return GettableSilenceToSilence(s), nil
}
// CreateSilence creates a silence in the Alertmanager for the organization provided, returning the silence ID. It will
// also persist the silence state to the kvstore immediately after creating the silence.
func (moa *MultiOrgAlertmanager) CreateSilence(ctx context.Context, orgID int64, ps models.Silence) (string, error) {
moa.alertmanagersMtx.RLock()
defer moa.alertmanagersMtx.RUnlock()
orgAM, err := moa.alertmanagerForOrg(orgID)
if err != nil {
return "", err
}
// Need to create the silence in the AM first to get the silence ID.
silenceID, err := orgAM.CreateSilence(ctx, ps)
silenceID, err := orgAM.CreateSilence(ctx, SilenceToPostableSilence(ps))
if err != nil {
return "", err
if errors.Is(err, alertingNotify.ErrSilenceNotFound) {
return "", WithPublicError(ErrSilenceNotFound.Errorf("silence %v not found", ps.ID))
}
if errors.Is(err, alertingNotify.ErrCreateSilenceBadPayload) {
return "", WithPublicError(ErrSilencesBadRequest.Errorf("invalid silence: %w", err))
}
return "", WithPublicError(ErrSilenceInternal.Errorf("failed to upsert silence: %w", err))
}
err = moa.updateSilenceState(ctx, orgAM, orgID)
@@ -429,26 +505,35 @@ func (moa *MultiOrgAlertmanager) CreateSilence(ctx context.Context, orgID int64,
return silenceID, nil
}
// UpdateSilence updates a silence in the Alertmanager for the organization provided, returning the silence ID. It will
// also persist the silence state to the kvstore immediately after creating the silence.
// Currently, this just calls CreateSilence as the underlying Alertmanager implementation upserts.
func (moa *MultiOrgAlertmanager) UpdateSilence(ctx context.Context, orgID int64, ps models.Silence) (string, error) {
if ps.ID == nil || *ps.ID == "" { // TODO: Alertmanager interface should probably include a method for updating silences. For now, we leak this implementation detail.
return "", WithPublicError(ErrSilencesBadRequest.Errorf("silence ID is required"))
}
return moa.CreateSilence(ctx, orgID, ps)
}
// DeleteSilence deletes a silence in the Alertmanager for the organization provided. It will also persist the silence
// state to the kvstore immediately after deleting the silence.
func (moa *MultiOrgAlertmanager) DeleteSilence(ctx context.Context, orgID int64, silenceID string) error {
moa.alertmanagersMtx.RLock()
defer moa.alertmanagersMtx.RUnlock()
orgAM, existing := moa.alertmanagers[orgID]
if !existing {
return ErrNoAlertmanagerForOrg
}
if !orgAM.Ready() {
return ErrAlertmanagerNotReady
}
err := orgAM.DeleteSilence(ctx, silenceID)
orgAM, err := moa.alertmanagerForOrg(orgID)
if err != nil {
return err
}
err = orgAM.DeleteSilence(ctx, silenceID)
if err != nil {
if errors.Is(err, alertingNotify.ErrSilenceNotFound) {
return WithPublicError(ErrSilenceNotFound.Errorf("silence %s not found", silenceID))
}
return WithPublicError(ErrSilenceInternal.Errorf("failed to delete silence %s: %w", silenceID, err))
}
err = moa.updateSilenceState(ctx, orgAM, orgID)
if err != nil {
moa.logger.Warn("Failed to persist silence state on delete, will be corrected by next maintenance run", "orgID", orgID, "silenceID", silenceID, "error", err)

View File

@@ -6,12 +6,13 @@ import (
"testing"
"time"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stretchr/testify/require"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
@@ -207,13 +208,13 @@ func TestMultiOrgAlertmanager_AlertmanagerFor(t *testing.T) {
// First, let's try to request an Alertmanager from an org that doesn't exist.
{
_, err := mam.AlertmanagerFor(5)
require.EqualError(t, err, ErrNoAlertmanagerForOrg.Error())
_, err := mam.alertmanagerForOrg(5)
require.ErrorIs(t, err, ErrAlertmanagerNotFound)
}
// With an Alertmanager that exists, it responds correctly.
{
am, err := mam.AlertmanagerFor(2)
am, err := mam.alertmanagerForOrg(2)
require.NoError(t, err)
internalAm, ok := am.(*alertmanager)
require.True(t, ok)
@@ -227,8 +228,8 @@ func TestMultiOrgAlertmanager_AlertmanagerFor(t *testing.T) {
mam.orgStore.(*FakeOrgStore).orgs = []int64{1, 3}
require.NoError(t, mam.LoadAndSyncAlertmanagersForOrgs(ctx))
{
_, err := mam.AlertmanagerFor(2)
require.EqualError(t, err, ErrNoAlertmanagerForOrg.Error())
_, err := mam.alertmanagerForOrg(2)
require.ErrorIs(t, err, ErrAlertmanagerNotFound)
}
}
@@ -253,7 +254,7 @@ func TestMultiOrgAlertmanager_ActivateHistoricalConfiguration(t *testing.T) {
// Now let's save a new config for org 2.
newConfig := `{"template_files":null,"alertmanager_config":{"route":{"receiver":"grafana-default-email","group_by":["grafana_folder","alertname"]},"templates":null,"receivers":[{"name":"grafana-default-email","grafana_managed_receiver_configs":[{"uid":"","name":"some other name","type":"email","disableResolveMessage":false,"settings":{"addresses":"\u003cexample@email.com\u003e"},"secureSettings":null}]}]}}`
am, err := mam.AlertmanagerFor(2)
am, err := mam.alertmanagerForOrg(2)
require.NoError(t, err)
postable, err := Load([]byte(newConfig))
@@ -295,7 +296,7 @@ func TestMultiOrgAlertmanager_Silences(t *testing.T) {
require.Len(t, mam.alertmanagers, 3)
}
am, err := mam.AlertmanagerFor(1)
am, err := mam.alertmanagerForOrg(1)
require.NoError(t, err)
// Confirm no silences.
@@ -315,10 +316,11 @@ func TestMultiOrgAlertmanager_Silences(t *testing.T) {
require.Empty(t, v)
// Create 2 silences.
sid, err := mam.CreateSilence(ctx, 1, GenSilence("test"))
gen := models.SilenceGen(models.SilenceMuts.WithEmptyId())
sid, err := mam.CreateSilence(ctx, 1, gen())
require.NoError(t, err)
require.NotEmpty(t, sid)
sid2, err := mam.CreateSilence(ctx, 1, GenSilence("test"))
sid2, err := mam.CreateSilence(ctx, 1, gen())
require.NoError(t, err)
require.NotEmpty(t, sid2)

View File

@@ -0,0 +1,127 @@
package notifier
import (
"context"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
// SilenceService is the authenticated service for managing alertmanager silences.
type SilenceService struct {
authz SilenceAccessControlService
xact transactionManager
log log.Logger
store SilenceStore
}
// SilenceAccessControlService provides access control for silences.
type SilenceAccessControlService interface {
FilterByAccess(ctx context.Context, user identity.Requester, silences ...*models.Silence) ([]*models.Silence, error)
AuthorizeReadSilence(ctx context.Context, user identity.Requester, silence *models.Silence) error
AuthorizeCreateSilence(ctx context.Context, user identity.Requester, silence *models.Silence) error
AuthorizeUpdateSilence(ctx context.Context, user identity.Requester, silence *models.Silence) error
}
// SilenceStore is the interface for storing and retrieving silences. Currently, this is implemented by
// MultiOrgAlertmanager but should eventually be replaced with an actual store.
type SilenceStore interface {
ListSilences(ctx context.Context, orgID int64, filter []string) ([]*models.Silence, error)
GetSilence(ctx context.Context, orgID int64, id string) (*models.Silence, error)
CreateSilence(ctx context.Context, orgID int64, ps models.Silence) (string, error)
UpdateSilence(ctx context.Context, orgID int64, ps models.Silence) (string, error)
DeleteSilence(ctx context.Context, orgID int64, id string) error
}
func NewSilenceService(
authz SilenceAccessControlService,
xact transactionManager,
log log.Logger,
store SilenceStore,
) *SilenceService {
return &SilenceService{
authz: authz,
xact: xact,
log: log,
store: store,
}
}
// GetSilence retrieves a silence by its ID.
func (s *SilenceService) GetSilence(ctx context.Context, user identity.Requester, silenceID string) (*models.Silence, error) {
gettableSilence, err := s.store.GetSilence(ctx, user.GetOrgID(), silenceID)
if err != nil {
return nil, err
}
if err := s.authz.AuthorizeReadSilence(ctx, user, gettableSilence); err != nil {
return nil, err
}
return gettableSilence, nil
}
// ListSilences retrieves all silences that match the given filter. This will include all rule-specific silences that
// the user has access to as well as all general silences.
func (s *SilenceService) ListSilences(ctx context.Context, user identity.Requester, filter []string) ([]*models.Silence, error) {
gettableSilences, err := s.store.ListSilences(ctx, user.GetOrgID(), filter)
if err != nil {
return nil, err
}
return s.authz.FilterByAccess(ctx, user, gettableSilences...)
}
// CreateSilence creates a new silence.
// For rule-specific silences, the user needs permission to create silences in the folder that the associated rule is in.
// For general silences, the user needs broader permissions.
func (s *SilenceService) CreateSilence(ctx context.Context, user identity.Requester, ps models.Silence) (string, error) {
if err := s.authz.AuthorizeCreateSilence(ctx, user, &ps); err != nil {
return "", err
}
silenceId, err := s.store.CreateSilence(ctx, user.GetOrgID(), ps)
if err != nil {
return "", err
}
return silenceId, nil
}
// UpdateSilence updates an existing silence.
// For rule-specific silences, the user needs permission to update silences in the folder that the associated rule is in.
// For general silences, the user needs broader permissions.
func (s *SilenceService) UpdateSilence(ctx context.Context, user identity.Requester, ps models.Silence) (string, error) {
if err := s.authz.AuthorizeUpdateSilence(ctx, user, &ps); err != nil {
return "", err
}
silenceId, err := s.store.UpdateSilence(ctx, user.GetOrgID(), ps)
if err != nil {
return "", err
}
return silenceId, nil
}
// DeleteSilence deletes a silence by its ID.
// For rule-specific silences, the user needs permission to update silences in the folder that the associated rule is in.
// For general silences, the user needs broader permissions.
func (s *SilenceService) DeleteSilence(ctx context.Context, user identity.Requester, silenceID string) error {
silence, err := s.GetSilence(ctx, user, silenceID)
if err != nil {
return err
}
if err := s.authz.AuthorizeUpdateSilence(ctx, user, silence); err != nil {
return err
}
err = s.store.DeleteSilence(ctx, user.GetOrgID(), silenceID)
if err != nil {
return err
}
return nil
}

View File

@@ -7,18 +7,14 @@ import (
"errors"
"fmt"
"io"
"math/rand"
"testing"
"time"
"github.com/go-openapi/strfmt"
"github.com/matttproud/golang_protobuf_extensions/pbutil"
amv2 "github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/alertmanager/nflog/nflogpb"
"github.com/prometheus/alertmanager/silence/silencepb"
"github.com/prometheus/common/model"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/store"
@@ -361,24 +357,3 @@ func createNotificationLog(groupKey string, receiverName string, sentAt, expires
ExpiresAt: expiresAt,
}
}
func GenSilence(createdBy string) *apimodels.PostableSilence {
starts := strfmt.DateTime(time.Now().Add(time.Duration(rand.Int63n(9)+1) * time.Second))
ends := strfmt.DateTime(time.Now().Add(time.Duration(rand.Int63n(9)+10) * time.Second))
comment := "test comment"
isEqual := true
name := "test"
value := "test"
isRegex := false
matchers := amv2.Matchers{&amv2.Matcher{IsEqual: &isEqual, Name: &name, Value: &value, IsRegex: &isRegex}}
return &apimodels.PostableSilence{
Silence: amv2.Silence{
Comment: &comment,
CreatedBy: &createdBy,
Matchers: matchers,
StartsAt: &starts,
EndsAt: &ends,
},
}
}

View File

@@ -507,7 +507,8 @@ func TestIntegrationRemoteAlertmanagerSilences(t *testing.T) {
require.Equal(t, 0, len(silences))
// Creating a silence should succeed.
testSilence := notifier.GenSilence("test")
gen := ngmodels.SilenceGen(ngmodels.SilenceMuts.WithEmptyId())
testSilence := notifier.SilenceToPostableSilence(gen())
id, err := am.CreateSilence(context.Background(), testSilence)
require.NoError(t, err)
require.NotEmpty(t, id)
@@ -523,7 +524,7 @@ func TestIntegrationRemoteAlertmanagerSilences(t *testing.T) {
require.Error(t, err)
// After creating another silence, the total amount should be 2.
testSilence2 := notifier.GenSilence("test")
testSilence2 := notifier.SilenceToPostableSilence(gen())
id, err = am.CreateSilence(context.Background(), testSilence2)
require.NoError(t, err)
require.NotEmpty(t, id)
@@ -546,7 +547,7 @@ func TestIntegrationRemoteAlertmanagerSilences(t *testing.T) {
if *s.ID == testSilence.ID {
require.Equal(t, *s.Status.State, "expired")
} else {
require.Equal(t, *s.Status.State, "pending")
require.Equal(t, *s.Status.State, "active")
}
}

View File

@@ -11,6 +11,8 @@ import (
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
"xorm.io/xorm"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth/identity"
@@ -23,7 +25,6 @@ import (
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/services/store/entity"
"github.com/grafana/grafana/pkg/util"
"xorm.io/xorm"
)
// AlertRuleMaxTitleLength is the maximum length of the alert rule title
@@ -769,3 +770,20 @@ func ruleConstraintViolationToErr(rule ngmodels.AlertRule, err error) error {
return ngmodels.ErrAlertRuleConflict(rule, err)
}
}
// GetNamespacesByRuleUID returns a map of rule UIDs to their namespace UID.
func (st DBstore) GetNamespacesByRuleUID(ctx context.Context, orgID int64, uids ...string) (map[string]string, error) {
result := make(map[string]string)
err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
var rules []ngmodels.AlertRule
err := sess.Table(ngmodels.AlertRule{}).Select("uid, namespace_uid").Where("org_id = ?", orgID).In("uid", uids).Find(&rules)
if err != nil {
return err
}
for _, rule := range rules {
result[rule.UID] = rule.NamespaceUID
}
return nil
})
return result, err
}

View File

@@ -809,6 +809,57 @@ func TestIntegrationListNotificationSettings(t *testing.T) {
})
}
func TestIntegrationGetNamespacesByRuleUID(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
sqlStore := db.InitTestDB(t)
cfg := setting.NewCfg()
cfg.UnifiedAlerting.BaseInterval = 1 * time.Second
store := &DBstore{
SQLStore: sqlStore,
FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()),
Logger: log.New("test-dbstore"),
Cfg: cfg.UnifiedAlerting,
}
rules := models.RuleGen.With(models.RuleMuts.WithOrgID(1)).GenerateMany(5)
_, err := store.InsertAlertRules(context.Background(), rules)
require.NoError(t, err)
uids := make([]string, 0, len(rules))
for _, rule := range rules {
uids = append(uids, rule.UID)
}
result, err := store.GetNamespacesByRuleUID(context.Background(), 1, uids...)
require.NoError(t, err)
require.Len(t, result, len(rules))
for _, rule := range rules {
if !assert.Contains(t, result, rule.UID) {
continue
}
assert.EqualValues(t, rule.NamespaceUID, result[rule.UID])
}
// Now test with a subset of uids.
subset := uids[:3]
result, err = store.GetNamespacesByRuleUID(context.Background(), 1, subset...)
require.NoError(t, err)
require.Len(t, result, len(subset))
for _, uid := range subset {
if !assert.Contains(t, result, uid) {
continue
}
for _, rule := range rules {
if rule.UID == uid {
assert.EqualValues(t, rule.NamespaceUID, result[uid])
}
}
}
}
// createAlertRule creates an alert rule in the database and returns it.
// If a generator is not specified, uniqueness of primary key is not guaranteed.
func createRule(t *testing.T, store *DBstore, generator *models.AlertRuleGenerator) *models.AlertRule {

View File

@@ -353,3 +353,28 @@ func (f *RuleStore) IncreaseVersionForAllRulesInNamespace(_ context.Context, org
func (f *RuleStore) CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, u identity.Requester) (int64, error) {
return 0, nil
}
func (f *RuleStore) GetNamespacesByRuleUID(ctx context.Context, orgID int64, uids ...string) (map[string]string, error) {
f.mtx.Lock()
defer f.mtx.Unlock()
namespacesMap := make(map[string]string)
rules, ok := f.Rules[orgID]
if !ok {
return namespacesMap, nil
}
uidFilter := make(map[string]struct{}, len(uids))
for _, uid := range uids {
uidFilter[uid] = struct{}{}
}
for _, rule := range rules {
if _, ok := uidFilter[rule.UID]; ok {
namespacesMap[rule.UID] = rule.NamespaceUID
}
}
return namespacesMap, nil
}