mirror of https://github.com/grafana/grafana
AlertingNG: Save alert instances (#30223)
* AlertingNG: Save alert instances Co-authored-by: Kyle Brandt <kyle@grafana.com> * Rename alert instance fields/columns * Include definition title in listing alert instances * Delete instances when deleting defintion Co-authored-by: Kyle Brandt <kyle@grafana.com>pull/30370/head
parent
93a59561ba
commit
8c31e25926
@ -0,0 +1,96 @@ |
||||
package ngalert |
||||
|
||||
import ( |
||||
"fmt" |
||||
"time" |
||||
) |
||||
|
||||
// AlertInstance represents a single alert instance.
|
||||
type AlertInstance struct { |
||||
DefinitionOrgID int64 `xorm:"def_org_id"` |
||||
DefinitionUID string `xorm:"def_uid"` |
||||
Labels InstanceLabels |
||||
LabelsHash string |
||||
CurrentState InstanceStateType |
||||
CurrentStateSince time.Time |
||||
LastEvalTime time.Time |
||||
} |
||||
|
||||
// InstanceStateType is an enum for instance states.
|
||||
type InstanceStateType string |
||||
|
||||
const ( |
||||
// InstanceStateFiring is for a firing alert.
|
||||
InstanceStateFiring InstanceStateType = "Alerting" |
||||
// InstanceStateNormal is for a normal alert.
|
||||
InstanceStateNormal InstanceStateType = "Normal" |
||||
) |
||||
|
||||
// IsValid checks that the value of InstanceStateType is a valid
|
||||
// string.
|
||||
func (i InstanceStateType) IsValid() bool { |
||||
return i == InstanceStateFiring || |
||||
i == InstanceStateNormal |
||||
} |
||||
|
||||
// saveAlertInstanceCommand is the query for saving a new alert instance.
|
||||
type saveAlertInstanceCommand struct { |
||||
DefinitionOrgID int64 |
||||
DefinitionUID string |
||||
Labels InstanceLabels |
||||
State InstanceStateType |
||||
LastEvalTime time.Time |
||||
} |
||||
|
||||
// getAlertDefinitionByIDQuery is the query for retrieving/deleting an alert definition by ID.
|
||||
// nolint:unused
|
||||
type getAlertInstanceQuery struct { |
||||
DefinitionOrgID int64 |
||||
DefinitionUID string |
||||
Labels InstanceLabels |
||||
|
||||
Result *AlertInstance |
||||
} |
||||
|
||||
// listAlertInstancesCommand is the query list alert Instances.
|
||||
type listAlertInstancesQuery struct { |
||||
DefinitionOrgID int64 `json:"-"` |
||||
DefinitionUID string |
||||
State InstanceStateType |
||||
|
||||
Result []*listAlertInstancesQueryResult |
||||
} |
||||
|
||||
// listAlertInstancesQueryResult represents the result of listAlertInstancesQuery.
|
||||
type listAlertInstancesQueryResult struct { |
||||
DefinitionOrgID int64 `xorm:"def_org_id"` |
||||
DefinitionUID string `xorm:"def_uid"` |
||||
DefinitionTitle string `xorm:"def_title"` |
||||
Labels InstanceLabels |
||||
LabelsHash string |
||||
CurrentState InstanceStateType |
||||
CurrentStateSince time.Time |
||||
LastEvalTime time.Time |
||||
} |
||||
|
||||
// validateAlertInstance validates that the alert instance contains an alert definition id,
|
||||
// and state.
|
||||
func validateAlertInstance(alertInstance *AlertInstance) error { |
||||
if alertInstance == nil { |
||||
return fmt.Errorf("alert instance is invalid because it is nil") |
||||
} |
||||
|
||||
if alertInstance.DefinitionOrgID == 0 { |
||||
return fmt.Errorf("alert instance is invalid due to missing alert definition organisation") |
||||
} |
||||
|
||||
if alertInstance.DefinitionUID == "" { |
||||
return fmt.Errorf("alert instance is invalid due to missing alert definition uid") |
||||
} |
||||
|
||||
if !alertInstance.CurrentState.IsValid() { |
||||
return fmt.Errorf("alert instance is invalid because the state '%v' is invalid", alertInstance.CurrentState) |
||||
} |
||||
|
||||
return nil |
||||
} |
@ -0,0 +1,17 @@ |
||||
package ngalert |
||||
|
||||
import ( |
||||
"github.com/grafana/grafana/pkg/api/response" |
||||
"github.com/grafana/grafana/pkg/models" |
||||
) |
||||
|
||||
// listAlertInstancesEndpoint handles GET /api/alert-instances.
|
||||
func (ng *AlertNG) listAlertInstancesEndpoint(c *models.ReqContext) response.Response { |
||||
cmd := listAlertInstancesQuery{DefinitionOrgID: c.SignedInUser.OrgId} |
||||
|
||||
if err := ng.listAlertInstances(&cmd); err != nil { |
||||
return response.Error(500, "Failed to list alert instances", err) |
||||
} |
||||
|
||||
return response.JSON(200, cmd.Result) |
||||
} |
@ -0,0 +1,115 @@ |
||||
package ngalert |
||||
|
||||
import ( |
||||
"context" |
||||
"fmt" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore" |
||||
) |
||||
|
||||
// getAlertInstance is a handler for retrieving an alert instance based on OrgId, AlertDefintionID, and
|
||||
// the hash of the labels.
|
||||
// nolint:unused
|
||||
func (ng *AlertNG) getAlertInstance(cmd *getAlertInstanceQuery) error { |
||||
return ng.SQLStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { |
||||
instance := AlertInstance{} |
||||
s := strings.Builder{} |
||||
s.WriteString(`SELECT * FROM alert_instance |
||||
WHERE |
||||
def_org_id=? AND |
||||
def_uid=? AND |
||||
labels_hash=? |
||||
`) |
||||
|
||||
_, hash, err := cmd.Labels.StringAndHash() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
params := append(make([]interface{}, 0), cmd.DefinitionOrgID, cmd.DefinitionUID, hash) |
||||
|
||||
has, err := sess.SQL(s.String(), params...).Get(&instance) |
||||
if !has { |
||||
return fmt.Errorf("instance not found for labels %v (hash: %v), alert definition %v (org %v)", cmd.Labels, hash, cmd.DefinitionUID, cmd.DefinitionOrgID) |
||||
} |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
cmd.Result = &instance |
||||
return nil |
||||
}) |
||||
} |
||||
|
||||
// listAlertInstances is a handler for retrieving alert instances within specific organisation
|
||||
// based on various filters.
|
||||
func (ng *AlertNG) listAlertInstances(cmd *listAlertInstancesQuery) error { |
||||
return ng.SQLStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { |
||||
alertInstances := make([]*listAlertInstancesQueryResult, 0) |
||||
|
||||
s := strings.Builder{} |
||||
params := make([]interface{}, 0) |
||||
|
||||
addToQuery := func(stmt string, p ...interface{}) { |
||||
s.WriteString(stmt) |
||||
params = append(params, p...) |
||||
} |
||||
|
||||
addToQuery("SELECT alert_instance.*, alert_definition.title AS def_title FROM alert_instance LEFT JOIN alert_definition ON alert_instance.def_org_id = alert_definition.org_id AND alert_instance.def_uid = alert_definition.uid WHERE def_org_id = ?", cmd.DefinitionOrgID) |
||||
|
||||
if cmd.DefinitionUID != "" { |
||||
addToQuery(` AND def_uid = ?`, cmd.DefinitionUID) |
||||
} |
||||
|
||||
if cmd.State != "" { |
||||
addToQuery(` AND current_state = ?`, cmd.State) |
||||
} |
||||
|
||||
if err := sess.SQL(s.String(), params...).Find(&alertInstances); err != nil { |
||||
return err |
||||
} |
||||
|
||||
cmd.Result = alertInstances |
||||
return nil |
||||
}) |
||||
} |
||||
|
||||
// saveAlertDefinition is a handler for saving a new alert definition.
|
||||
// nolint:unused
|
||||
func (ng *AlertNG) saveAlertInstance(cmd *saveAlertInstanceCommand) error { |
||||
return ng.SQLStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { |
||||
labelTupleJSON, labelsHash, err := cmd.Labels.StringAndHash() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
alertInstance := &AlertInstance{ |
||||
DefinitionOrgID: cmd.DefinitionOrgID, |
||||
DefinitionUID: cmd.DefinitionUID, |
||||
Labels: cmd.Labels, |
||||
LabelsHash: labelsHash, |
||||
CurrentState: cmd.State, |
||||
CurrentStateSince: time.Now(), |
||||
LastEvalTime: cmd.LastEvalTime, |
||||
} |
||||
|
||||
if err := validateAlertInstance(alertInstance); err != nil { |
||||
return err |
||||
} |
||||
|
||||
params := append(make([]interface{}, 0), alertInstance.DefinitionOrgID, alertInstance.DefinitionUID, labelTupleJSON, alertInstance.LabelsHash, alertInstance.CurrentState, alertInstance.CurrentStateSince.Unix(), alertInstance.LastEvalTime.Unix()) |
||||
|
||||
upsertSQL := ng.SQLStore.Dialect.UpsertSQL( |
||||
"alert_instance", |
||||
[]string{"def_org_id", "def_uid", "labels_hash"}, |
||||
[]string{"def_org_id", "def_uid", "labels", "labels_hash", "current_state", "current_state_since", "last_eval_time"}) |
||||
_, err = sess.SQL(upsertSQL, params...).Query() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
return nil |
||||
}) |
||||
} |
@ -0,0 +1,163 @@ |
||||
// +build integration
|
||||
|
||||
package ngalert |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestAlertInstanceOperations(t *testing.T) { |
||||
ng := setupTestEnv(t) |
||||
|
||||
alertDefinition1 := createTestAlertDefinition(t, ng, 60) |
||||
orgID := alertDefinition1.OrgID |
||||
|
||||
alertDefinition2 := createTestAlertDefinition(t, ng, 60) |
||||
require.Equal(t, orgID, alertDefinition2.OrgID) |
||||
|
||||
alertDefinition3 := createTestAlertDefinition(t, ng, 60) |
||||
require.Equal(t, orgID, alertDefinition3.OrgID) |
||||
|
||||
alertDefinition4 := createTestAlertDefinition(t, ng, 60) |
||||
require.Equal(t, orgID, alertDefinition4.OrgID) |
||||
|
||||
t.Run("can save and read new alert instance", func(t *testing.T) { |
||||
saveCmd := &saveAlertInstanceCommand{ |
||||
DefinitionOrgID: alertDefinition1.OrgID, |
||||
DefinitionUID: alertDefinition1.UID, |
||||
State: InstanceStateFiring, |
||||
Labels: InstanceLabels{"test": "testValue"}, |
||||
} |
||||
err := ng.saveAlertInstance(saveCmd) |
||||
require.NoError(t, err) |
||||
|
||||
getCmd := &getAlertInstanceQuery{ |
||||
DefinitionOrgID: saveCmd.DefinitionOrgID, |
||||
DefinitionUID: saveCmd.DefinitionUID, |
||||
Labels: InstanceLabels{"test": "testValue"}, |
||||
} |
||||
|
||||
err = ng.getAlertInstance(getCmd) |
||||
require.NoError(t, err) |
||||
|
||||
require.Equal(t, saveCmd.Labels, getCmd.Result.Labels) |
||||
require.Equal(t, alertDefinition1.OrgID, getCmd.Result.DefinitionOrgID) |
||||
require.Equal(t, alertDefinition1.UID, getCmd.Result.DefinitionUID) |
||||
}) |
||||
|
||||
t.Run("can save and read new alert instance with no labels", func(t *testing.T) { |
||||
saveCmd := &saveAlertInstanceCommand{ |
||||
DefinitionOrgID: alertDefinition2.OrgID, |
||||
DefinitionUID: alertDefinition2.UID, |
||||
State: InstanceStateNormal, |
||||
} |
||||
err := ng.saveAlertInstance(saveCmd) |
||||
require.NoError(t, err) |
||||
|
||||
getCmd := &getAlertInstanceQuery{ |
||||
DefinitionOrgID: saveCmd.DefinitionOrgID, |
||||
DefinitionUID: saveCmd.DefinitionUID, |
||||
} |
||||
|
||||
err = ng.getAlertInstance(getCmd) |
||||
require.NoError(t, err) |
||||
|
||||
require.Equal(t, alertDefinition2.OrgID, getCmd.Result.DefinitionOrgID) |
||||
require.Equal(t, alertDefinition2.UID, getCmd.Result.DefinitionUID) |
||||
require.Equal(t, saveCmd.Labels, getCmd.Result.Labels) |
||||
}) |
||||
|
||||
t.Run("can save two instances with same org_id, uid and different labels", func(t *testing.T) { |
||||
saveCmdOne := &saveAlertInstanceCommand{ |
||||
DefinitionOrgID: alertDefinition3.OrgID, |
||||
DefinitionUID: alertDefinition3.UID, |
||||
State: InstanceStateFiring, |
||||
Labels: InstanceLabels{"test": "testValue"}, |
||||
} |
||||
|
||||
err := ng.saveAlertInstance(saveCmdOne) |
||||
require.NoError(t, err) |
||||
|
||||
saveCmdTwo := &saveAlertInstanceCommand{ |
||||
DefinitionOrgID: saveCmdOne.DefinitionOrgID, |
||||
DefinitionUID: saveCmdOne.DefinitionUID, |
||||
State: InstanceStateFiring, |
||||
Labels: InstanceLabels{"test": "meow"}, |
||||
} |
||||
err = ng.saveAlertInstance(saveCmdTwo) |
||||
require.NoError(t, err) |
||||
|
||||
listCommand := &listAlertInstancesQuery{ |
||||
DefinitionOrgID: saveCmdOne.DefinitionOrgID, |
||||
DefinitionUID: saveCmdOne.DefinitionUID, |
||||
} |
||||
|
||||
err = ng.listAlertInstances(listCommand) |
||||
require.NoError(t, err) |
||||
|
||||
require.Len(t, listCommand.Result, 2) |
||||
}) |
||||
|
||||
t.Run("can list all added instances in org", func(t *testing.T) { |
||||
listCommand := &listAlertInstancesQuery{ |
||||
DefinitionOrgID: orgID, |
||||
} |
||||
|
||||
err := ng.listAlertInstances(listCommand) |
||||
require.NoError(t, err) |
||||
|
||||
require.Len(t, listCommand.Result, 4) |
||||
}) |
||||
|
||||
t.Run("can list all added instances in org filtered by current state", func(t *testing.T) { |
||||
listCommand := &listAlertInstancesQuery{ |
||||
DefinitionOrgID: orgID, |
||||
State: InstanceStateNormal, |
||||
} |
||||
|
||||
err := ng.listAlertInstances(listCommand) |
||||
require.NoError(t, err) |
||||
|
||||
require.Len(t, listCommand.Result, 1) |
||||
}) |
||||
|
||||
t.Run("update instance with same org_id, uid and different labels", func(t *testing.T) { |
||||
saveCmdOne := &saveAlertInstanceCommand{ |
||||
DefinitionOrgID: alertDefinition4.OrgID, |
||||
DefinitionUID: alertDefinition4.UID, |
||||
State: InstanceStateFiring, |
||||
Labels: InstanceLabels{"test": "testValue"}, |
||||
} |
||||
|
||||
err := ng.saveAlertInstance(saveCmdOne) |
||||
require.NoError(t, err) |
||||
|
||||
saveCmdTwo := &saveAlertInstanceCommand{ |
||||
DefinitionOrgID: saveCmdOne.DefinitionOrgID, |
||||
DefinitionUID: saveCmdOne.DefinitionUID, |
||||
State: InstanceStateNormal, |
||||
Labels: InstanceLabels{"test": "testValue"}, |
||||
} |
||||
err = ng.saveAlertInstance(saveCmdTwo) |
||||
require.NoError(t, err) |
||||
|
||||
listCommand := &listAlertInstancesQuery{ |
||||
DefinitionOrgID: alertDefinition4.OrgID, |
||||
DefinitionUID: alertDefinition4.UID, |
||||
} |
||||
|
||||
err = ng.listAlertInstances(listCommand) |
||||
require.NoError(t, err) |
||||
|
||||
require.Len(t, listCommand.Result, 1) |
||||
|
||||
require.Equal(t, saveCmdTwo.DefinitionOrgID, listCommand.Result[0].DefinitionOrgID) |
||||
require.Equal(t, saveCmdTwo.DefinitionUID, listCommand.Result[0].DefinitionUID) |
||||
require.Equal(t, saveCmdTwo.Labels, listCommand.Result[0].Labels) |
||||
require.Equal(t, saveCmdTwo.State, listCommand.Result[0].CurrentState) |
||||
require.NotEmpty(t, listCommand.Result[0].DefinitionTitle) |
||||
require.Equal(t, alertDefinition4.Title, listCommand.Result[0].DefinitionTitle) |
||||
}) |
||||
} |
@ -0,0 +1,105 @@ |
||||
package ngalert |
||||
|
||||
import ( |
||||
// nolint:gosec
|
||||
"crypto/sha1" |
||||
"encoding/json" |
||||
"fmt" |
||||
"sort" |
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data" |
||||
) |
||||
|
||||
// InstanceLabels is an extension to data.Labels with methods
|
||||
// for database serialization.
|
||||
type InstanceLabels data.Labels |
||||
|
||||
// FromDB loads labels stored in the database as json tuples into InstanceLabels.
|
||||
// FromDB is part of the xorm Conversion interface.
|
||||
func (il *InstanceLabels) FromDB(b []byte) error { |
||||
tl := &tupleLabels{} |
||||
err := json.Unmarshal(b, tl) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
labels, err := tupleLablesToLabels(*tl) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
*il = labels |
||||
return nil |
||||
} |
||||
|
||||
// ToDB is not implemented as serialization is handled with manual SQL queries).
|
||||
// ToDB is part of the xorm Conversion interface.
|
||||
func (il *InstanceLabels) ToDB() ([]byte, error) { |
||||
// Currently handled manually in sql command, needed to fulfill the xorm
|
||||
// converter interface it seems
|
||||
return []byte{}, fmt.Errorf("database serialization of alerting ng Instance labels is not implemented") |
||||
} |
||||
|
||||
// StringAndHash returns a the json representation of the labels as tuples
|
||||
// sorted by key. It also returns the a hash of that representation.
|
||||
func (il *InstanceLabels) StringAndHash() (string, string, error) { |
||||
tl := labelsToTupleLabels(*il) |
||||
|
||||
b, err := json.Marshal(tl) |
||||
if err != nil { |
||||
return "", "", fmt.Errorf("can not gereate key for alert instance due to failure to encode labels: %w", err) |
||||
} |
||||
|
||||
h := sha1.New() |
||||
if _, err := h.Write(b); err != nil { |
||||
return "", "", err |
||||
} |
||||
|
||||
return string(b), fmt.Sprintf("%x", h.Sum(nil)), nil |
||||
} |
||||
|
||||
// The following is based on SDK code, copied for now
|
||||
|
||||
// tupleLables is an alternative representation of Labels (map[string]string) that can be sorted
|
||||
// and then marshalled into a consistent string that can be used a map key. All tupleLabel objects
|
||||
// in tupleLabels should have unique first elements (keys).
|
||||
type tupleLabels []tupleLabel |
||||
|
||||
// tupleLabel is an element of tupleLabels and should be in the form of [2]{"key", "value"}.
|
||||
type tupleLabel [2]string |
||||
|
||||
// Sort tupleLabels by each elements first property (key).
|
||||
func (t *tupleLabels) sortBtKey() { |
||||
if t == nil { |
||||
return |
||||
} |
||||
sort.Slice((*t)[:], func(i, j int) bool { |
||||
return (*t)[i][0] < (*t)[j][0] |
||||
}) |
||||
} |
||||
|
||||
// labelsToTupleLabels converts Labels (map[string]string) to tupleLabels.
|
||||
func labelsToTupleLabels(l InstanceLabels) tupleLabels { |
||||
if l == nil { |
||||
return nil |
||||
} |
||||
t := make(tupleLabels, 0, len(l)) |
||||
for k, v := range l { |
||||
t = append(t, tupleLabel{k, v}) |
||||
} |
||||
t.sortBtKey() |
||||
return t |
||||
} |
||||
|
||||
// tupleLabelsToLabels converts tupleLabels to Labels (map[string]string), erroring if there are duplicate keys.
|
||||
func tupleLablesToLabels(tuples tupleLabels) (InstanceLabels, error) { |
||||
if tuples == nil { |
||||
return nil, nil |
||||
} |
||||
labels := make(map[string]string) |
||||
for _, tuple := range tuples { |
||||
if key, ok := labels[tuple[0]]; ok { |
||||
return nil, fmt.Errorf("duplicate key '%v' in lables: %v", key, tuples) |
||||
} |
||||
labels[tuple[0]] = tuple[1] |
||||
} |
||||
return labels, nil |
||||
} |
Loading…
Reference in new issue