mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Rule version history API (#99041)
* implement store method to read rule versions * implement request handler * declare a new endpoint * fix fake to return correct response * add tests * add integration tests * rename history to versions * apply diff from swagger CI step Signed-off-by: Yuri Tseretyan <yuriy.tseretyan@grafana.com> --------- Signed-off-by: Yuri Tseretyan <yuriy.tseretyan@grafana.com>
This commit is contained in:
parent
8a259ecafa
commit
ac41c19350
@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -330,6 +331,30 @@ func (srv RulerSrv) RouteGetRuleByUID(c *contextmodel.ReqContext, ruleUID string
|
||||
return response.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (srv RulerSrv) RouteGetRuleVersionsByUID(c *contextmodel.ReqContext, ruleUID string) response.Response {
|
||||
ctx := c.Req.Context()
|
||||
// make sure the user has access to the current version of the rule. Also, check if it exists
|
||||
_, err := srv.getAuthorizedRuleByUid(ctx, c, ruleUID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ngmodels.ErrAlertRuleNotFound) {
|
||||
return response.Empty(http.StatusNotFound)
|
||||
}
|
||||
return response.ErrOrFallback(http.StatusInternalServerError, "failed to get rule by UID", err)
|
||||
}
|
||||
|
||||
rules, err := srv.store.GetAlertRuleVersions(ctx, ngmodels.AlertRuleKey{OrgID: c.OrgID, UID: ruleUID})
|
||||
if err != nil {
|
||||
return response.ErrOrFallback(http.StatusInternalServerError, "failed to get rule history", err)
|
||||
}
|
||||
sort.Slice(rules, func(i, j int) bool { return rules[i].ID > rules[j].ID })
|
||||
result := make(apimodels.GettableRuleVersions, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
// do not provide provenance status because we do not have historical changes for it
|
||||
result = append(result, toGettableExtendedRuleNode(*rule, map[string]ngmodels.Provenance{}, srv.resolveUserIdToNameFn(ctx)))
|
||||
}
|
||||
return response.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (srv RulerSrv) RoutePostNameRulesConfig(c *contextmodel.ReqContext, ruleGroupConfig apimodels.PostableRuleGroupConfig, namespaceUID string) response.Response {
|
||||
namespace, err := srv.store.GetNamespaceByUID(c.Req.Context(), namespaceUID, c.SignedInUser.GetOrgID(), c.SignedInUser)
|
||||
if err != nil {
|
||||
|
@ -448,6 +448,109 @@ func TestRouteGetRuleByUID(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestRouteGetRuleHistoryByUID(t *testing.T) {
|
||||
orgID := rand.Int63()
|
||||
f := randFolder()
|
||||
groupKey := models.GenerateGroupKey(orgID)
|
||||
groupKey.NamespaceUID = f.UID
|
||||
gen := models.RuleGen.With(models.RuleGen.WithGroupKey(groupKey), models.RuleGen.WithUniqueID())
|
||||
|
||||
t.Run("rule history is successfully fetched with the correct UID", func(t *testing.T) {
|
||||
ruleStore := fakes.NewRuleStore(t)
|
||||
ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], f)
|
||||
|
||||
rule := gen.GenerateRef()
|
||||
history := gen.With(gen.WithUID(rule.UID)).GenerateManyRef(3)
|
||||
// simulate order of the history
|
||||
rule.ID = 100
|
||||
for i, alertRule := range history {
|
||||
alertRule.ID = rule.ID - int64(i) - 1
|
||||
}
|
||||
|
||||
ruleStore.PutRule(context.Background(), rule)
|
||||
ruleStore.History[rule.GetKey()] = append(ruleStore.History[rule.GetKey()], history...)
|
||||
|
||||
perms := createPermissionsForRules([]*models.AlertRule{rule}, orgID)
|
||||
req := createRequestContextWithPerms(orgID, perms, nil)
|
||||
|
||||
svc := createService(ruleStore)
|
||||
response := svc.RouteGetRuleVersionsByUID(req, rule.UID)
|
||||
|
||||
require.Equal(t, http.StatusOK, response.Status())
|
||||
var result apimodels.GettableRuleVersions
|
||||
require.NoError(t, json.Unmarshal(response.Body(), &result))
|
||||
require.NotNil(t, result)
|
||||
|
||||
require.Len(t, result, len(history)+1) // history + current version
|
||||
|
||||
t.Run("should be in correct order", func(t *testing.T) {
|
||||
expectedHistory := append([]*models.AlertRule{rule}, history...)
|
||||
for i, rul := range expectedHistory {
|
||||
assert.Equal(t, rul.UID, result[i].GrafanaManagedAlert.UID)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("NotFound when rule does not exist", func(t *testing.T) {
|
||||
ruleStore := fakes.NewRuleStore(t)
|
||||
ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], f)
|
||||
ruleKey := models.AlertRuleKey{
|
||||
OrgID: orgID,
|
||||
UID: "test",
|
||||
}
|
||||
history := gen.With(gen.WithKey(ruleKey)).GenerateManyRef(3)
|
||||
ruleStore.History[ruleKey] = append(ruleStore.History[ruleKey], history...) // even if history is full of records
|
||||
|
||||
perms := createPermissionsForRules(history, orgID)
|
||||
req := createRequestContextWithPerms(orgID, perms, nil)
|
||||
response := createService(ruleStore).RouteGetRuleVersionsByUID(req, ruleKey.UID)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, response.Status())
|
||||
})
|
||||
|
||||
t.Run("Empty result when rule history is empty", func(t *testing.T) {
|
||||
ruleStore := fakes.NewRuleStore(t)
|
||||
ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], f)
|
||||
ruleKey := models.AlertRuleKey{
|
||||
OrgID: orgID,
|
||||
UID: "test",
|
||||
}
|
||||
rule := gen.With(gen.WithKey(ruleKey)).GenerateRef()
|
||||
ruleStore.PutRule(context.Background(), rule)
|
||||
ruleStore.History[ruleKey] = nil
|
||||
|
||||
perms := createPermissionsForRules([]*models.AlertRule{rule}, orgID)
|
||||
req := createRequestContextWithPerms(orgID, perms, nil)
|
||||
response := createService(ruleStore).RouteGetRuleVersionsByUID(req, ruleKey.UID)
|
||||
|
||||
require.Equal(t, http.StatusOK, response.Status())
|
||||
|
||||
var result apimodels.GettableRuleVersions
|
||||
require.NoError(t, json.Unmarshal(response.Body(), &result))
|
||||
require.Empty(t, result)
|
||||
})
|
||||
|
||||
t.Run("Unauthorized if user does not have access to the current rule", func(t *testing.T) {
|
||||
ruleStore := fakes.NewRuleStore(t)
|
||||
anotherFolder := randFolder()
|
||||
ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], f, anotherFolder)
|
||||
ruleKey := models.AlertRuleKey{
|
||||
OrgID: orgID,
|
||||
UID: "test",
|
||||
}
|
||||
rule := gen.With(gen.WithKey(ruleKey), gen.WithNamespaceUID(anotherFolder.UID)).GenerateRef()
|
||||
ruleStore.PutRule(context.Background(), rule)
|
||||
history := gen.With(gen.WithKey(ruleKey)).GenerateManyRef(3)
|
||||
ruleStore.History[ruleKey] = history
|
||||
|
||||
perms := createPermissionsForRules(history, orgID) // grant permissions to all records in history but not the rule itself
|
||||
req := createRequestContextWithPerms(orgID, perms, nil)
|
||||
response := createService(ruleStore).RouteGetRuleVersionsByUID(req, ruleKey.UID)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, response.Status())
|
||||
})
|
||||
}
|
||||
|
||||
func TestRouteGetRulesConfig(t *testing.T) {
|
||||
gen := models.RuleGen
|
||||
t.Run("fine-grained access is enabled", func(t *testing.T) {
|
||||
|
@ -41,7 +41,8 @@ func (api *API) authorize(method, path string) web.Handler {
|
||||
case http.MethodGet + "/api/ruler/grafana/api/v1/rules",
|
||||
http.MethodGet + "/api/ruler/grafana/api/v1/export/rules":
|
||||
eval = ac.EvalPermission(ac.ActionAlertingRuleRead)
|
||||
case http.MethodGet + "/api/ruler/grafana/api/v1/rule/{RuleUID}":
|
||||
case http.MethodGet + "/api/ruler/grafana/api/v1/rule/{RuleUID}",
|
||||
http.MethodGet + "/api/ruler/grafana/api/v1/rule/{RuleUID}/versions":
|
||||
eval = ac.EvalAll(
|
||||
ac.EvalPermission(ac.ActionAlertingRuleRead),
|
||||
ac.EvalPermission(dashboards.ActionFoldersRead),
|
||||
|
@ -41,7 +41,7 @@ func TestAuthorize(t *testing.T) {
|
||||
}
|
||||
paths[p] = methods
|
||||
}
|
||||
require.Len(t, paths, 59)
|
||||
require.Len(t, paths, 60)
|
||||
|
||||
ac := acmock.New()
|
||||
api := &API{AccessControl: ac, FeatureManager: featuremgmt.WithFeatures()}
|
||||
|
@ -124,3 +124,7 @@ func (f *RulerApiHandler) getService(ctx *contextmodel.ReqContext) (*LotexRuler,
|
||||
}
|
||||
return f.LotexRuler, nil
|
||||
}
|
||||
|
||||
func (f *RulerApiHandler) handleRouteGetRuleVersionsByUID(ctx *contextmodel.ReqContext, ruleUID string) response.Response {
|
||||
return f.GrafanaRuler.RouteGetRuleVersionsByUID(ctx, ruleUID)
|
||||
}
|
||||
|
@ -29,6 +29,7 @@ type RulerApi interface {
|
||||
RouteGetNamespaceGrafanaRulesConfig(*contextmodel.ReqContext) response.Response
|
||||
RouteGetNamespaceRulesConfig(*contextmodel.ReqContext) response.Response
|
||||
RouteGetRuleByUID(*contextmodel.ReqContext) response.Response
|
||||
RouteGetRuleVersionsByUID(*contextmodel.ReqContext) response.Response
|
||||
RouteGetRulegGroupConfig(*contextmodel.ReqContext) response.Response
|
||||
RouteGetRulesConfig(*contextmodel.ReqContext) response.Response
|
||||
RouteGetRulesForExport(*contextmodel.ReqContext) response.Response
|
||||
@ -86,6 +87,11 @@ func (f *RulerApiHandler) RouteGetRuleByUID(ctx *contextmodel.ReqContext) respon
|
||||
ruleUIDParam := web.Params(ctx.Req)[":RuleUID"]
|
||||
return f.handleRouteGetRuleByUID(ctx, ruleUIDParam)
|
||||
}
|
||||
func (f *RulerApiHandler) RouteGetRuleVersionsByUID(ctx *contextmodel.ReqContext) response.Response {
|
||||
// Parse Path Parameters
|
||||
ruleUIDParam := web.Params(ctx.Req)[":RuleUID"]
|
||||
return f.handleRouteGetRuleVersionsByUID(ctx, ruleUIDParam)
|
||||
}
|
||||
func (f *RulerApiHandler) RouteGetRulegGroupConfig(ctx *contextmodel.ReqContext) response.Response {
|
||||
// Parse Path Parameters
|
||||
datasourceUIDParam := web.Params(ctx.Req)[":DatasourceUID"]
|
||||
@ -243,6 +249,18 @@ func (api *API) RegisterRulerApiEndpoints(srv RulerApi, m *metrics.API) {
|
||||
m,
|
||||
),
|
||||
)
|
||||
group.Get(
|
||||
toMacaronPath("/api/ruler/grafana/api/v1/rule/{RuleUID}/versions"),
|
||||
requestmeta.SetOwner(requestmeta.TeamAlerting),
|
||||
requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow),
|
||||
api.authorize(http.MethodGet, "/api/ruler/grafana/api/v1/rule/{RuleUID}/versions"),
|
||||
metrics.Instrument(
|
||||
http.MethodGet,
|
||||
"/api/ruler/grafana/api/v1/rule/{RuleUID}/versions",
|
||||
api.Hooks.Wrap(srv.RouteGetRuleVersionsByUID),
|
||||
m,
|
||||
),
|
||||
)
|
||||
group.Get(
|
||||
toMacaronPath("/api/ruler/{DatasourceUID}/api/v1/rules/{Namespace}/{Groupname}"),
|
||||
requestmeta.SetOwner(requestmeta.TeamAlerting),
|
||||
|
@ -28,6 +28,6 @@ type RuleStore interface {
|
||||
|
||||
// IncreaseVersionForAllRulesInNamespaces Increases version for all rules that have specified namespace uids
|
||||
IncreaseVersionForAllRulesInNamespaces(ctx context.Context, orgID int64, namespaceUIDs []string) ([]ngmodels.AlertRuleKeyWithVersion, error)
|
||||
|
||||
GetAlertRuleVersions(ctx context.Context, key ngmodels.AlertRuleKey) ([]*ngmodels.AlertRule, error)
|
||||
accesscontrol.RuleUIDToNamespaceStore
|
||||
}
|
||||
|
@ -459,6 +459,9 @@
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"folderUid": {
|
||||
"type": "string"
|
||||
},
|
||||
"health": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -498,10 +501,15 @@
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"uid": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"uid",
|
||||
"name",
|
||||
"folderUid",
|
||||
"query",
|
||||
"health",
|
||||
"type",
|
||||
@ -1166,6 +1174,14 @@
|
||||
"CounterResetHint": {
|
||||
"$ref": "#/definitions/CounterResetHint"
|
||||
},
|
||||
"CustomValues": {
|
||||
"description": "Holds the custom (usually upper) bounds for bucket definitions, otherwise nil.\nThis slice is interned, to be treated as immutable and copied by reference.\nThese numbers should be strictly increasing. This field is only used when the\nschema is for custom buckets, and the ZeroThreshold, ZeroCount, NegativeSpans\nand NegativeBuckets fields are not used in that case.",
|
||||
"items": {
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PositiveBuckets": {
|
||||
"description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.",
|
||||
"items": {
|
||||
@ -1182,7 +1198,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"Schema": {
|
||||
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).",
|
||||
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8 for exponential buckets.\nThey are all for base-2 bucket schemas, where 1 is a bucket boundary in\neach case, and then each power of two is divided into 2^n logarithmic buckets.\nOr in other words, each bucket boundary is the previous boundary times\n2^(2^-n). Another valid schema number is -53 for custom buckets, defined by\nthe CustomValues field.",
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
},
|
||||
@ -1628,6 +1644,9 @@
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"updated_by": {
|
||||
"$ref": "#/definitions/UserInfo"
|
||||
},
|
||||
"version": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
@ -1712,6 +1731,12 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"GettableRuleVersions": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/GettableExtendedRuleNode"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"GettableStatus": {
|
||||
"properties": {
|
||||
"cluster": {
|
||||
@ -3572,6 +3597,9 @@
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"folderUid": {
|
||||
"type": "string"
|
||||
},
|
||||
"health": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -3593,10 +3621,15 @@
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"uid": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"uid",
|
||||
"name",
|
||||
"folderUid",
|
||||
"query",
|
||||
"health",
|
||||
"type"
|
||||
@ -3636,6 +3669,9 @@
|
||||
"file": {
|
||||
"type": "string"
|
||||
},
|
||||
"folderUid": {
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
@ -3665,6 +3701,7 @@
|
||||
"required": [
|
||||
"name",
|
||||
"file",
|
||||
"folderUid",
|
||||
"rules",
|
||||
"interval"
|
||||
],
|
||||
@ -3767,6 +3804,10 @@
|
||||
"Sample": {
|
||||
"description": "Sample is a single sample belonging to a metric. It represents either a float\nsample or a histogram sample. If H is nil, it is a float sample. Otherwise,\nit is a histogram sample.",
|
||||
"properties": {
|
||||
"DropName": {
|
||||
"description": "DropName is used to indicate whether the __name__ label should be dropped\nas part of the query evaluation.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"F": {
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
@ -4382,7 +4423,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"
|
||||
@ -4418,7 +4458,7 @@
|
||||
"$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"
|
||||
},
|
||||
"UpdateRuleGroupResponse": {
|
||||
@ -4447,6 +4487,18 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"UserInfo": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"uid": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "UserInfo represents user-related information, including a unique identifier and a name.",
|
||||
"type": "object"
|
||||
},
|
||||
"Userinfo": {
|
||||
"description": "The Userinfo type is an immutable encapsulation of username and\npassword details for a [URL]. An existing Userinfo value is guaranteed\nto have a username set (potentially empty, as allowed by RFC 2396),\nand optionally a password.",
|
||||
"type": "object"
|
||||
@ -4648,6 +4700,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"alertGroups": {
|
||||
"description": "AlertGroups alert groups",
|
||||
"items": {
|
||||
"$ref": "#/definitions/alertGroup",
|
||||
"type": "object"
|
||||
@ -4934,6 +4987,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"gettableSilences": {
|
||||
"description": "GettableSilences gettable silences",
|
||||
"items": {
|
||||
"$ref": "#/definitions/gettableSilence",
|
||||
"type": "object"
|
||||
|
@ -20,6 +20,18 @@ import (
|
||||
// 403: ForbiddenError
|
||||
// 404: description: Not found.
|
||||
|
||||
// swagger:route Get /ruler/grafana/api/v1/rule/{RuleUID}/versions ruler RouteGetRuleVersionsByUID
|
||||
//
|
||||
// Get rule versions by UID
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// Responses:
|
||||
// 202: GettableRuleVersions
|
||||
// 403: ForbiddenError
|
||||
// 404: description: Not found.
|
||||
|
||||
// swagger:route Get /ruler/grafana/api/v1/rules ruler RouteGetGrafanaRulesConfig
|
||||
//
|
||||
// List rule groups
|
||||
@ -218,7 +230,7 @@ type PathGetRulesParams struct {
|
||||
PanelID int64
|
||||
}
|
||||
|
||||
// swagger:parameters RouteGetRuleByUID
|
||||
// swagger:parameters RouteGetRuleByUID RouteGetRuleVersionsByUID
|
||||
type PathGetRuleByUIDParams struct {
|
||||
// in: path
|
||||
RuleUID string
|
||||
@ -290,6 +302,9 @@ func (c *PostableRuleGroupConfig) validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// swagger:model
|
||||
type GettableRuleVersions []GettableExtendedRuleNode
|
||||
|
||||
// swagger:model
|
||||
type GettableRuleGroupConfig struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
|
@ -459,6 +459,9 @@
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"folderUid": {
|
||||
"type": "string"
|
||||
},
|
||||
"health": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -498,10 +501,15 @@
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"uid": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"uid",
|
||||
"name",
|
||||
"folderUid",
|
||||
"query",
|
||||
"health",
|
||||
"type",
|
||||
@ -1166,6 +1174,14 @@
|
||||
"CounterResetHint": {
|
||||
"$ref": "#/definitions/CounterResetHint"
|
||||
},
|
||||
"CustomValues": {
|
||||
"description": "Holds the custom (usually upper) bounds for bucket definitions, otherwise nil.\nThis slice is interned, to be treated as immutable and copied by reference.\nThese numbers should be strictly increasing. This field is only used when the\nschema is for custom buckets, and the ZeroThreshold, ZeroCount, NegativeSpans\nand NegativeBuckets fields are not used in that case.",
|
||||
"items": {
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PositiveBuckets": {
|
||||
"description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.",
|
||||
"items": {
|
||||
@ -1182,7 +1198,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"Schema": {
|
||||
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).",
|
||||
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8 for exponential buckets.\nThey are all for base-2 bucket schemas, where 1 is a bucket boundary in\neach case, and then each power of two is divided into 2^n logarithmic buckets.\nOr in other words, each bucket boundary is the previous boundary times\n2^(2^-n). Another valid schema number is -53 for custom buckets, defined by\nthe CustomValues field.",
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
},
|
||||
@ -1628,6 +1644,9 @@
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"updated_by": {
|
||||
"$ref": "#/definitions/UserInfo"
|
||||
},
|
||||
"version": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
@ -1712,6 +1731,12 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"GettableRuleVersions": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/GettableExtendedRuleNode"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"GettableStatus": {
|
||||
"properties": {
|
||||
"cluster": {
|
||||
@ -3572,6 +3597,9 @@
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"folderUid": {
|
||||
"type": "string"
|
||||
},
|
||||
"health": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -3593,10 +3621,15 @@
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"uid": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"uid",
|
||||
"name",
|
||||
"folderUid",
|
||||
"query",
|
||||
"health",
|
||||
"type"
|
||||
@ -3636,6 +3669,9 @@
|
||||
"file": {
|
||||
"type": "string"
|
||||
},
|
||||
"folderUid": {
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
@ -3665,6 +3701,7 @@
|
||||
"required": [
|
||||
"name",
|
||||
"file",
|
||||
"folderUid",
|
||||
"rules",
|
||||
"interval"
|
||||
],
|
||||
@ -3767,6 +3804,10 @@
|
||||
"Sample": {
|
||||
"description": "Sample is a single sample belonging to a metric. It represents either a float\nsample or a histogram sample. If H is nil, it is a float sample. Otherwise,\nit is a histogram sample.",
|
||||
"properties": {
|
||||
"DropName": {
|
||||
"description": "DropName is used to indicate whether the __name__ label should be dropped\nas part of the query evaluation.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"F": {
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
@ -4446,6 +4487,18 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"UserInfo": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"uid": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "UserInfo represents user-related information, including a unique identifier and a name.",
|
||||
"type": "object"
|
||||
},
|
||||
"Userinfo": {
|
||||
"description": "The Userinfo type is an immutable encapsulation of username and\npassword details for a [URL]. An existing Userinfo value is guaranteed\nto have a username set (potentially empty, as allowed by RFC 2396),\nand optionally a password.",
|
||||
"type": "object"
|
||||
@ -4647,6 +4700,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"alertGroups": {
|
||||
"description": "AlertGroups alert groups",
|
||||
"items": {
|
||||
"$ref": "#/definitions/alertGroup",
|
||||
"type": "object"
|
||||
@ -4808,6 +4862,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"gettableAlerts": {
|
||||
"description": "GettableAlerts gettable alerts",
|
||||
"items": {
|
||||
"$ref": "#/definitions/gettableAlert",
|
||||
"type": "object"
|
||||
@ -4932,6 +4987,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"gettableSilences": {
|
||||
"description": "GettableSilences gettable silences",
|
||||
"items": {
|
||||
"$ref": "#/definitions/gettableSilence",
|
||||
"type": "object"
|
||||
@ -6515,6 +6571,43 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/ruler/grafana/api/v1/rule/{RuleUID}/versions": {
|
||||
"get": {
|
||||
"description": "Get rule versions by UID",
|
||||
"operationId": "RouteGetRuleVersionsByUID",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "RuleUID",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "GettableRuleVersions",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/GettableRuleVersions"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "ForbiddenError",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/ForbiddenError"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": " Not found."
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"ruler"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/ruler/grafana/api/v1/rules": {
|
||||
"get": {
|
||||
"description": "List rule groups",
|
||||
|
@ -1343,6 +1343,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ruler/grafana/api/v1/rule/{RuleUID}/versions": {
|
||||
"get": {
|
||||
"description": "Get rule versions by UID",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"ruler"
|
||||
],
|
||||
"operationId": "RouteGetRuleVersionsByUID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"name": "RuleUID",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "GettableRuleVersions",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/GettableRuleVersions"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "ForbiddenError",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/ForbiddenError"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": " Not found."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ruler/grafana/api/v1/rules": {
|
||||
"get": {
|
||||
"description": "List rule groups",
|
||||
@ -4088,7 +4125,9 @@
|
||||
"description": "adapted from cortex",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"uid",
|
||||
"name",
|
||||
"folderUid",
|
||||
"query",
|
||||
"health",
|
||||
"type",
|
||||
@ -4118,6 +4157,9 @@
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"folderUid": {
|
||||
"type": "string"
|
||||
},
|
||||
"health": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -4157,6 +4199,9 @@
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"uid": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -4818,6 +4863,14 @@
|
||||
"CounterResetHint": {
|
||||
"$ref": "#/definitions/CounterResetHint"
|
||||
},
|
||||
"CustomValues": {
|
||||
"description": "Holds the custom (usually upper) bounds for bucket definitions, otherwise nil.\nThis slice is interned, to be treated as immutable and copied by reference.\nThese numbers should be strictly increasing. This field is only used when the\nschema is for custom buckets, and the ZeroThreshold, ZeroCount, NegativeSpans\nand NegativeBuckets fields are not used in that case.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
}
|
||||
},
|
||||
"PositiveBuckets": {
|
||||
"description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.",
|
||||
"type": "array",
|
||||
@ -4834,7 +4887,7 @@
|
||||
}
|
||||
},
|
||||
"Schema": {
|
||||
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).",
|
||||
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8 for exponential buckets.\nThey are all for base-2 bucket schemas, where 1 is a bucket boundary in\neach case, and then each power of two is divided into 2^n logarithmic buckets.\nOr in other words, each bucket boundary is the previous boundary times\n2^(2^-n). Another valid schema number is -53 for custom buckets, defined by\nthe CustomValues field.",
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
@ -5279,6 +5332,9 @@
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"updated_by": {
|
||||
"$ref": "#/definitions/UserInfo"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
@ -5362,6 +5418,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GettableRuleVersions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/GettableExtendedRuleNode"
|
||||
}
|
||||
},
|
||||
"GettableStatus": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@ -7220,7 +7282,9 @@
|
||||
"description": "adapted from cortex",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"uid",
|
||||
"name",
|
||||
"folderUid",
|
||||
"query",
|
||||
"health",
|
||||
"type"
|
||||
@ -7230,6 +7294,9 @@
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"folderUid": {
|
||||
"type": "string"
|
||||
},
|
||||
"health": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -7251,6 +7318,9 @@
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"uid": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -7283,6 +7353,7 @@
|
||||
"required": [
|
||||
"name",
|
||||
"file",
|
||||
"folderUid",
|
||||
"rules",
|
||||
"interval"
|
||||
],
|
||||
@ -7294,6 +7365,9 @@
|
||||
"file": {
|
||||
"type": "string"
|
||||
},
|
||||
"folderUid": {
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
@ -7419,6 +7493,10 @@
|
||||
"description": "Sample is a single sample belonging to a metric. It represents either a float\nsample or a histogram sample. If H is nil, it is a float sample. Otherwise,\nit is a histogram sample.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"DropName": {
|
||||
"description": "DropName is used to indicate whether the __name__ label should be dropped\nas part of the query evaluation.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"F": {
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
@ -8097,6 +8175,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"UserInfo": {
|
||||
"type": "object",
|
||||
"title": "UserInfo represents user-related information, including a unique identifier and a name.",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"uid": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Userinfo": {
|
||||
"description": "The Userinfo type is an immutable encapsulation of username and\npassword details for a [URL]. An existing Userinfo value is guaranteed\nto have a username set (potentially empty, as allowed by RFC 2396),\nand optionally a password.",
|
||||
"type": "object"
|
||||
@ -8298,6 +8388,7 @@
|
||||
}
|
||||
},
|
||||
"alertGroups": {
|
||||
"description": "AlertGroups alert groups",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
@ -8459,6 +8550,7 @@
|
||||
}
|
||||
},
|
||||
"gettableAlerts": {
|
||||
"description": "GettableAlerts gettable alerts",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
@ -8583,6 +8675,7 @@
|
||||
}
|
||||
},
|
||||
"gettableSilences": {
|
||||
"description": "GettableSilences gettable silences",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
|
@ -550,6 +550,19 @@ func (a *AlertRuleMutators) WithUpdatedBy(uid *UserUID) AlertRuleMutator {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AlertRuleMutators) WithUID(uid string) AlertRuleMutator {
|
||||
return func(r *AlertRule) {
|
||||
r.UID = uid
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AlertRuleMutators) WithKey(key AlertRuleKey) AlertRuleMutator {
|
||||
return func(r *AlertRule) {
|
||||
r.UID = key.UID
|
||||
r.OrgID = key.OrgID
|
||||
}
|
||||
}
|
||||
|
||||
func (g *AlertRuleGenerator) GenerateLabels(min, max int, prefix string) data.Labels {
|
||||
count := max
|
||||
if min > max {
|
||||
|
@ -118,6 +118,36 @@ func (st DBstore) GetAlertRuleByUID(ctx context.Context, query *ngmodels.GetAler
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (st DBstore) GetAlertRuleVersions(ctx context.Context, key ngmodels.AlertRuleKey) ([]*ngmodels.AlertRule, error) {
|
||||
alertRules := make([]*ngmodels.AlertRule, 0)
|
||||
err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
rows, err := sess.Table(new(alertRuleVersion)).Where("rule_org_id = ? AND rule_uid = ?", key.OrgID, key.UID).Desc("id").Rows(new(alertRuleVersion))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Deserialize each rule separately in case any of them contain invalid JSON.
|
||||
for rows.Next() {
|
||||
rule := new(alertRuleVersion)
|
||||
err = rows.Scan(rule)
|
||||
if err != nil {
|
||||
st.Logger.Error("Invalid rule version found in DB store, ignoring it", "func", "GetAlertRuleVersions", "error", err)
|
||||
continue
|
||||
}
|
||||
converted, err := alertRuleToModelsAlertRule(alertRuleVersionToAlertRule(*rule), st.Logger)
|
||||
if err != nil {
|
||||
st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "GetAlertRuleVersions", "error", err, "version_id", rule.ID)
|
||||
continue
|
||||
}
|
||||
alertRules = append(alertRules, &converted)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return alertRules, nil
|
||||
}
|
||||
|
||||
// GetRuleByID retrieves models.AlertRule by ID.
|
||||
// It returns models.ErrAlertRuleNotFound if no alert rule is found for the provided ID.
|
||||
func (st DBstore) GetRuleByID(ctx context.Context, query ngmodels.GetAlertRuleByIDQuery) (result *ngmodels.AlertRule, err error) {
|
||||
|
@ -204,3 +204,34 @@ func alertRuleToAlertRuleVersion(rule alertRule) alertRuleVersion {
|
||||
Metadata: rule.Metadata,
|
||||
}
|
||||
}
|
||||
|
||||
func alertRuleVersionToAlertRule(version alertRuleVersion) alertRule {
|
||||
return alertRule{
|
||||
ID: version.ID,
|
||||
OrgID: version.RuleOrgID,
|
||||
Title: version.Title,
|
||||
Condition: version.Condition,
|
||||
Data: version.Data,
|
||||
Updated: version.Created,
|
||||
UpdatedBy: version.CreatedBy,
|
||||
IntervalSeconds: version.IntervalSeconds,
|
||||
Version: version.Version,
|
||||
UID: version.RuleUID,
|
||||
NamespaceUID: version.RuleNamespaceUID,
|
||||
// Versions do not store Dashboard\Panel as separate column.
|
||||
// However, these fields are part of annotations and information in these fields is redundant
|
||||
DashboardUID: nil,
|
||||
PanelID: nil,
|
||||
RuleGroup: version.RuleGroup,
|
||||
RuleGroupIndex: version.RuleGroupIndex,
|
||||
Record: version.Record,
|
||||
NoDataState: version.NoDataState,
|
||||
ExecErrState: version.ExecErrState,
|
||||
For: version.For,
|
||||
Annotations: version.Annotations,
|
||||
Labels: version.Labels,
|
||||
IsPaused: version.IsPaused,
|
||||
NotificationSettings: version.NotificationSettings,
|
||||
Metadata: version.Metadata,
|
||||
}
|
||||
}
|
||||
|
@ -43,3 +43,20 @@ func TestAlertRuleToModelsAlertRule(t *testing.T) {
|
||||
require.Equal(t, ngmodels.ErrorErrState, converted.ExecErrState)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAlertRuleVersionToAlertRule(t *testing.T) {
|
||||
g := ngmodels.RuleGen
|
||||
|
||||
t.Run("make sure no data is lost between conversions", func(t *testing.T) {
|
||||
for _, rule := range g.GenerateMany(100) {
|
||||
// ignore fields
|
||||
rule.DashboardUID = nil
|
||||
rule.PanelID = nil
|
||||
|
||||
r, err := alertRuleFromModelsAlertRule(rule)
|
||||
require.NoError(t, err)
|
||||
r2 := alertRuleVersionToAlertRule(alertRuleToAlertRuleVersion(r))
|
||||
require.Equal(t, r, r2)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -22,6 +22,7 @@ type RuleStore struct {
|
||||
mtx sync.Mutex
|
||||
// OrgID -> RuleGroup -> Namespace -> Rules
|
||||
Rules map[int64][]*models.AlertRule
|
||||
History map[models.AlertRuleKey][]*models.AlertRule
|
||||
Hook func(cmd any) error // use Hook if you need to intercept some query and return an error
|
||||
RecordedOps []any
|
||||
Folders map[int64][]*folder.Folder
|
||||
@ -40,6 +41,7 @@ func NewRuleStore(t *testing.T) *RuleStore {
|
||||
return nil
|
||||
},
|
||||
Folders: map[int64][]*folder.Folder{},
|
||||
History: map[models.AlertRuleKey][]*models.AlertRule{},
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,6 +52,8 @@ func (f *RuleStore) PutRule(_ context.Context, rules ...*models.AlertRule) {
|
||||
mainloop:
|
||||
for _, r := range rules {
|
||||
rgs := f.Rules[r.OrgID]
|
||||
cp := models.CopyRule(r)
|
||||
f.History[r.GetKey()] = append(f.History[r.GetKey()], cp)
|
||||
for idx, rulePtr := range rgs {
|
||||
if rulePtr.UID == r.UID {
|
||||
rgs[idx] = r
|
||||
@ -131,11 +135,7 @@ func (f *RuleStore) GetAlertRuleByUID(_ context.Context, q *models.GetAlertRuleB
|
||||
if err := f.Hook(*q); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules, ok := f.Rules[q.OrgID]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rules := f.Rules[q.OrgID]
|
||||
for _, rule := range rules {
|
||||
if rule.UID == q.UID {
|
||||
return rule, nil
|
||||
@ -372,3 +372,22 @@ func (f *RuleStore) GetNamespacesByRuleUID(ctx context.Context, orgID int64, uid
|
||||
|
||||
return namespacesMap, nil
|
||||
}
|
||||
|
||||
func (f *RuleStore) GetAlertRuleVersions(_ context.Context, key models.AlertRuleKey) ([]*models.AlertRule, error) {
|
||||
f.mtx.Lock()
|
||||
defer f.mtx.Unlock()
|
||||
|
||||
q := GenericRecordedQuery{
|
||||
Name: "GetAlertRuleVersions",
|
||||
Params: []any{key},
|
||||
}
|
||||
defer func() {
|
||||
f.RecordedOps = append(f.RecordedOps, q)
|
||||
}()
|
||||
|
||||
if err := f.Hook(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return f.History[key], nil
|
||||
}
|
||||
|
@ -241,6 +241,18 @@ func TestIntegrationAlertRulePermissions(t *testing.T) {
|
||||
require.Len(t, export.Groups, 1)
|
||||
require.Equal(t, expected, export.Groups[0])
|
||||
})
|
||||
|
||||
t.Run("Get versions of any rule", func(t *testing.T) {
|
||||
for _, groups := range allRules { // random rule from each folder
|
||||
group := groups[rand.Intn(len(groups))]
|
||||
rule := group.Rules[rand.Intn(len(group.Rules))]
|
||||
versions, status, raw := apiClient.GetRuleVersionsWithStatus(t, rule.GrafanaManagedAlert.UID)
|
||||
if assert.Equalf(t, http.StatusOK, status, "Expected status 200, got %d: %s", status, raw) {
|
||||
assert.NotEmpty(t, versions)
|
||||
assert.Equal(t, rule, versions[0]) // the first version in the collection should always be the current
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("when permissions for folder2 removed", func(t *testing.T) {
|
||||
@ -310,6 +322,12 @@ func TestIntegrationAlertRulePermissions(t *testing.T) {
|
||||
require.Equal(t, http.StatusForbidden, status)
|
||||
})
|
||||
|
||||
t.Run("Versions of rule", func(t *testing.T) {
|
||||
uid := allRules["folder2"][0].Rules[0].GrafanaManagedAlert.UID
|
||||
_, status, raw := apiClient.GetRuleVersionsWithStatus(t, uid)
|
||||
require.Equalf(t, http.StatusForbidden, status, "Expected status 403, got %d: %s", status, raw)
|
||||
})
|
||||
|
||||
t.Run("when all permissions are revoked", func(t *testing.T) {
|
||||
removeFolderPermission(t, permissionsStore, 1, userID, org.RoleEditor, "folder1")
|
||||
apiClient.ReloadCachedPermissions(t)
|
||||
@ -4315,6 +4333,95 @@ func TestIntegrationRuleUpdateAllDatabases(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationRuleVersions(t *testing.T) {
|
||||
testinfra.SQLiteIntegrationTest(t)
|
||||
|
||||
// Setup Grafana and its Database
|
||||
dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
|
||||
DisableLegacyAlerting: true,
|
||||
EnableUnifiedAlerting: true,
|
||||
EnableQuota: true,
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
})
|
||||
|
||||
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p)
|
||||
|
||||
createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{
|
||||
DefaultOrgRole: string(org.RoleEditor),
|
||||
Password: "password",
|
||||
Login: "grafana",
|
||||
})
|
||||
|
||||
apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password")
|
||||
|
||||
// Create the namespace we'll save our alerts to.
|
||||
apiClient.CreateFolder(t, "folder1", "folder1")
|
||||
|
||||
postGroupRaw, err := testData.ReadFile(path.Join("test-data", "rulegroup-1-post.json"))
|
||||
require.NoError(t, err)
|
||||
var group1 apimodels.PostableRuleGroupConfig
|
||||
require.NoError(t, json.Unmarshal(postGroupRaw, &group1))
|
||||
|
||||
// Create rule under folder1
|
||||
response := apiClient.PostRulesGroup(t, "folder1", &group1)
|
||||
|
||||
require.NotEmptyf(t, response.Created, "Expected created to be set")
|
||||
uid := response.Created[0]
|
||||
|
||||
ruleV1 := apiClient.GetRuleByUID(t, uid)
|
||||
|
||||
t.Run("should return 1 version right after creation", func(t *testing.T) {
|
||||
versions, status, raw := apiClient.GetRuleVersionsWithStatus(t, uid)
|
||||
require.Equalf(t, http.StatusOK, status, "Expected status 200, got %d: %s", status, raw)
|
||||
require.Lenf(t, versions, 1, "Expected 1 version, got %d", len(versions))
|
||||
assert.Equal(t, ruleV1, versions[0])
|
||||
})
|
||||
|
||||
group1Gettable := apiClient.GetRulesGroup(t, "folder1", group1.Name)
|
||||
group1 = convertGettableRuleGroupToPostable(group1Gettable.GettableRuleGroupConfig)
|
||||
group1.Rules[0].Annotations[util.GenerateShortUID()] = util.GenerateShortUID()
|
||||
|
||||
_ = apiClient.PostRulesGroup(t, "folder1", &group1)
|
||||
|
||||
ruleV2 := apiClient.GetRuleByUID(t, uid)
|
||||
|
||||
t.Run("should return previous versions after update", func(t *testing.T) {
|
||||
versions, status, raw := apiClient.GetRuleVersionsWithStatus(t, uid)
|
||||
require.Equalf(t, http.StatusOK, status, "Expected status 200, got %d: %s", status, raw)
|
||||
require.Lenf(t, versions, 2, "Expected 2 versions, got %d", len(versions))
|
||||
|
||||
pathsToIgnore := []string{
|
||||
"GrafanaManagedAlert.ID", // In versions ID has different value
|
||||
}
|
||||
// compare expected and actual and ignore the dynamic fields
|
||||
diff := cmp.Diff(apimodels.GettableRuleVersions{ruleV2, ruleV1}, versions, cmp.FilterPath(func(path cmp.Path) bool {
|
||||
for _, s := range pathsToIgnore {
|
||||
if strings.Contains(path.String(), s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, cmp.Ignore()))
|
||||
assert.Empty(t, diff)
|
||||
})
|
||||
|
||||
_ = apiClient.PostRulesGroup(t, "folder1", &group1) // Noop update
|
||||
|
||||
t.Run("should not add new version if rule was not changed", func(t *testing.T) {
|
||||
versions, status, raw := apiClient.GetRuleVersionsWithStatus(t, uid)
|
||||
require.Equalf(t, http.StatusOK, status, "Expected status 200, got %d: %s", status, raw)
|
||||
require.Lenf(t, versions, 2, "Expected 2 versions, got %d", len(versions))
|
||||
})
|
||||
|
||||
apiClient.DeleteRulesGroup(t, "folder1", group1.Name)
|
||||
|
||||
t.Run("should NotFound after rule was deleted", func(t *testing.T) {
|
||||
_, status, raw := apiClient.GetRuleVersionsWithStatus(t, uid)
|
||||
require.Equalf(t, http.StatusNotFound, status, "Expected status 404, got %d: %s", status, raw)
|
||||
})
|
||||
}
|
||||
|
||||
func newTestingRuleConfig(t *testing.T) apimodels.PostableRuleGroupConfig {
|
||||
interval, err := model.ParseDuration("1m")
|
||||
require.NoError(t, err)
|
||||
|
@ -475,6 +475,13 @@ func (a apiClient) PostRulesGroupWithStatus(t *testing.T, folder string, group *
|
||||
return m, resp.StatusCode, string(b)
|
||||
}
|
||||
|
||||
func (a apiClient) PostRulesGroup(t *testing.T, folder string, group *apimodels.PostableRuleGroupConfig) apimodels.UpdateRuleGroupResponse {
|
||||
t.Helper()
|
||||
m, status, raw := a.PostRulesGroupWithStatus(t, folder, group)
|
||||
requireStatusCode(t, http.StatusAccepted, status, raw)
|
||||
return m
|
||||
}
|
||||
|
||||
func (a apiClient) PostRulesExportWithStatus(t *testing.T, folder string, group *apimodels.PostableRuleGroupConfig, params *apimodels.ExportQueryParams) (int, string) {
|
||||
t.Helper()
|
||||
buf := bytes.Buffer{}
|
||||
@ -1048,6 +1055,22 @@ func (a apiClient) GetActiveAlertsWithStatus(t *testing.T) (apimodels.AlertGroup
|
||||
return sendRequest[apimodels.AlertGroups](t, req, http.StatusOK)
|
||||
}
|
||||
|
||||
func (a apiClient) GetRuleVersionsWithStatus(t *testing.T, ruleUID string) (apimodels.GettableRuleVersions, int, string) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/ruler/grafana/api/v1/rule/%s/versions", a.url, ruleUID), nil)
|
||||
require.NoError(t, err)
|
||||
return sendRequest[apimodels.GettableRuleVersions](t, req, http.StatusOK)
|
||||
}
|
||||
|
||||
func (a apiClient) GetRuleByUID(t *testing.T, ruleUID string) apimodels.GettableExtendedRuleNode {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/ruler/grafana/api/v1/rule/%s", a.url, ruleUID), nil)
|
||||
require.NoError(t, err)
|
||||
rule, status, raw := sendRequest[apimodels.GettableExtendedRuleNode](t, req, http.StatusOK)
|
||||
requireStatusCode(t, http.StatusOK, status, raw)
|
||||
return rule
|
||||
}
|
||||
|
||||
func sendRequest[T any](t *testing.T, req *http.Request, successStatusCode int) (T, int, string) {
|
||||
t.Helper()
|
||||
client := &http.Client{}
|
||||
|
@ -12861,7 +12861,9 @@
|
||||
"description": "adapted from cortex",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"uid",
|
||||
"name",
|
||||
"folderUid",
|
||||
"query",
|
||||
"health",
|
||||
"type",
|
||||
@ -12891,6 +12893,9 @@
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"folderUid": {
|
||||
"type": "string"
|
||||
},
|
||||
"health": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -12930,6 +12935,9 @@
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"uid": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -15392,6 +15400,14 @@
|
||||
"CounterResetHint": {
|
||||
"$ref": "#/definitions/CounterResetHint"
|
||||
},
|
||||
"CustomValues": {
|
||||
"description": "Holds the custom (usually upper) bounds for bucket definitions, otherwise nil.\nThis slice is interned, to be treated as immutable and copied by reference.\nThese numbers should be strictly increasing. This field is only used when the\nschema is for custom buckets, and the ZeroThreshold, ZeroCount, NegativeSpans\nand NegativeBuckets fields are not used in that case.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
}
|
||||
},
|
||||
"PositiveBuckets": {
|
||||
"description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.",
|
||||
"type": "array",
|
||||
@ -15408,7 +15424,7 @@
|
||||
}
|
||||
},
|
||||
"Schema": {
|
||||
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).",
|
||||
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8 for exponential buckets.\nThey are all for base-2 bucket schemas, where 1 is a bucket boundary in\neach case, and then each power of two is divided into 2^n logarithmic buckets.\nOr in other words, each bucket boundary is the previous boundary times\n2^(2^-n). Another valid schema number is -53 for custom buckets, defined by\nthe CustomValues field.",
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
@ -16048,6 +16064,9 @@
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"updated_by": {
|
||||
"$ref": "#/definitions/UserInfo"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
@ -16131,6 +16150,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GettableRuleVersions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/GettableExtendedRuleNode"
|
||||
}
|
||||
},
|
||||
"GettableStatus": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@ -19810,7 +19835,9 @@
|
||||
"description": "adapted from cortex",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"uid",
|
||||
"name",
|
||||
"folderUid",
|
||||
"query",
|
||||
"health",
|
||||
"type"
|
||||
@ -19820,6 +19847,9 @@
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"folderUid": {
|
||||
"type": "string"
|
||||
},
|
||||
"health": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -19841,6 +19871,9 @@
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"uid": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -19873,6 +19906,7 @@
|
||||
"required": [
|
||||
"name",
|
||||
"file",
|
||||
"folderUid",
|
||||
"rules",
|
||||
"interval"
|
||||
],
|
||||
@ -19884,6 +19918,9 @@
|
||||
"file": {
|
||||
"type": "string"
|
||||
},
|
||||
"folderUid": {
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
@ -20009,6 +20046,10 @@
|
||||
"description": "Sample is a single sample belonging to a metric. It represents either a float\nsample or a histogram sample. If H is nil, it is a float sample. Otherwise,\nit is a histogram sample.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"DropName": {
|
||||
"description": "DropName is used to indicate whether the __name__ label should be dropped\nas part of the query evaluation.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"F": {
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
@ -22032,6 +22073,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"UserInfo": {
|
||||
"type": "object",
|
||||
"title": "UserInfo represents user-related information, including a unique identifier and a name.",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"uid": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UserLookupDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -22437,6 +22490,7 @@
|
||||
}
|
||||
},
|
||||
"alertGroups": {
|
||||
"description": "AlertGroups alert groups",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
@ -22766,6 +22820,7 @@
|
||||
}
|
||||
},
|
||||
"gettableSilences": {
|
||||
"description": "GettableSilences gettable silences",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
|
@ -2956,6 +2956,9 @@
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"folderUid": {
|
||||
"type": "string"
|
||||
},
|
||||
"health": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -2995,10 +2998,15 @@
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"uid": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"uid",
|
||||
"name",
|
||||
"folderUid",
|
||||
"query",
|
||||
"health",
|
||||
"type",
|
||||
@ -5465,6 +5473,14 @@
|
||||
"CounterResetHint": {
|
||||
"$ref": "#/components/schemas/CounterResetHint"
|
||||
},
|
||||
"CustomValues": {
|
||||
"description": "Holds the custom (usually upper) bounds for bucket definitions, otherwise nil.\nThis slice is interned, to be treated as immutable and copied by reference.\nThese numbers should be strictly increasing. This field is only used when the\nschema is for custom buckets, and the ZeroThreshold, ZeroCount, NegativeSpans\nand NegativeBuckets fields are not used in that case.",
|
||||
"items": {
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"PositiveBuckets": {
|
||||
"description": "Observation counts in buckets. Each represents an absolute count and\nmust be zero or positive.",
|
||||
"items": {
|
||||
@ -5481,7 +5497,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"Schema": {
|
||||
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8. They are all for\nbase-2 bucket schemas, where 1 is a bucket boundary in each case, and\nthen each power of two is divided into 2^n logarithmic buckets. Or\nin other words, each bucket boundary is the previous boundary times\n2^(2^-n).",
|
||||
"description": "Currently valid schema numbers are -4 \u003c= n \u003c= 8 for exponential buckets.\nThey are all for base-2 bucket schemas, where 1 is a bucket boundary in\neach case, and then each power of two is divided into 2^n logarithmic buckets.\nOr in other words, each bucket boundary is the previous boundary times\n2^(2^-n). Another valid schema number is -53 for custom buckets, defined by\nthe CustomValues field.",
|
||||
"format": "int32",
|
||||
"type": "integer"
|
||||
},
|
||||
@ -6122,6 +6138,9 @@
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"updated_by": {
|
||||
"$ref": "#/components/schemas/UserInfo"
|
||||
},
|
||||
"version": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
@ -6206,6 +6225,12 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"GettableRuleVersions": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GettableExtendedRuleNode"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"GettableStatus": {
|
||||
"properties": {
|
||||
"cluster": {
|
||||
@ -9888,6 +9913,9 @@
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
},
|
||||
"folderUid": {
|
||||
"type": "string"
|
||||
},
|
||||
"health": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -9909,10 +9937,15 @@
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"uid": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"uid",
|
||||
"name",
|
||||
"folderUid",
|
||||
"query",
|
||||
"health",
|
||||
"type"
|
||||
@ -9952,6 +9985,9 @@
|
||||
"file": {
|
||||
"type": "string"
|
||||
},
|
||||
"folderUid": {
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
@ -9981,6 +10017,7 @@
|
||||
"required": [
|
||||
"name",
|
||||
"file",
|
||||
"folderUid",
|
||||
"rules",
|
||||
"interval"
|
||||
],
|
||||
@ -10083,6 +10120,10 @@
|
||||
"Sample": {
|
||||
"description": "Sample is a single sample belonging to a metric. It represents either a float\nsample or a histogram sample. If H is nil, it is a float sample. Otherwise,\nit is a histogram sample.",
|
||||
"properties": {
|
||||
"DropName": {
|
||||
"description": "DropName is used to indicate whether the __name__ label should be dropped\nas part of the query evaluation.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"F": {
|
||||
"format": "double",
|
||||
"type": "number"
|
||||
@ -12106,6 +12147,18 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"UserInfo": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"uid": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "UserInfo represents user-related information, including a unique identifier and a name.",
|
||||
"type": "object"
|
||||
},
|
||||
"UserLookupDTO": {
|
||||
"properties": {
|
||||
"avatarUrl": {
|
||||
@ -12511,6 +12564,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"alertGroups": {
|
||||
"description": "AlertGroups alert groups",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/alertGroup"
|
||||
},
|
||||
@ -12838,6 +12892,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"gettableSilences": {
|
||||
"description": "GettableSilences gettable silences",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/gettableSilence"
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user