Alerting: Add support for alpha rules apis in legacy storage
Rules created in the new api makes the rule have no group in the database, but the rule is returned in the old group api with a sentinel group name formatted with the rule uid for compatiblity with the old api. This makes the UI continue to work with the rules without a group, and the ruler will continue to work with the rules without a group. Rules are not allowed to be created in the provisioning api with a NoGroup sentinel mask, but NoGroup rules can be manipulated through both the new and old apis. Co-authored-by: William Wernert <william.wernert@grafana.com>
This commit is contained in:
committed by
Moustafa Baiou
co-authored by
William Wernert
parent
0a85a30642
commit
ca8324e62a
@@ -0,0 +1,50 @@
|
||||
package alertrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
)
|
||||
|
||||
func Authorize(ctx context.Context, ac accesscontrol.AccessControl, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
|
||||
if attr.GetResource() != ResourceInfo.GroupResource().Resource {
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
}
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "valid user is required", err
|
||||
}
|
||||
|
||||
var action accesscontrol.Evaluator
|
||||
// Default evaluator - all actions require read permissions
|
||||
defaultEvaluator := accesscontrol.EvalPermission(accesscontrol.ActionAlertingRuleRead)
|
||||
|
||||
switch attr.GetVerb() {
|
||||
case "get", "list", "watch":
|
||||
action = defaultEvaluator
|
||||
case "create":
|
||||
action = accesscontrol.EvalAll(
|
||||
defaultEvaluator,
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingRuleCreate),
|
||||
)
|
||||
case "patch", "update":
|
||||
action = accesscontrol.EvalAll(
|
||||
defaultEvaluator,
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingRuleUpdate),
|
||||
)
|
||||
case "delete", "deletecollection":
|
||||
action = accesscontrol.EvalAll(
|
||||
defaultEvaluator,
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingRuleDelete),
|
||||
)
|
||||
}
|
||||
|
||||
ok, err := ac.Evaluate(ctx, user, action)
|
||||
if ok {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "", err
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package alertrule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
model "github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
prom_model "github.com/prometheus/common/model"
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidRule = fmt.Errorf("rule is not a alerting rule")
|
||||
)
|
||||
|
||||
func convertToK8sResource(
|
||||
orgID int64,
|
||||
rule *ngmodels.AlertRule,
|
||||
provenance ngmodels.Provenance,
|
||||
namespaceMapper request.NamespaceMapper,
|
||||
) (*model.AlertRule, error) {
|
||||
if rule.Type() != ngmodels.RuleTypeAlerting {
|
||||
return nil, errInvalidRule
|
||||
}
|
||||
interval, err := prom_model.ParseDuration(fmt.Sprintf("%ds", rule.IntervalSeconds))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse interval: %w", err)
|
||||
}
|
||||
k8sRule := &model.AlertRule{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: rule.UID,
|
||||
Namespace: namespaceMapper(orgID),
|
||||
ResourceVersion: fmt.Sprint(rule.Version),
|
||||
Labels: make(map[string]string),
|
||||
},
|
||||
Spec: model.AlertRuleSpec{
|
||||
Title: rule.Title,
|
||||
Data: make(map[string]model.AlertRuleQuery),
|
||||
Trigger: model.AlertRuleIntervalTrigger{
|
||||
Interval: model.AlertRulePromDuration(interval.String()),
|
||||
},
|
||||
Labels: make(map[string]model.AlertRuleTemplateString),
|
||||
Annotations: make(map[string]model.AlertRuleTemplateString),
|
||||
NoDataState: string(rule.NoDataState),
|
||||
ExecErrState: string(rule.ExecErrState),
|
||||
MissingSeriesEvalsToResolve: rule.MissingSeriesEvalsToResolve,
|
||||
},
|
||||
}
|
||||
|
||||
if rule.IsPaused {
|
||||
k8sRule.Spec.Paused = util.Pointer(true)
|
||||
}
|
||||
|
||||
if rule.RuleGroup != "" && !ngmodels.IsNoGroupRuleGroup(rule.RuleGroup) {
|
||||
k8sRule.Labels[model.GroupLabelKey] = rule.RuleGroup
|
||||
k8sRule.Labels[model.GroupIndexLabelKey] = strconv.Itoa(rule.RuleGroupIndex)
|
||||
}
|
||||
|
||||
if rule.For != 0 {
|
||||
k8sRule.Spec.For = util.Pointer(rule.For.String())
|
||||
}
|
||||
|
||||
if rule.KeepFiringFor != 0 {
|
||||
k8sRule.Spec.KeepFiringFor = util.Pointer(rule.KeepFiringFor.String())
|
||||
}
|
||||
|
||||
if rule.PanelID != nil && rule.DashboardUID != nil &&
|
||||
*rule.PanelID > 0 && *rule.DashboardUID != "" {
|
||||
k8sRule.Spec.PanelRef = &model.AlertRuleV0alpha1SpecPanelRef{
|
||||
PanelID: *rule.PanelID,
|
||||
DashboardUID: *rule.DashboardUID,
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range rule.Annotations {
|
||||
k8sRule.Spec.Annotations[k] = model.AlertRuleTemplateString(v)
|
||||
}
|
||||
|
||||
for k, v := range rule.Labels {
|
||||
k8sRule.Spec.Labels[k] = model.AlertRuleTemplateString(v)
|
||||
}
|
||||
|
||||
for _, query := range rule.Data {
|
||||
k8sQuery := model.AlertRuleQuery{
|
||||
QueryType: query.QueryType,
|
||||
Model: query.Model,
|
||||
DatasourceUID: model.AlertRuleDatasourceUID(query.DatasourceUID),
|
||||
Source: util.Pointer(rule.Condition == query.RefID),
|
||||
}
|
||||
if time.Duration(query.RelativeTimeRange.From) > 0 || time.Duration(query.RelativeTimeRange.To) > 0 {
|
||||
k8sQuery.RelativeTimeRange = &model.AlertRuleRelativeTimeRange{
|
||||
From: model.AlertRulePromDurationWMillis(query.RelativeTimeRange.From.String()),
|
||||
To: model.AlertRulePromDurationWMillis(query.RelativeTimeRange.To.String()),
|
||||
}
|
||||
}
|
||||
k8sRule.Spec.Data[query.RefID] = k8sQuery
|
||||
}
|
||||
|
||||
for _, setting := range rule.NotificationSettings {
|
||||
nfSetting := model.AlertRuleV0alpha1SpecNotificationSettings{
|
||||
Receiver: setting.Receiver,
|
||||
GroupBy: setting.GroupBy,
|
||||
}
|
||||
if setting.GroupWait != nil {
|
||||
nfSetting.GroupWait = util.Pointer(model.AlertRulePromDuration(setting.GroupWait.String()))
|
||||
}
|
||||
if setting.GroupInterval != nil {
|
||||
nfSetting.GroupInterval = util.Pointer(model.AlertRulePromDuration(setting.GroupInterval.String()))
|
||||
}
|
||||
if setting.RepeatInterval != nil {
|
||||
nfSetting.RepeatInterval = util.Pointer(model.AlertRulePromDuration(setting.RepeatInterval.String()))
|
||||
}
|
||||
if setting.MuteTimeIntervals != nil {
|
||||
nfSetting.MuteTimeIntervals = make([]model.AlertRuleTimeIntervalRef, 0, len(setting.MuteTimeIntervals))
|
||||
for _, m := range setting.MuteTimeIntervals {
|
||||
nfSetting.MuteTimeIntervals = append(nfSetting.MuteTimeIntervals, model.AlertRuleTimeIntervalRef(m))
|
||||
}
|
||||
}
|
||||
if setting.ActiveTimeIntervals != nil {
|
||||
nfSetting.ActiveTimeIntervals = make([]model.AlertRuleTimeIntervalRef, 0, len(setting.ActiveTimeIntervals))
|
||||
for _, a := range setting.ActiveTimeIntervals {
|
||||
nfSetting.ActiveTimeIntervals = append(nfSetting.ActiveTimeIntervals, model.AlertRuleTimeIntervalRef(a))
|
||||
}
|
||||
}
|
||||
k8sRule.Spec.NotificationSettings = &nfSetting
|
||||
}
|
||||
|
||||
meta, err := utils.MetaAccessor(k8sRule)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get metadata: %w", err)
|
||||
}
|
||||
meta.SetFolder(rule.NamespaceUID)
|
||||
if rule.UpdatedBy != nil {
|
||||
meta.SetUpdatedBy(string(*rule.UpdatedBy))
|
||||
k8sRule.SetUpdatedBy(string(*rule.UpdatedBy))
|
||||
}
|
||||
meta.SetUpdatedTimestamp(&rule.Updated)
|
||||
k8sRule.SetUpdateTimestamp(rule.Updated)
|
||||
|
||||
if err := k8sRule.SetProvenanceStatus(string(provenance)); err != nil {
|
||||
return nil, fmt.Errorf("failed to set provenance status: %w", err)
|
||||
}
|
||||
|
||||
// FIXME: we don't have a creation timestamp in the domain model, so we can't set it here.
|
||||
// We should consider adding it to the domain model. Migration can set it to the Updated timestamp for existing
|
||||
// k8sRule.SetCreationTimestamp(rule.)
|
||||
|
||||
k8sRule.UID = gapiutil.CalculateClusterWideUID(k8sRule)
|
||||
return k8sRule, nil
|
||||
}
|
||||
|
||||
func convertToK8sResources(
|
||||
orgID int64,
|
||||
rules []*ngmodels.AlertRule,
|
||||
provenanceMap map[string]ngmodels.Provenance,
|
||||
namespaceMapper request.NamespaceMapper,
|
||||
continueToken string,
|
||||
) (*model.AlertRuleList, error) {
|
||||
k8sRules := &model.AlertRuleList{
|
||||
ListMeta: metav1.ListMeta{
|
||||
Continue: continueToken,
|
||||
},
|
||||
Items: make([]model.AlertRule, 0, len(rules)),
|
||||
}
|
||||
for _, rule := range rules {
|
||||
provenance := provenanceMap[rule.UID]
|
||||
k8sRule, err := convertToK8sResource(orgID, rule, provenance, namespaceMapper)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert to k8s resource: %w", err)
|
||||
}
|
||||
k8sRules.Items = append(k8sRules.Items, *k8sRule)
|
||||
}
|
||||
return k8sRules, nil
|
||||
}
|
||||
|
||||
func convertToDomainModel(orgID int64, k8sRule *model.AlertRule) (*ngmodels.AlertRule, ngmodels.Provenance, error) {
|
||||
domainRule, err := convertToBaseDomainModel(orgID, k8sRule)
|
||||
if err != nil {
|
||||
return nil, ngmodels.ProvenanceNone, fmt.Errorf("failed to convert to domain model: %w", err)
|
||||
}
|
||||
sourceProv := k8sRule.GetProvenanceStatus()
|
||||
if !slices.Contains(model.AcceptedProvenanceStatuses, sourceProv) {
|
||||
return nil, ngmodels.ProvenanceNone, fmt.Errorf("invalid provenance status: %s", sourceProv)
|
||||
}
|
||||
provenance := ngmodels.Provenance(sourceProv)
|
||||
return domainRule, provenance, nil
|
||||
}
|
||||
|
||||
func convertToBaseDomainModel(orgID int64, k8sRule *model.AlertRule) (*ngmodels.AlertRule, error) {
|
||||
domainRule := &ngmodels.AlertRule{
|
||||
OrgID: orgID,
|
||||
UID: k8sRule.Name,
|
||||
Title: k8sRule.Spec.Title,
|
||||
NamespaceUID: k8sRule.Namespace,
|
||||
Data: make([]ngmodels.AlertQuery, 0, len(k8sRule.Spec.Data)),
|
||||
IsPaused: k8sRule.Spec.Paused != nil && *k8sRule.Spec.Paused,
|
||||
Labels: make(map[string]string),
|
||||
Annotations: make(map[string]string),
|
||||
NoDataState: ngmodels.NoDataState(k8sRule.Spec.NoDataStateOrDefault()),
|
||||
ExecErrState: ngmodels.ExecutionErrorState(k8sRule.Spec.ExecErrStateOrDefault()),
|
||||
}
|
||||
|
||||
meta, err := utils.MetaAccessor(k8sRule)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get metadata: %w", err)
|
||||
}
|
||||
|
||||
if group, ok := k8sRule.Labels[model.GroupLabelKey]; ok {
|
||||
domainRule.RuleGroup = group
|
||||
}
|
||||
if groupIndexStr, ok := k8sRule.Labels[model.GroupIndexLabelKey]; ok {
|
||||
groupIndex, err := strconv.Atoi(groupIndexStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse group index: %w", err)
|
||||
}
|
||||
domainRule.RuleGroupIndex = groupIndex
|
||||
}
|
||||
|
||||
domainRule.NamespaceUID = meta.GetFolder()
|
||||
|
||||
for k, v := range k8sRule.Spec.Annotations {
|
||||
domainRule.Annotations[k] = string(v)
|
||||
}
|
||||
|
||||
for k, v := range k8sRule.Spec.Labels {
|
||||
domainRule.Labels[k] = string(v)
|
||||
}
|
||||
|
||||
if k8sRule.Spec.PanelRef != nil {
|
||||
domainRule.PanelID = &k8sRule.Spec.PanelRef.PanelID
|
||||
domainRule.DashboardUID = &k8sRule.Spec.PanelRef.DashboardUID
|
||||
}
|
||||
|
||||
if k8sRule.Spec.MissingSeriesEvalsToResolve != nil {
|
||||
src := *k8sRule.Spec.MissingSeriesEvalsToResolve
|
||||
domainRule.MissingSeriesEvalsToResolve = &src
|
||||
}
|
||||
|
||||
if k8sRule.Spec.For != nil {
|
||||
pendingPeriod, err := prom_model.ParseDuration(*k8sRule.Spec.For)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse duration: %w", err)
|
||||
}
|
||||
domainRule.For = time.Duration(pendingPeriod)
|
||||
}
|
||||
|
||||
if k8sRule.Spec.KeepFiringFor != nil {
|
||||
keepFiringFor, err := prom_model.ParseDuration(*k8sRule.Spec.KeepFiringFor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse duration: %w", err)
|
||||
}
|
||||
domainRule.KeepFiringFor = time.Duration(keepFiringFor)
|
||||
}
|
||||
|
||||
interval, err := prom_model.ParseDuration(string(k8sRule.Spec.Trigger.Interval))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse interval: %w", err)
|
||||
}
|
||||
domainRule.IntervalSeconds = int64(time.Duration(interval).Seconds())
|
||||
|
||||
for refID, query := range k8sRule.Spec.Data {
|
||||
domainQuery, err := convertToDomainQuery(query, refID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
domainRule.Data = append(domainRule.Data, domainQuery)
|
||||
if query.Source != nil && *query.Source {
|
||||
if domainRule.Condition != "" {
|
||||
return nil, fmt.Errorf("multiple queries marked as source: %s and %s", domainRule.Condition, refID)
|
||||
}
|
||||
domainRule.Condition = refID
|
||||
}
|
||||
}
|
||||
if domainRule.Condition == "" {
|
||||
return nil, fmt.Errorf("no query marked as source")
|
||||
}
|
||||
|
||||
sourceSettings := k8sRule.Spec.NotificationSettings
|
||||
if sourceSettings != nil {
|
||||
settings, err := convertNotificationSettings(sourceSettings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
domainRule.NotificationSettings = []ngmodels.NotificationSettings{settings}
|
||||
}
|
||||
|
||||
return domainRule, nil
|
||||
}
|
||||
|
||||
func convertNotificationSettings(sourceSettings *model.AlertRuleV0alpha1SpecNotificationSettings) (ngmodels.NotificationSettings, error) {
|
||||
settings := ngmodels.NotificationSettings{
|
||||
Receiver: sourceSettings.Receiver,
|
||||
GroupBy: sourceSettings.GroupBy,
|
||||
}
|
||||
if sourceSettings.GroupWait != nil {
|
||||
groupWait, err := prom_model.ParseDuration(string(*sourceSettings.GroupWait))
|
||||
if err != nil {
|
||||
return ngmodels.NotificationSettings{}, fmt.Errorf("failed to parse duration: %w", err)
|
||||
}
|
||||
settings.GroupWait = &groupWait
|
||||
}
|
||||
if sourceSettings.GroupInterval != nil {
|
||||
groupInterval, err := prom_model.ParseDuration(string(*sourceSettings.GroupInterval))
|
||||
if err != nil {
|
||||
return ngmodels.NotificationSettings{}, fmt.Errorf("failed to parse duration: %w", err)
|
||||
}
|
||||
settings.GroupInterval = &groupInterval
|
||||
}
|
||||
if sourceSettings.RepeatInterval != nil {
|
||||
repeatInterval, err := prom_model.ParseDuration(string(*sourceSettings.RepeatInterval))
|
||||
if err != nil {
|
||||
return ngmodels.NotificationSettings{}, fmt.Errorf("failed to parse duration: %w", err)
|
||||
}
|
||||
settings.RepeatInterval = &repeatInterval
|
||||
}
|
||||
if sourceSettings.MuteTimeIntervals != nil {
|
||||
settings.MuteTimeIntervals = make([]string, 0, len(sourceSettings.MuteTimeIntervals))
|
||||
for _, m := range sourceSettings.MuteTimeIntervals {
|
||||
muteInterval := string(m)
|
||||
settings.MuteTimeIntervals = append(settings.MuteTimeIntervals, muteInterval)
|
||||
}
|
||||
}
|
||||
if sourceSettings.ActiveTimeIntervals != nil {
|
||||
settings.ActiveTimeIntervals = make([]string, 0, len(sourceSettings.ActiveTimeIntervals))
|
||||
for _, a := range sourceSettings.ActiveTimeIntervals {
|
||||
activeTimeInterval := string(a)
|
||||
settings.ActiveTimeIntervals = append(settings.ActiveTimeIntervals, activeTimeInterval)
|
||||
}
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func convertToDomainQuery(query model.AlertRuleQuery, refID string) (ngmodels.AlertQuery, error) {
|
||||
modelJson, err := json.Marshal(query.Model)
|
||||
if err != nil {
|
||||
return ngmodels.AlertQuery{}, fmt.Errorf("failed to marshal model: %w", err)
|
||||
}
|
||||
domainQuery := ngmodels.AlertQuery{
|
||||
RefID: refID,
|
||||
QueryType: query.QueryType,
|
||||
DatasourceUID: string(query.DatasourceUID),
|
||||
Model: modelJson,
|
||||
}
|
||||
if query.RelativeTimeRange != nil {
|
||||
from, err := prom_model.ParseDuration(string(query.RelativeTimeRange.From))
|
||||
if err != nil {
|
||||
return ngmodels.AlertQuery{}, fmt.Errorf("failed to parse duration: %w", err)
|
||||
}
|
||||
to, err := prom_model.ParseDuration(string(query.RelativeTimeRange.To))
|
||||
if err != nil {
|
||||
return ngmodels.AlertQuery{}, fmt.Errorf("failed to parse duration: %w", err)
|
||||
}
|
||||
domainQuery.RelativeTimeRange = ngmodels.RelativeTimeRange{
|
||||
From: ngmodels.Duration(from),
|
||||
To: ngmodels.Duration(to),
|
||||
}
|
||||
}
|
||||
return domainQuery, nil
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package alertrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
model "github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
|
||||
)
|
||||
|
||||
var (
|
||||
_ grafanarest.Storage = (*legacyStorage)(nil)
|
||||
)
|
||||
|
||||
type legacyStorage struct {
|
||||
service provisioning.AlertRuleService
|
||||
namespacer request.NamespaceMapper
|
||||
tableConverter rest.TableConvertor
|
||||
}
|
||||
|
||||
func (s *legacyStorage) New() runtime.Object {
|
||||
return ResourceInfo.NewFunc()
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Destroy() {}
|
||||
|
||||
func (s *legacyStorage) NamespaceScoped() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *legacyStorage) GetSingularName() string {
|
||||
return ResourceInfo.GetSingularName()
|
||||
}
|
||||
|
||||
func (s *legacyStorage) NewList() runtime.Object {
|
||||
return ResourceInfo.NewListFunc()
|
||||
}
|
||||
|
||||
func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
|
||||
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
|
||||
}
|
||||
|
||||
func (s *legacyStorage) List(ctx context.Context, opts *internalversion.ListOptions) (runtime.Object, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules, provenanceMap, continueToken, err := s.service.ListAlertRules(ctx, user, provisioning.ListAlertRulesOptions{
|
||||
RuleType: ngmodels.RuleTypeFilterAlerting,
|
||||
Limit: opts.Limit,
|
||||
ContinueToken: opts.Continue,
|
||||
// TODO: add field selectors for filtering
|
||||
// TODO: add label selectors for filtering on group and folders
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertToK8sResources(info.OrgID, rules, provenanceMap, s.namespacer, continueToken)
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Get(ctx context.Context, name string, _ *metav1.GetOptions) (runtime.Object, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rule, provenance, err := s.service.GetAlertRule(ctx, user, name)
|
||||
if err != nil {
|
||||
if errors.Is(err, ngmodels.ErrAlertRuleNotFound) {
|
||||
return nil, k8serrors.NewNotFound(ResourceInfo.GroupResource(), name)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj, err := convertToK8sResource(info.OrgID, &rule, provenance, s.namespacer)
|
||||
if err != nil && errors.Is(err, errInvalidRule) {
|
||||
return nil, k8serrors.NewNotFound(ResourceInfo.GroupResource(), name)
|
||||
}
|
||||
return obj, err
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, _ *metav1.CreateOptions) (runtime.Object, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if createValidation != nil {
|
||||
if err := createValidation(ctx, obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
p, ok := obj.(*model.AlertRule)
|
||||
if !ok {
|
||||
return nil, k8serrors.NewBadRequest("expected valid alert rule object")
|
||||
}
|
||||
|
||||
if p.GenerateName != "" {
|
||||
return nil, fmt.Errorf("generate-name is not supported in legacy storage mode")
|
||||
}
|
||||
// TODO: move this to the validation function
|
||||
if p.Labels[model.GroupLabelKey] != "" || p.Labels[model.GroupIndexLabelKey] != "" {
|
||||
return nil, k8serrors.NewBadRequest("cannot set group when creating alert rule")
|
||||
}
|
||||
|
||||
model, provenance, err := convertToDomainModel(info.OrgID, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
created, err := s.service.CreateAlertRule(ctx, user, *model, provenance)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return convertToK8sResource(info.OrgID, &created, provenance, s.namespacer)
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
old, err := s.Get(ctx, name, nil)
|
||||
if err != nil {
|
||||
return old, false, err
|
||||
}
|
||||
|
||||
current, ok := old.(*model.AlertRule)
|
||||
if !ok {
|
||||
// this shouldn't really be possible
|
||||
return nil, false, k8serrors.NewBadRequest("expected valid alert rule object")
|
||||
}
|
||||
|
||||
obj, err := objInfo.UpdatedObject(ctx, old)
|
||||
if err != nil {
|
||||
return old, false, err
|
||||
}
|
||||
if updateValidation != nil {
|
||||
if err := updateValidation(ctx, obj, old); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
|
||||
new, ok := obj.(*model.AlertRule)
|
||||
if !ok {
|
||||
return nil, false, k8serrors.NewBadRequest("expected valid alert rule object")
|
||||
}
|
||||
if current.Labels[model.GroupLabelKey] == "" && new.Labels[model.GroupLabelKey] != "" {
|
||||
return nil, false, k8serrors.NewBadRequest("cannot set group label when updating un-grouped alert rule")
|
||||
}
|
||||
|
||||
model, provenance, err := convertToDomainModel(info.OrgID, new)
|
||||
if err != nil {
|
||||
return old, false, err
|
||||
}
|
||||
|
||||
// ignore returned rule as it doesn't contain the updated version
|
||||
_, err = s.service.UpdateAlertRule(ctx, user, *model, provenance)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
updated, provenance, err := s.service.GetAlertRule(ctx, user, name)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
rule, err := convertToK8sResource(info.OrgID, &updated, provenance, s.namespacer)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return rule, false, nil
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, opts *metav1.DeleteOptions) (runtime.Object, bool, error) {
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
old, err := s.Get(ctx, name, nil)
|
||||
if err != nil {
|
||||
return old, false, err
|
||||
}
|
||||
if deleteValidation != nil {
|
||||
if err := deleteValidation(ctx, old); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
p, ok := old.(*model.AlertRule)
|
||||
if !ok {
|
||||
return nil, false, k8serrors.NewBadRequest("expected valid recording rule object")
|
||||
}
|
||||
|
||||
sourceProv := p.GetProvenanceStatus()
|
||||
if !slices.Contains(model.AcceptedProvenanceStatuses, sourceProv) {
|
||||
return nil, false, fmt.Errorf("invalid provenance status: %s", sourceProv)
|
||||
}
|
||||
provenance := ngmodels.Provenance(sourceProv)
|
||||
|
||||
err = s.service.DeleteAlertRule(ctx, user, name, provenance)
|
||||
if err != nil {
|
||||
return old, false, err
|
||||
}
|
||||
|
||||
return old, false, nil
|
||||
}
|
||||
|
||||
func (s *legacyStorage) DeleteCollection(ctx context.Context, _ rest.ValidateObjectFunc, _ *metav1.DeleteOptions, _ *internalversion.ListOptions) (runtime.Object, error) {
|
||||
// TODO: support this once a pattern is established for bulk delete operations
|
||||
return nil, k8serrors.NewMethodNotSupported(ResourceInfo.GroupResource(), "delete")
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package alertrule
|
||||
|
||||
import (
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
|
||||
)
|
||||
|
||||
func NewStorage(legacySvc provisioning.AlertRuleService, namespacer request.NamespaceMapper) grafanarest.Storage {
|
||||
return &legacyStorage{
|
||||
service: legacySvc,
|
||||
namespacer: namespacer,
|
||||
tableConverter: rest.NewDefaultTableConvertor(ResourceInfo.GroupResource()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package alertrule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
model "github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
var kind = model.AlertRuleKind()
|
||||
|
||||
var ResourceInfo = utils.NewResourceInfo(kind.Group(), kind.Version(),
|
||||
kind.GroupVersionResource().Resource, strings.ToLower(kind.Kind()), kind.Kind(),
|
||||
func() runtime.Object { return kind.ZeroValue() },
|
||||
func() runtime.Object { return kind.ZeroListValue() },
|
||||
utils.TableColumns{},
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
package recordingrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
)
|
||||
|
||||
func Authorize(ctx context.Context, ac accesscontrol.AccessControl, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
|
||||
if attr.GetResource() != ResourceInfo.GroupResource().Resource {
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
}
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "valid user is required", err
|
||||
}
|
||||
|
||||
var action accesscontrol.Evaluator
|
||||
// Default evaluator - all actions require read permissions
|
||||
defaultEvaluator := accesscontrol.EvalPermission(accesscontrol.ActionAlertingRuleRead)
|
||||
|
||||
switch attr.GetVerb() {
|
||||
case "get", "list", "watch":
|
||||
action = defaultEvaluator
|
||||
case "create":
|
||||
action = accesscontrol.EvalAll(
|
||||
defaultEvaluator,
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingRuleCreate),
|
||||
)
|
||||
case "patch", "update":
|
||||
action = accesscontrol.EvalAll(
|
||||
defaultEvaluator,
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingRuleUpdate),
|
||||
)
|
||||
case "delete", "deletecollection":
|
||||
action = accesscontrol.EvalAll(
|
||||
defaultEvaluator,
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingRuleDelete),
|
||||
)
|
||||
}
|
||||
|
||||
ok, err := ac.Evaluate(ctx, user, action)
|
||||
if ok {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "", err
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package recordingrule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
model "github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
prom_model "github.com/prometheus/common/model"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidRule = fmt.Errorf("rule is not a recording rule")
|
||||
)
|
||||
|
||||
func convertToK8sResource(
|
||||
orgID int64,
|
||||
rule *ngmodels.AlertRule,
|
||||
provenance ngmodels.Provenance,
|
||||
namespaceMapper request.NamespaceMapper,
|
||||
) (*model.RecordingRule, error) {
|
||||
if rule.Type() != ngmodels.RuleTypeRecording {
|
||||
return nil, errInvalidRule
|
||||
}
|
||||
interval, err := prom_model.ParseDuration(fmt.Sprintf("%ds", rule.IntervalSeconds))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse interval: %w", err)
|
||||
}
|
||||
k8sRule := &model.RecordingRule{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: rule.UID,
|
||||
Namespace: namespaceMapper(orgID),
|
||||
ResourceVersion: fmt.Sprint(rule.Version),
|
||||
Labels: make(map[string]string),
|
||||
},
|
||||
Spec: model.RecordingRuleSpec{
|
||||
Title: rule.Title,
|
||||
Data: make(map[string]model.RecordingRuleQuery),
|
||||
Trigger: model.RecordingRuleIntervalTrigger{
|
||||
Interval: model.RecordingRulePromDuration(interval.String()),
|
||||
},
|
||||
Labels: make(map[string]model.RecordingRuleTemplateString),
|
||||
Metric: rule.Record.Metric,
|
||||
TargetDatasourceUID: rule.Record.TargetDatasourceUID,
|
||||
},
|
||||
}
|
||||
|
||||
if rule.IsPaused {
|
||||
k8sRule.Spec.Paused = util.Pointer(true)
|
||||
}
|
||||
|
||||
if rule.RuleGroup != "" && !ngmodels.IsNoGroupRuleGroup(rule.RuleGroup) {
|
||||
k8sRule.Labels[model.GroupLabelKey] = rule.RuleGroup
|
||||
k8sRule.Labels[model.GroupIndexLabelKey] = strconv.Itoa(rule.RuleGroupIndex)
|
||||
}
|
||||
|
||||
for k, v := range rule.Labels {
|
||||
k8sRule.Spec.Labels[k] = model.RecordingRuleTemplateString(v)
|
||||
}
|
||||
|
||||
for _, query := range rule.Data {
|
||||
k8sQuery := model.RecordingRuleQuery{
|
||||
QueryType: query.QueryType,
|
||||
Model: query.Model,
|
||||
DatasourceUID: model.RecordingRuleDatasourceUID(query.DatasourceUID),
|
||||
}
|
||||
if time.Duration(query.RelativeTimeRange.From) > 0 || time.Duration(query.RelativeTimeRange.To) > 0 {
|
||||
k8sQuery.RelativeTimeRange = &model.RecordingRuleRelativeTimeRange{
|
||||
From: model.RecordingRulePromDurationWMillis(query.RelativeTimeRange.From.String()),
|
||||
To: model.RecordingRulePromDurationWMillis(query.RelativeTimeRange.To.String()),
|
||||
}
|
||||
}
|
||||
if rule.Record != nil && rule.Record.From == query.RefID {
|
||||
k8sQuery.Source = util.Pointer(true)
|
||||
}
|
||||
k8sRule.Spec.Data[query.RefID] = k8sQuery
|
||||
}
|
||||
|
||||
meta, err := utils.MetaAccessor(k8sRule)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get metadata: %w", err)
|
||||
}
|
||||
meta.SetFolder(rule.NamespaceUID)
|
||||
if rule.UpdatedBy != nil {
|
||||
meta.SetUpdatedBy(string(*rule.UpdatedBy))
|
||||
k8sRule.SetUpdatedBy(string(*rule.UpdatedBy))
|
||||
}
|
||||
meta.SetUpdatedTimestamp(&rule.Updated)
|
||||
k8sRule.SetUpdateTimestamp(rule.Updated)
|
||||
|
||||
if err := k8sRule.SetProvenanceStatus(string(provenance)); err != nil {
|
||||
return nil, fmt.Errorf("failed to set provenance status: %w", err)
|
||||
}
|
||||
|
||||
// FIXME: we don't have a creation timestamp in the domain model, so we can't set it here.
|
||||
// We should consider adding it to the domain model. Migration can set it to the Updated timestamp for existing
|
||||
// k8sRule.SetCreationTimestamp(rule.)
|
||||
|
||||
k8sRule.UID = gapiutil.CalculateClusterWideUID(k8sRule)
|
||||
return k8sRule, nil
|
||||
}
|
||||
|
||||
func convertToK8sResources(
|
||||
orgID int64,
|
||||
rules []*ngmodels.AlertRule,
|
||||
provenanceMap map[string]ngmodels.Provenance,
|
||||
namespaceMapper request.NamespaceMapper,
|
||||
continueToken string,
|
||||
) (*model.RecordingRuleList, error) {
|
||||
k8sRules := &model.RecordingRuleList{
|
||||
ListMeta: metav1.ListMeta{
|
||||
Continue: continueToken,
|
||||
},
|
||||
Items: make([]model.RecordingRule, 0, len(rules)),
|
||||
}
|
||||
for _, rule := range rules {
|
||||
provenance := provenanceMap[rule.UID]
|
||||
k8sRule, err := convertToK8sResource(orgID, rule, provenance, namespaceMapper)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert to k8s resource: %w", err)
|
||||
}
|
||||
k8sRules.Items = append(k8sRules.Items, *k8sRule)
|
||||
}
|
||||
return k8sRules, nil
|
||||
}
|
||||
|
||||
func convertToDomainModel(orgID int64, k8sRule *model.RecordingRule) (*ngmodels.AlertRule, ngmodels.Provenance, error) {
|
||||
domainRule, err := convertToBaseDomainModel(orgID, k8sRule)
|
||||
if err != nil {
|
||||
return nil, ngmodels.ProvenanceNone, fmt.Errorf("failed to convert to domain model: %w", err)
|
||||
}
|
||||
sourceProv := k8sRule.GetProvenanceStatus()
|
||||
if !slices.Contains(model.AcceptedProvenanceStatuses, sourceProv) {
|
||||
return nil, ngmodels.ProvenanceNone, fmt.Errorf("invalid provenance status: %s", sourceProv)
|
||||
}
|
||||
provenance := ngmodels.Provenance(sourceProv)
|
||||
return domainRule, provenance, nil
|
||||
}
|
||||
|
||||
func convertToBaseDomainModel(orgID int64, k8sRule *model.RecordingRule) (*ngmodels.AlertRule, error) {
|
||||
domainRule := &ngmodels.AlertRule{
|
||||
OrgID: orgID,
|
||||
UID: k8sRule.Name,
|
||||
Title: k8sRule.Spec.Title,
|
||||
Data: make([]ngmodels.AlertQuery, 0, len(k8sRule.Spec.Data)),
|
||||
IsPaused: k8sRule.Spec.Paused != nil && *k8sRule.Spec.Paused,
|
||||
Labels: make(map[string]string),
|
||||
|
||||
Record: &ngmodels.Record{
|
||||
Metric: k8sRule.Spec.Metric,
|
||||
TargetDatasourceUID: k8sRule.Spec.TargetDatasourceUID,
|
||||
},
|
||||
}
|
||||
|
||||
if group, ok := k8sRule.Labels[model.GroupLabelKey]; ok {
|
||||
domainRule.RuleGroup = group
|
||||
}
|
||||
if groupIndexStr, ok := k8sRule.Labels[model.GroupIndexLabelKey]; ok {
|
||||
groupIndex, err := strconv.Atoi(groupIndexStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse group index: %w", err)
|
||||
}
|
||||
domainRule.RuleGroupIndex = groupIndex
|
||||
}
|
||||
|
||||
meta, err := utils.MetaAccessor(k8sRule)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get metadata: %w", err)
|
||||
}
|
||||
|
||||
domainRule.NamespaceUID = meta.GetFolder()
|
||||
|
||||
interval, err := prom_model.ParseDuration(string(k8sRule.Spec.Trigger.Interval))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse interval: %w", err)
|
||||
}
|
||||
domainRule.IntervalSeconds = int64(time.Duration(interval).Seconds())
|
||||
|
||||
for k, v := range k8sRule.Spec.Labels {
|
||||
domainRule.Labels[k] = string(v)
|
||||
}
|
||||
for refID, query := range k8sRule.Spec.Data {
|
||||
modelJson, err := json.Marshal(query.Model)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal model: %w", err)
|
||||
}
|
||||
domainQuery := ngmodels.AlertQuery{
|
||||
RefID: refID,
|
||||
QueryType: query.QueryType,
|
||||
DatasourceUID: string(query.DatasourceUID),
|
||||
Model: modelJson,
|
||||
}
|
||||
if query.RelativeTimeRange != nil {
|
||||
from, err := prom_model.ParseDuration(string(query.RelativeTimeRange.From))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse duration: %w", err)
|
||||
}
|
||||
to, err := prom_model.ParseDuration(string(query.RelativeTimeRange.To))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse duration: %w", err)
|
||||
}
|
||||
domainQuery.RelativeTimeRange = ngmodels.RelativeTimeRange{
|
||||
From: ngmodels.Duration(from),
|
||||
To: ngmodels.Duration(to),
|
||||
}
|
||||
}
|
||||
|
||||
domainRule.Data = append(domainRule.Data, domainQuery)
|
||||
|
||||
if query.Source != nil && *query.Source {
|
||||
if domainRule.Record.From != "" {
|
||||
return nil, fmt.Errorf("multiple queries marked as source: %s and %s", domainRule.Record.From, refID)
|
||||
}
|
||||
domainRule.Record.From = refID
|
||||
}
|
||||
}
|
||||
if domainRule.Record.From == "" {
|
||||
return nil, fmt.Errorf("no query marked as source")
|
||||
}
|
||||
return domainRule, nil
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package recordingrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
model "github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
)
|
||||
|
||||
var (
|
||||
_ grafanarest.Storage = (*legacyStorage)(nil)
|
||||
)
|
||||
|
||||
type legacyStorage struct {
|
||||
service provisioning.AlertRuleService
|
||||
namespacer request.NamespaceMapper
|
||||
tableConverter rest.TableConvertor
|
||||
}
|
||||
|
||||
func (s *legacyStorage) New() runtime.Object {
|
||||
return ResourceInfo.NewFunc()
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Destroy() {}
|
||||
|
||||
func (s *legacyStorage) NamespaceScoped() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *legacyStorage) GetSingularName() string {
|
||||
return ResourceInfo.GetSingularName()
|
||||
}
|
||||
|
||||
func (s *legacyStorage) NewList() runtime.Object {
|
||||
return ResourceInfo.NewListFunc()
|
||||
}
|
||||
|
||||
func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
|
||||
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
|
||||
}
|
||||
|
||||
func (s *legacyStorage) List(ctx context.Context, opts *internalversion.ListOptions) (runtime.Object, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules, provenanceMap, continueToken, err := s.service.ListAlertRules(ctx, user, provisioning.ListAlertRulesOptions{
|
||||
RuleType: ngmodels.RuleTypeFilterRecording,
|
||||
Limit: opts.Limit,
|
||||
ContinueToken: opts.Continue,
|
||||
// TODO: add field selectors for filtering
|
||||
// TODO: add label selectors for filtering on group and folders
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return convertToK8sResources(info.OrgID, rules, provenanceMap, s.namespacer, continueToken)
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Get(ctx context.Context, name string, _ *metav1.GetOptions) (runtime.Object, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rule, provenance, err := s.service.GetAlertRule(ctx, user, name)
|
||||
if err != nil {
|
||||
if errors.Is(err, ngmodels.ErrAlertRuleNotFound) {
|
||||
return nil, k8serrors.NewNotFound(ResourceInfo.GroupResource(), name)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj, err := convertToK8sResource(info.OrgID, &rule, provenance, s.namespacer)
|
||||
if err != nil && errors.Is(err, errInvalidRule) {
|
||||
return nil, k8serrors.NewNotFound(ResourceInfo.GroupResource(), name)
|
||||
}
|
||||
return obj, err
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, _ rest.ValidateObjectFunc, _ *metav1.CreateOptions) (runtime.Object, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p, ok := obj.(*model.RecordingRule)
|
||||
if !ok {
|
||||
return nil, k8serrors.NewBadRequest("expected valid recording rule object")
|
||||
}
|
||||
|
||||
if p.GenerateName != "" {
|
||||
return nil, k8serrors.NewBadRequest("generate-name is not supported in legacy storage mode")
|
||||
}
|
||||
// TODO: move this to the validation function
|
||||
if p.Labels[model.GroupLabelKey] != "" || p.Labels[model.GroupIndexLabelKey] != "" {
|
||||
return nil, k8serrors.NewBadRequest("cannot set group label when creating recording rule")
|
||||
}
|
||||
|
||||
model, provenance, err := convertToDomainModel(info.OrgID, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rule, err := s.service.CreateAlertRule(ctx, user, *model, provenance)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return convertToK8sResource(info.OrgID, &rule, provenance, s.namespacer)
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, _ rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, _ bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
old, err := s.Get(ctx, name, nil)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
current, ok := old.(*model.RecordingRule)
|
||||
if !ok {
|
||||
return nil, false, k8serrors.NewBadRequest("expected valid recording rule object")
|
||||
}
|
||||
|
||||
obj, err := objInfo.UpdatedObject(ctx, old)
|
||||
if err != nil {
|
||||
return old, false, err
|
||||
}
|
||||
if updateValidation != nil {
|
||||
if err := updateValidation(ctx, obj, old); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
|
||||
new, ok := obj.(*model.RecordingRule)
|
||||
if !ok {
|
||||
return nil, false, k8serrors.NewBadRequest("expected valid recording rule object")
|
||||
}
|
||||
// FIXME(@rwwiv): this shouldn't be necessary
|
||||
if new.Name != "" {
|
||||
new.UID = types.UID(new.Name)
|
||||
}
|
||||
// TODO: move to validation function
|
||||
if current.Labels[model.GroupLabelKey] == "" && new.Labels[model.GroupLabelKey] != "" {
|
||||
return nil, false, k8serrors.NewBadRequest("cannot set group label when updating un-grouped recording rule")
|
||||
}
|
||||
|
||||
model, provenance, err := convertToDomainModel(info.OrgID, new)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// ignore returned rule as it doesn't contain the updated version
|
||||
_, err = s.service.UpdateAlertRule(ctx, user, *model, provenance)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
updated, provenance, err := s.service.GetAlertRule(ctx, user, name)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
rule, err := convertToK8sResource(info.OrgID, &updated, provenance, s.namespacer)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return rule, true, nil
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, opts *metav1.DeleteOptions) (runtime.Object, bool, error) {
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
old, err := s.Get(ctx, name, nil)
|
||||
if err != nil {
|
||||
return old, false, err
|
||||
}
|
||||
if deleteValidation != nil {
|
||||
if err := deleteValidation(ctx, old); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
p, ok := old.(*model.RecordingRule)
|
||||
if !ok {
|
||||
return nil, false, k8serrors.NewBadRequest("expected valid recording rule object")
|
||||
}
|
||||
|
||||
sourceProv := p.GetProvenanceStatus()
|
||||
if !slices.Contains(model.AcceptedProvenanceStatuses, sourceProv) {
|
||||
return nil, false, fmt.Errorf("invalid provenance status: %s", sourceProv)
|
||||
}
|
||||
provenance := ngmodels.Provenance(sourceProv)
|
||||
|
||||
err = s.service.DeleteAlertRule(ctx, user, name, provenance)
|
||||
if err != nil {
|
||||
return old, false, err
|
||||
}
|
||||
|
||||
return old, false, nil
|
||||
}
|
||||
|
||||
func (s *legacyStorage) DeleteCollection(_ context.Context, _ rest.ValidateObjectFunc, _ *metav1.DeleteOptions, _ *internalversion.ListOptions) (runtime.Object, error) {
|
||||
// TODO: support this once a pattern is established for bulk delete operations
|
||||
return nil, k8serrors.NewMethodNotSupported(ResourceInfo.GroupResource(), "delete")
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package recordingrule
|
||||
|
||||
import (
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
|
||||
)
|
||||
|
||||
func NewStorage(legacySvc provisioning.AlertRuleService, namespacer request.NamespaceMapper) grafanarest.Storage {
|
||||
return &legacyStorage{
|
||||
service: legacySvc,
|
||||
namespacer: namespacer,
|
||||
tableConverter: rest.NewDefaultTableConvertor(ResourceInfo.GroupResource()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package recordingrule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
model "github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
var kind = model.RecordingRuleKind()
|
||||
var ResourceInfo = utils.NewResourceInfo(kind.Group(), kind.Version(),
|
||||
kind.GroupVersionResource().Resource, strings.ToLower(kind.Kind()), kind.Kind(),
|
||||
func() runtime.Object { return kind.ZeroValue() },
|
||||
func() runtime.Object { return kind.ZeroListValue() },
|
||||
utils.TableColumns{},
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
restclient "k8s.io/client-go/rest"
|
||||
|
||||
"github.com/grafana/grafana-app-sdk/app"
|
||||
appsdkapiserver "github.com/grafana/grafana-app-sdk/k8s/apiserver"
|
||||
"github.com/grafana/grafana-app-sdk/simple"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
|
||||
"github.com/grafana/grafana/apps/alerting/rules/pkg/apis"
|
||||
rulesApp "github.com/grafana/grafana/apps/alerting/rules/pkg/app"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/rules/alertrule"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/rules/recordingrule"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
var (
|
||||
_ appsdkapiserver.AppInstaller = (*AlertingRulesAppInstaller)(nil)
|
||||
_ appinstaller.AuthorizerProvider = (*AlertingRulesAppInstaller)(nil)
|
||||
_ appinstaller.LegacyStorageProvider = (*AlertingRulesAppInstaller)(nil)
|
||||
)
|
||||
|
||||
type AlertingRulesAppInstaller struct {
|
||||
appsdkapiserver.AppInstaller
|
||||
cfg *setting.Cfg
|
||||
ng *ngalert.AlertNG
|
||||
}
|
||||
|
||||
func RegisterAppInstaller(
|
||||
cfg *setting.Cfg,
|
||||
ng *ngalert.AlertNG,
|
||||
) (*AlertingRulesAppInstaller, error) {
|
||||
if ng.IsDisabled() {
|
||||
return nil, fmt.Errorf("alerting rules app installer cannot be registered when ngalert is disabled")
|
||||
}
|
||||
|
||||
installer := &AlertingRulesAppInstaller{
|
||||
cfg: cfg,
|
||||
ng: ng,
|
||||
}
|
||||
|
||||
provider := simple.NewAppProvider(apis.LocalManifest(), nil, rulesApp.New)
|
||||
|
||||
appConfig := app.Config{
|
||||
KubeConfig: restclient.Config{}, // this will be overridden by the installer's InitializeApp method
|
||||
ManifestData: *apis.LocalManifest().ManifestData,
|
||||
}
|
||||
|
||||
i, err := appsdkapiserver.NewDefaultAppInstaller(provider, appConfig, apis.ManifestGoTypeAssociator, apis.ManifestCustomRouteResponsesAssociator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
installer.AppInstaller = i
|
||||
return installer, nil
|
||||
}
|
||||
|
||||
func (a *AlertingRulesAppInstaller) GetAuthorizer() authorizer.Authorizer {
|
||||
authz := a.ng.Api.AccessControl
|
||||
return authorizer.AuthorizerFunc(
|
||||
func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
switch a.GetResource() {
|
||||
case recordingrule.ResourceInfo.GroupResource().Resource:
|
||||
return recordingrule.Authorize(ctx, authz, a)
|
||||
case alertrule.ResourceInfo.GroupResource().Resource:
|
||||
return alertrule.Authorize(ctx, authz, a)
|
||||
}
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (a *AlertingRulesAppInstaller) GetLegacyStorage(gvr schema.GroupVersionResource) grafanarest.Storage {
|
||||
namespacer := request.GetNamespaceMapper(a.cfg)
|
||||
switch gvr {
|
||||
case recordingrule.ResourceInfo.GroupVersionResource():
|
||||
return recordingrule.NewStorage(*a.ng.Api.AlertRules, namespacer)
|
||||
case alertrule.ResourceInfo.GroupVersionResource():
|
||||
return alertrule.NewStorage(*a.ng.Api.AlertRules, namespacer)
|
||||
default:
|
||||
panic("unknown legacy storage requested: " + gvr.String())
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/registry"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/advisor"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/rules"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/investigations"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/playlist"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/plugins"
|
||||
@@ -30,14 +31,15 @@ func ProvideAppInstallers(
|
||||
playlistAppInstaller *playlist.PlaylistAppInstaller,
|
||||
pluginsApplInstaller *plugins.PluginsAppInstaller,
|
||||
shorturlAppInstaller *shorturl.ShortURLAppInstaller,
|
||||
rulesAppInstaller *rules.AlertingRulesAppInstaller,
|
||||
) []appsdkapiserver.AppInstaller {
|
||||
installers := []appsdkapiserver.AppInstaller{
|
||||
playlistAppInstaller,
|
||||
pluginsApplInstaller,
|
||||
}
|
||||
installers := []appsdkapiserver.AppInstaller{playlistAppInstaller, pluginsApplInstaller}
|
||||
if features.IsEnabledGlobally(featuremgmt.FlagKubernetesShortURLs) {
|
||||
installers = append(installers, shorturlAppInstaller)
|
||||
}
|
||||
if features.IsEnabledGlobally(featuremgmt.FlagKubernetesAlertingRules) {
|
||||
installers = append(installers, rulesAppInstaller)
|
||||
}
|
||||
return installers
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/registry/apps/advisor"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/rules"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/investigations"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/playlist"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/plugins"
|
||||
@@ -20,4 +21,5 @@ var WireSet = wire.NewSet(
|
||||
notifications.RegisterApp,
|
||||
plugins.RegisterAppInstaller,
|
||||
shorturl.RegisterAppInstaller,
|
||||
rules.RegisterAppInstaller,
|
||||
)
|
||||
|
||||
+11
-2
@@ -81,6 +81,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/registry/apps"
|
||||
advisor2 "github.com/grafana/grafana/pkg/registry/apps/advisor"
|
||||
notifications2 "github.com/grafana/grafana/pkg/registry/apps/alerting/notifications"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/alerting/rules"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/investigations"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/playlist"
|
||||
"github.com/grafana/grafana/pkg/registry/apps/plugins"
|
||||
@@ -755,7 +756,11 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, pluginsAppInstaller, shortURLAppInstaller)
|
||||
alertingRulesAppInstaller, err := rules.RegisterAppInstaller(cfg, alertNG)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, pluginsAppInstaller, shortURLAppInstaller, alertingRulesAppInstaller)
|
||||
builderMetrics := builder.ProvideBuilderMetrics(registerer)
|
||||
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics)
|
||||
if err != nil {
|
||||
@@ -1344,7 +1349,11 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, pluginsAppInstaller, shortURLAppInstaller)
|
||||
alertingRulesAppInstaller, err := rules.RegisterAppInstaller(cfg, alertNG)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v2 := appregistry.ProvideAppInstallers(featureToggles, playlistAppInstaller, pluginsAppInstaller, shortURLAppInstaller, alertingRulesAppInstaller)
|
||||
builderMetrics := builder.ProvideBuilderMetrics(registerer)
|
||||
apiserverService, err := apiserver.ProvideService(cfg, featureToggles, routeRegisterImpl, tracingService, serverLockService, sqlStore, kvStore, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, dualwriteService, resourceClient, inlineSecureValueSupport, eventualRestConfigProvider, v, eventualRestConfigProvider, registerer, aggregatorRunner, v2, builderMetrics)
|
||||
if err != nil {
|
||||
|
||||
@@ -462,6 +462,13 @@ var (
|
||||
Owner: grafanaAppPlatformSquad,
|
||||
RequiresRestart: true, // changes the API routing
|
||||
},
|
||||
{
|
||||
Name: "kubernetesAlertingRules",
|
||||
Description: "Adds support for Kubernetes alerting and recording rules",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaAlertingSquad,
|
||||
RequiresRestart: true,
|
||||
},
|
||||
{
|
||||
Name: "dashboardDisableSchemaValidationV1",
|
||||
Description: "Disable schema validation for dashboards/v1",
|
||||
|
||||
@@ -59,6 +59,7 @@ kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,false,true,
|
||||
kubernetesLibraryPanels,experimental,@grafana/grafana-app-platform-squad,false,true,false
|
||||
kubernetesDashboards,GA,@grafana/dashboards-squad,false,false,true
|
||||
kubernetesShortURLs,experimental,@grafana/grafana-app-platform-squad,false,true,false
|
||||
kubernetesAlertingRules,experimental,@grafana/alerting-squad,false,true,false
|
||||
dashboardDisableSchemaValidationV1,experimental,@grafana/grafana-app-platform-squad,false,false,false
|
||||
dashboardDisableSchemaValidationV2,experimental,@grafana/grafana-app-platform-squad,false,false,false
|
||||
dashboardSchemaValidationLogging,experimental,@grafana/grafana-app-platform-squad,false,false,false
|
||||
|
||||
|
@@ -247,6 +247,10 @@ const (
|
||||
// Routes short url requests from /api to the /apis endpoint
|
||||
FlagKubernetesShortURLs = "kubernetesShortURLs"
|
||||
|
||||
// FlagKubernetesAlertingRules
|
||||
// Adds support for Kubernetes alerting and recording rules
|
||||
FlagKubernetesAlertingRules = "kubernetesAlertingRules"
|
||||
|
||||
// FlagDashboardDisableSchemaValidationV1
|
||||
// Disable schema validation for dashboards/v1
|
||||
FlagDashboardDisableSchemaValidationV1 = "dashboardDisableSchemaValidationV1"
|
||||
|
||||
@@ -1908,6 +1908,19 @@
|
||||
"requiresRestart": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kubernetesAlertingRules",
|
||||
"resourceVersion": "1754340669702",
|
||||
"creationTimestamp": "2025-08-04T20:51:09Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Adds support for Kubernetes alerting and recording rules",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/alerting-squad",
|
||||
"requiresRestart": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kubernetesAuthnMutation",
|
||||
|
||||
@@ -550,6 +550,9 @@ func (srv RulerSrv) performUpdateAlertRules(ctx context.Context, c *contextmodel
|
||||
updates := make([]ngmodels.UpdateRule, 0, len(finalChanges.Update))
|
||||
for _, update := range finalChanges.Update {
|
||||
logger.Debug("Updating rule", "rule_uid", update.New.UID, "diff", update.Diff.String())
|
||||
if ngmodels.IsNoGroupRuleGroup(update.Existing.RuleGroup) && !ngmodels.IsNoGroupRuleGroup(update.New.RuleGroup) {
|
||||
return fmt.Errorf("%w: cannot move rule out of this group", ngmodels.ErrAlertRuleFailedValidation)
|
||||
}
|
||||
updates = append(updates, ngmodels.UpdateRule{
|
||||
Existing: update.Existing,
|
||||
New: *update.New,
|
||||
|
||||
@@ -329,7 +329,9 @@ func ValidateRuleGroup(
|
||||
return nil, errors.New("rule group name cannot be empty")
|
||||
}
|
||||
|
||||
if len(ruleGroupConfig.Name) > store.AlertRuleMaxRuleGroupNameLength {
|
||||
isNoGroupRuleGroup := ngmodels.IsNoGroupRuleGroup(ruleGroupConfig.Name)
|
||||
|
||||
if len(ruleGroupConfig.Name) > store.AlertRuleMaxRuleGroupNameLength && !isNoGroupRuleGroup {
|
||||
return nil, fmt.Errorf("rule group name is too long. Max length is %d", store.AlertRuleMaxRuleGroupNameLength)
|
||||
}
|
||||
|
||||
@@ -345,6 +347,11 @@ func ValidateRuleGroup(
|
||||
|
||||
// TODO should we validate that interval is >= cfg.MinInterval? Currently, we allow to save but fix the specified interval if it is < cfg.MinInterval
|
||||
|
||||
// If the rule group is reserved for no-group rules, we cannot have multiple rules in it.
|
||||
if isNoGroupRuleGroup && len(ruleGroupConfig.Rules) > 1 {
|
||||
return nil, fmt.Errorf("rule group %s is reserved for no-group rules and cannot be used for rule groups with multiple rules", ruleGroupConfig.Name)
|
||||
}
|
||||
|
||||
result := make([]*ngmodels.AlertRuleWithOptionals, 0, len(ruleGroupConfig.Rules))
|
||||
uids := make(map[string]int, cap(result))
|
||||
for idx := range ruleGroupConfig.Rules {
|
||||
|
||||
@@ -281,6 +281,55 @@ func NewUserUID(requester interface{ GetIdentifier() string }) *UserUID {
|
||||
return &userUID
|
||||
}
|
||||
|
||||
const (
|
||||
NoGroupPrefix = "no_group_for_rule_"
|
||||
NoGroupNameLength = 200
|
||||
)
|
||||
|
||||
// NoGroupRuleGroup is a special rule group that is used to represent rules that do not belong to any group.
|
||||
type NoGroupRuleGroup struct {
|
||||
ruleUID string
|
||||
}
|
||||
|
||||
func NewNoGroupRuleGroup(ruleUID string) (*NoGroupRuleGroup, error) {
|
||||
// Generate a "no group" string that exceeds 190 char limit to fail validation
|
||||
// This is to ensure that the rule group is not created in the database.
|
||||
if len(ruleUID) > NoGroupNameLength-len(NoGroupPrefix) {
|
||||
return nil, fmt.Errorf("rule UID is too long: %s", ruleUID)
|
||||
}
|
||||
return &NoGroupRuleGroup{ruleUID: ruleUID}, nil
|
||||
}
|
||||
|
||||
func (ruleGroup *NoGroupRuleGroup) String() string {
|
||||
sb := strings.Builder{}
|
||||
sb.WriteString(NoGroupPrefix)
|
||||
sb.WriteString(ruleGroup.ruleUID)
|
||||
for sb.Len() < NoGroupNameLength {
|
||||
sb.WriteRune('*')
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (ruleGroup *NoGroupRuleGroup) GetRuleUID() string {
|
||||
return ruleGroup.ruleUID
|
||||
}
|
||||
|
||||
func IsNoGroupRuleGroup(ruleGroup string) bool {
|
||||
return strings.HasPrefix(ruleGroup, NoGroupPrefix) && len(ruleGroup) == NoGroupNameLength &&
|
||||
strings.Count(ruleGroup, "*") >= (NoGroupNameLength-len(NoGroupPrefix)-util.MaxUIDLength)
|
||||
}
|
||||
|
||||
func ParseNoRuleGroup(ruleGroup string) (*NoGroupRuleGroup, error) {
|
||||
if !IsNoGroupRuleGroup(ruleGroup) {
|
||||
return nil, fmt.Errorf("rule group %s is not a no group rule group", ruleGroup)
|
||||
}
|
||||
ruleUID := strings.TrimRight(strings.TrimPrefix(ruleGroup, NoGroupPrefix), "*")
|
||||
if err := util.ValidateUID(ruleUID); err != nil {
|
||||
return nil, fmt.Errorf("rule group %s is not a no group rule group, rule uid could not be parsed: %w", ruleGroup, err)
|
||||
}
|
||||
return &NoGroupRuleGroup{ruleUID: ruleUID}, nil
|
||||
}
|
||||
|
||||
// AlertRule is the model for alert rules in unified alerting.
|
||||
type AlertRule struct {
|
||||
ID int64
|
||||
|
||||
@@ -314,7 +314,7 @@ func (a *AlertRuleMutators) WithIntervalSeconds(seconds int64) AlertRuleMutator
|
||||
}
|
||||
}
|
||||
|
||||
// WithIntervalMatching mutator that generates random interval and `for` duration that are times of the provided base interval.
|
||||
// WithIntervalMatching mutator that generates random interval and `for` duration that are multiples of the provided base interval.
|
||||
func (a *AlertRuleMutators) WithIntervalMatching(baseInterval time.Duration) AlertRuleMutator {
|
||||
return func(rule *AlertRule) {
|
||||
rule.IntervalSeconds = int64(baseInterval.Seconds()) * (rand.Int63n(10) + 1)
|
||||
|
||||
@@ -242,10 +242,13 @@ func (service *AlertRuleService) GetAlertRuleWithFolderFullpath(ctx context.Cont
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateAlertRule creates a new alert rule. This function will ignore any
|
||||
// interval that is set in the rule struct and use the already existing group
|
||||
// interval or the default one.
|
||||
// CreateAlertRule creates a new alert rule. For normal rule groups, this function will ignore any
|
||||
// interval that is set in the rule struct and use the already existing group interval or the default one.
|
||||
func (service *AlertRuleService) CreateAlertRule(ctx context.Context, user identity.Requester, rule models.AlertRule, provenance models.Provenance) (models.AlertRule, error) {
|
||||
if models.IsNoGroupRuleGroup(rule.RuleGroup) {
|
||||
return models.AlertRule{}, fmt.Errorf("%w: rules must have a valid group", models.ErrAlertRuleFailedValidation)
|
||||
}
|
||||
|
||||
if rule.UID == "" {
|
||||
rule.UID = util.GenerateShortUID()
|
||||
} else if err := util.ValidateUID(rule.UID); err != nil {
|
||||
@@ -281,7 +284,9 @@ func (service *AlertRuleService) CreateAlertRule(ctx context.Context, user ident
|
||||
interval = existingGroup[0].IntervalSeconds
|
||||
}
|
||||
}
|
||||
rule.IntervalSeconds = interval
|
||||
if rule.RuleGroup != "" {
|
||||
rule.IntervalSeconds = interval
|
||||
}
|
||||
err = rule.SetDashboardAndPanelFromAnnotations()
|
||||
if err != nil {
|
||||
return models.AlertRule{}, err
|
||||
@@ -464,6 +469,11 @@ func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, user iden
|
||||
return err
|
||||
}
|
||||
|
||||
// If the rule group is reserved for no-group rules, we cannot have multiple rules in it.
|
||||
if models.IsNoGroupRuleGroup(group.Title) && len(group.Rules) > 1 {
|
||||
return fmt.Errorf("rule group %s is reserved for no-group rules and cannot be used for rule groups with multiple rules", group.Title)
|
||||
}
|
||||
|
||||
for _, rule := range group.Rules {
|
||||
if rule.UID == "" {
|
||||
// if empty the UID will be generated before save
|
||||
@@ -653,6 +663,9 @@ func (service *AlertRuleService) persistDelta(ctx context.Context, user identity
|
||||
},
|
||||
})
|
||||
}
|
||||
if models.IsNoGroupRuleGroup(update.Existing.RuleGroup) && !models.IsNoGroupRuleGroup(update.New.RuleGroup) {
|
||||
return fmt.Errorf("%w: cannot move rule out of this group", models.ErrAlertRuleFailedValidation)
|
||||
}
|
||||
updates = append(updates, models.UpdateRule{
|
||||
Existing: update.Existing,
|
||||
New: *update.New,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -748,6 +749,124 @@ func TestIntegrationAlertRuleService(t *testing.T) {
|
||||
|
||||
require.ErrorIs(t, err, models.ErrQuotaReached)
|
||||
})
|
||||
|
||||
t.Run("alert rules created without a group should be considered NoGroup rules", func(t *testing.T) {
|
||||
rule := createNoGroupRule("test-no-group-rule", orgID, "my-namespace")
|
||||
// This is the way legacy storage creates rules without a group
|
||||
rule.RuleGroup = ""
|
||||
rule, err := ruleService.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(60), rule.IntervalSeconds)
|
||||
|
||||
rule, _, err = ruleService.GetAlertRule(context.Background(), u, rule.UID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, models.IsNoGroupRuleGroup(rule.RuleGroup), "Rule should be considered NoGroup rule")
|
||||
|
||||
ruleGroup, err := ruleService.GetRuleGroup(context.Background(), u, rule.NamespaceUID, rule.RuleGroup)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ruleGroup)
|
||||
require.True(t, models.IsNoGroupRuleGroup(ruleGroup.Title), "Rule group should be NoGroup rule group")
|
||||
require.Len(t, ruleGroup.Rules, 1, "Rule group should only contain one NoGroup rule")
|
||||
})
|
||||
|
||||
t.Run("multiple alert rules created without a group should be considered NoGroup rules, and be returned in separate groups", func(t *testing.T) {
|
||||
rule := createNoGroupRule("test-no-group-rule-1", orgID, "my-namespace")
|
||||
// This is the way legacy storage creates rules without a group
|
||||
rule.RuleGroup = ""
|
||||
rule, err := ruleService.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(60), rule.IntervalSeconds)
|
||||
|
||||
rule2 := createNoGroupRule("test-no-group-rule-2", orgID, "my-namespace")
|
||||
// This is the way legacy storage creates rules without a group
|
||||
rule2.RuleGroup = ""
|
||||
rule2, err = ruleService.CreateAlertRule(context.Background(), u, rule2, models.ProvenanceNone)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(60), rule.IntervalSeconds)
|
||||
|
||||
rule, _, err = ruleService.GetAlertRule(context.Background(), u, rule.UID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, models.IsNoGroupRuleGroup(rule.RuleGroup), "Rule should be considered NoGroup rule")
|
||||
|
||||
rule2, _, err = ruleService.GetAlertRule(context.Background(), u, rule2.UID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, models.IsNoGroupRuleGroup(rule2.RuleGroup), "Rule should be considered NoGroup rule")
|
||||
|
||||
require.NotEqual(t, rule.RuleGroup, rule2.RuleGroup, "Both rules should have different NoGroup rule groups")
|
||||
|
||||
ruleGroup, err := ruleService.GetRuleGroup(context.Background(), u, rule.NamespaceUID, rule.RuleGroup)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ruleGroup)
|
||||
require.True(t, models.IsNoGroupRuleGroup(ruleGroup.Title), "Rule group should be NoGroup rule group")
|
||||
require.Len(t, ruleGroup.Rules, 1, "Rule group should only contain one NoGroup rule")
|
||||
|
||||
ruleGroup2, err := ruleService.GetRuleGroup(context.Background(), u, rule2.NamespaceUID, rule2.RuleGroup)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ruleGroup2)
|
||||
require.True(t, models.IsNoGroupRuleGroup(ruleGroup2.Title), "Rule group should be NoGroup rule group")
|
||||
require.Len(t, ruleGroup2.Rules, 1, "Rule group should only contain one NoGroup rule")
|
||||
|
||||
require.NotEqual(t, ruleGroup, ruleGroup2, "Both NoGroup rule groups should be different")
|
||||
})
|
||||
|
||||
t.Run("setting the group interval on NoGroup rules should only affect 1 rule", func(t *testing.T) {
|
||||
rule := createNoGroupRule("test-no-group-rule-1", orgID, "my-namespace")
|
||||
// This is the way legacy storage creates rules without a group
|
||||
rule.RuleGroup = ""
|
||||
rule, err := ruleService.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(60), rule.IntervalSeconds)
|
||||
|
||||
rule2 := createNoGroupRule("test-no-group-rule-2", orgID, "my-namespace")
|
||||
// This is the way legacy storage creates rules without a group
|
||||
rule2.RuleGroup = ""
|
||||
rule2, err = ruleService.CreateAlertRule(context.Background(), u, rule2, models.ProvenanceNone)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(60), rule.IntervalSeconds)
|
||||
|
||||
rule, _, err = ruleService.GetAlertRule(context.Background(), u, rule.UID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, models.IsNoGroupRuleGroup(rule.RuleGroup), "Rule should be considered NoGroup rule")
|
||||
|
||||
rule2, _, err = ruleService.GetAlertRule(context.Background(), u, rule2.UID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, models.IsNoGroupRuleGroup(rule2.RuleGroup), "Rule should be considered NoGroup rule")
|
||||
|
||||
require.NotEqual(t, rule.RuleGroup, rule2.RuleGroup, "Both rules should have different NoGroup rule groups")
|
||||
|
||||
var updatedInterval int64 = 120
|
||||
err = ruleService.UpdateRuleGroup(context.Background(), u, rule.NamespaceUID, rule.RuleGroup, updatedInterval)
|
||||
require.NoError(t, err)
|
||||
|
||||
rule, _, err = ruleService.GetAlertRule(context.Background(), u, rule.UID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updatedInterval, rule.IntervalSeconds, "Rule should have updated interval")
|
||||
rule2, _, err = ruleService.GetAlertRule(context.Background(), u, rule2.UID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(60), rule2.IntervalSeconds, "Rule should not have updated interval")
|
||||
require.NotEqual(t, updatedInterval, rule2.IntervalSeconds, "Both rules should not have updated interval")
|
||||
})
|
||||
|
||||
t.Run("alert rule in NoGroup should be updated correctly", func(t *testing.T) {
|
||||
rule := createNoGroupRule("test-no-group-rule", orgID, "my-namespace")
|
||||
// This is the way legacy storage creates rules without a group
|
||||
rule.RuleGroup = ""
|
||||
rule, err := ruleService.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(60), rule.IntervalSeconds)
|
||||
|
||||
// get the actual calculated group for use with the api
|
||||
rule, _, err = ruleService.GetAlertRule(context.Background(), u, rule.UID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, models.IsNoGroupRuleGroup(rule.RuleGroup), "Rule should be considered NoGroup rule")
|
||||
|
||||
err = ruleService.UpdateRuleGroup(context.Background(), u, rule.NamespaceUID, rule.RuleGroup, 120)
|
||||
require.NoError(t, err)
|
||||
|
||||
rule, _, err = ruleService.GetAlertRule(context.Background(), u, rule.UID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(120), rule.IntervalSeconds)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationCreateAlertRule(t *testing.T) {
|
||||
@@ -1028,6 +1147,25 @@ func TestIntegrationCreateAlertRule(t *testing.T) {
|
||||
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("should not allow creating with a NoGroup Rule", func(t *testing.T) {
|
||||
// NoGroup rules are not allowed to be created directly via provisioning, they must be created via new k8s apis
|
||||
ruleWNoGroup := createNoGroupRule("test_No_group_create_disallowed", orgID, "test-no-group-ns")
|
||||
_, err := ruleService.CreateAlertRule(context.Background(), u, ruleWNoGroup, models.ProvenanceNone)
|
||||
require.ErrorIs(t, err, models.ErrAlertRuleFailedValidation)
|
||||
require.ErrorContains(t, err, "rules must have a valid group")
|
||||
})
|
||||
|
||||
t.Run("should allow creating Rule without a group", func(t *testing.T) {
|
||||
ruleWNoGroup := createNoGroupRule("test_No_group_create_allowed", orgID, "test-no-group-ns")
|
||||
ruleWNoGroup.RuleGroup = "" // This is the way legacy storage creates rules without a group
|
||||
_, err := ruleService.CreateAlertRule(context.Background(), u, ruleWNoGroup, models.ProvenanceNone)
|
||||
require.NoError(t, err)
|
||||
// We should be able to retrieve the rule and see that it is a NoGroup rule
|
||||
retrievedRule, _, err := ruleService.GetAlertRule(context.Background(), u, ruleWNoGroup.UID)
|
||||
require.NoError(t, err)
|
||||
require.True(t, models.IsNoGroupRuleGroup(retrievedRule.RuleGroup), "Rule should be considered NoGroup rule")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateAlertRule(t *testing.T) {
|
||||
@@ -1132,7 +1270,7 @@ func TestUpdateAlertRule(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("when there are no changes it should be successful", func(t *testing.T) {
|
||||
// For this test we will not change the rule, and we will not use "admin" (CanWriteAllRulesFunc)
|
||||
// For this test we will not change the rule, and we will not use "admin" (CanWriteAllRules)
|
||||
// permissions. The response of the service should still be successful.
|
||||
service, ruleStore, _, ac := initServiceWithData(t)
|
||||
|
||||
@@ -1155,6 +1293,29 @@ func TestUpdateAlertRule(t *testing.T) {
|
||||
require.Empty(t, updates)
|
||||
})
|
||||
})
|
||||
|
||||
// NoGroup-specific tests for UpdateAlertRule
|
||||
t.Run("NoGroup: UpdateAlertRule preserves interval and sentinel group", func(t *testing.T) {
|
||||
service, ruleStore, provenanceStore, ac := initService(t)
|
||||
ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { return true, nil }
|
||||
|
||||
rule := createNoGroupRule("nogroup-update", orgID, "my-namespace")
|
||||
_, err := ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(u), []models.AlertRule{rule})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, provenanceStore.SetProvenance(context.Background(), &rule, orgID, models.ProvenanceNone))
|
||||
|
||||
// mutate fields and attempt to change interval via UpdateAlertRule
|
||||
rule.Title = "nogroup-update-new"
|
||||
originalInterval := int64(60)
|
||||
require.Equal(t, originalInterval, rule.IntervalSeconds)
|
||||
rule.IntervalSeconds = originalInterval + 60
|
||||
|
||||
updated, err := service.UpdateAlertRule(context.Background(), u, rule, models.ProvenanceNone)
|
||||
require.NoError(t, err)
|
||||
require.True(t, models.IsNoGroupRuleGroup(updated.RuleGroup))
|
||||
require.Equal(t, "nogroup-update-new", updated.Title)
|
||||
require.Equal(t, originalInterval, updated.IntervalSeconds)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteAlertRule(t *testing.T) {
|
||||
@@ -1248,6 +1409,62 @@ func TestDeleteAlertRule(t *testing.T) {
|
||||
require.Empty(t, deletes)
|
||||
})
|
||||
})
|
||||
|
||||
// NoGroup-specific behaviors
|
||||
t.Run("deleting a NoGroup rule removes only that rule", func(t *testing.T) {
|
||||
service, ruleStore, provenanceStore, ac := initService(t)
|
||||
ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { return true, nil }
|
||||
|
||||
// create two NoGroup rules in the same namespace (distinct sentinel groups)
|
||||
r1 := createNoGroupRule("nogroup-del-1", orgID, "my-namespace")
|
||||
r2 := createNoGroupRule("nogroup-del-2", orgID, "my-namespace")
|
||||
_, err := ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(u), []models.AlertRule{r1, r2})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, provenanceStore.SetProvenance(context.Background(), &r1, orgID, models.ProvenanceNone))
|
||||
require.NoError(t, provenanceStore.SetProvenance(context.Background(), &r2, orgID, models.ProvenanceNone))
|
||||
|
||||
err = service.DeleteAlertRule(context.Background(), u, r1.UID, models.ProvenanceNone)
|
||||
require.NoError(t, err)
|
||||
|
||||
deletes := getDeleteQueries(ruleStore)
|
||||
require.Len(t, deletes, 1)
|
||||
uids := deletes[0].Params[3].([]string)
|
||||
require.Contains(t, uids, r1.UID)
|
||||
|
||||
// verify r2 remains in store
|
||||
remaining := ruleStore.Rules[orgID]
|
||||
require.Len(t, remaining, 1)
|
||||
require.Equal(t, r2.UID, remaining[0].UID)
|
||||
// and its sentinel group remains intact
|
||||
require.True(t, models.IsNoGroupRuleGroup(remaining[0].RuleGroup))
|
||||
})
|
||||
|
||||
t.Run("when user cannot write all rules, deleting a NoGroup rule authorizes and succeeds", func(t *testing.T) {
|
||||
service, ruleStore, provenanceStore, ac := initService(t)
|
||||
ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { return false, nil }
|
||||
|
||||
r := createNoGroupRule("nogroup-del-auth", orgID, "my-namespace")
|
||||
_, err := ruleStore.InsertAlertRules(context.Background(), models.NewUserUID(u), []models.AlertRule{r})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, provenanceStore.SetProvenance(context.Background(), &r, orgID, models.ProvenanceNone))
|
||||
|
||||
ac.AuthorizeRuleChangesFunc = func(ctx context.Context, user identity.Requester, change *store.GroupDelta) error {
|
||||
// expect single delete and affected group contains exactly the rule
|
||||
require.Len(t, change.Delete, 1)
|
||||
require.Contains(t, change.AffectedGroups, change.GroupKey)
|
||||
require.Len(t, change.AffectedGroups[change.GroupKey], 1)
|
||||
require.Equal(t, r.UID, change.AffectedGroups[change.GroupKey][0].UID)
|
||||
return nil
|
||||
}
|
||||
|
||||
err = service.DeleteAlertRule(context.Background(), u, r.UID, models.ProvenanceNone)
|
||||
require.NoError(t, err)
|
||||
|
||||
deletes := getDeleteQueries(ruleStore)
|
||||
require.Len(t, deletes, 1)
|
||||
uids := deletes[0].Params[3].([]string)
|
||||
require.Contains(t, uids, r.UID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetAlertRule(t *testing.T) {
|
||||
@@ -1420,6 +1637,52 @@ func TestGetRuleGroup(t *testing.T) {
|
||||
assert.Len(t, ac.Calls, 1)
|
||||
assert.Equal(t, "CanReadAllRules", ac.Calls[0].Method)
|
||||
})
|
||||
|
||||
// NoGroup-specific behaviors
|
||||
// A NoGroup rule should be returned as a one-rule group addressed by its sentinel group title
|
||||
t.Run("should return NoGroup rule group with exactly one rule", func(t *testing.T) {
|
||||
service, ruleStore, _, ac := initService(t)
|
||||
ac.CanReadAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { return true, nil }
|
||||
|
||||
rule := createNoGroupRule("nogroup-rule-1", orgID, "my-namespace")
|
||||
ruleStore.Rules = map[int64][]*models.AlertRule{
|
||||
orgID: {&rule},
|
||||
}
|
||||
|
||||
group, err := service.GetRuleGroup(context.Background(), u, rule.NamespaceUID, rule.RuleGroup)
|
||||
require.NoError(t, err)
|
||||
require.True(t, models.IsNoGroupRuleGroup(group.Title))
|
||||
require.Equal(t, rule.NamespaceUID, group.FolderUID)
|
||||
require.Equal(t, rule.IntervalSeconds, group.Interval)
|
||||
require.Len(t, group.Rules, 1)
|
||||
require.Equal(t, rule.UID, group.Rules[0].UID)
|
||||
})
|
||||
|
||||
// Multiple NoGroup rules in the same namespace must produce separate sentinel groups
|
||||
t.Run("should return distinct NoGroup groups for multiple rules", func(t *testing.T) {
|
||||
service, ruleStore, _, ac := initService(t)
|
||||
ac.CanReadAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) { return true, nil }
|
||||
|
||||
rule1 := createNoGroupRule("nogroup-rule-a", orgID, "my-namespace")
|
||||
rule2 := createNoGroupRule("nogroup-rule-b", orgID, "my-namespace")
|
||||
require.NotEqual(t, rule1.RuleGroup, rule2.RuleGroup)
|
||||
|
||||
ruleStore.Rules = map[int64][]*models.AlertRule{
|
||||
orgID: {&rule1, &rule2},
|
||||
}
|
||||
|
||||
group1, err := service.GetRuleGroup(context.Background(), u, rule1.NamespaceUID, rule1.RuleGroup)
|
||||
require.NoError(t, err)
|
||||
require.True(t, models.IsNoGroupRuleGroup(group1.Title))
|
||||
require.Len(t, group1.Rules, 1)
|
||||
require.Equal(t, rule1.UID, group1.Rules[0].UID)
|
||||
|
||||
group2, err := service.GetRuleGroup(context.Background(), u, rule2.NamespaceUID, rule2.RuleGroup)
|
||||
require.NoError(t, err)
|
||||
require.True(t, models.IsNoGroupRuleGroup(group2.Title))
|
||||
require.Len(t, group2.Rules, 1)
|
||||
require.Equal(t, rule2.UID, group2.Rules[0].UID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestListAlertRules(t *testing.T) {
|
||||
@@ -1761,6 +2024,36 @@ func TestReplaceGroup(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "new", rule.Metadata.PrometheusStyleRule.OriginalRuleDefinition)
|
||||
})
|
||||
|
||||
// NoGroup rule group should not allow more than one rule
|
||||
t.Run("should reject multiple rules in a NoGroup rule group", func(t *testing.T) {
|
||||
service, _, _, _ := initServiceWithData(t)
|
||||
|
||||
// Build a NoGroup group title and attempt to place 2 rules under it
|
||||
group := createNoGroupRuleGroup("nogroup-multi", orgID, "my-namespace")
|
||||
second := createNoGroupRule("nogroup-second", orgID, "my-namespace")
|
||||
// Ensure the second rule is assigned to the same sentinel group
|
||||
second.RuleGroup = group.Title
|
||||
group.Rules = append(group.Rules, second)
|
||||
|
||||
err := service.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceNone)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "cannot be used for rule groups with multiple rules")
|
||||
})
|
||||
|
||||
t.Run("should reject changing the group name in a NoGroup rule group", func(t *testing.T) {
|
||||
service, store, _, _ := initServiceWithData(t)
|
||||
|
||||
// Create a NoGroup rule and attempt to change its group name
|
||||
groupSeed := createNoGroupRuleGroup("nogroup-multi", orgID, "my-namespace")
|
||||
store.Rules[orgID] = []*models.AlertRule{models.CopyRule(&groupSeed.Rules[0])}
|
||||
// change the group name away from the sentinel value
|
||||
groupSeed.Title = "some-other-group" // not the sentinel group name
|
||||
|
||||
err := service.ReplaceRuleGroup(context.Background(), u, groupSeed, models.ProvenanceNone)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "cannot move rule out of this group")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteRuleGroup(t *testing.T) {
|
||||
@@ -2162,6 +2455,56 @@ func dummyRule(title string, orgID int64) models.AlertRule {
|
||||
return createTestRule(title, "my-cool-group", orgID, "my-namespace")
|
||||
}
|
||||
|
||||
func createNoGroupRuleGroup(title string, orgID int64, namespace string) models.AlertRuleGroup {
|
||||
uid := util.GenerateShortUID()
|
||||
group, err := models.NewNoGroupRuleGroup(uid)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to create NoGroupRuleGroup: %v", err))
|
||||
}
|
||||
|
||||
return models.AlertRuleGroup{
|
||||
Title: group.String(),
|
||||
Interval: 60,
|
||||
FolderUID: namespace,
|
||||
Rules: []models.AlertRule{
|
||||
createNoGroupRule(title, orgID, namespace),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createNoGroupRule(title string, orgID int64, namespace string) models.AlertRule {
|
||||
uid := util.GenerateShortUID()
|
||||
group, err := models.NewNoGroupRuleGroup(uid)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to create NoGroupRuleGroup: %v", err))
|
||||
}
|
||||
|
||||
return models.AlertRule{
|
||||
UID: uid,
|
||||
OrgID: orgID,
|
||||
Title: title,
|
||||
Condition: "A",
|
||||
Version: 1,
|
||||
IntervalSeconds: 60,
|
||||
Data: []models.AlertQuery{
|
||||
{
|
||||
RefID: "A",
|
||||
Model: json.RawMessage("{}"),
|
||||
DatasourceUID: expr.DatasourceUID,
|
||||
RelativeTimeRange: models.RelativeTimeRange{
|
||||
From: models.Duration(60),
|
||||
To: models.Duration(0),
|
||||
},
|
||||
},
|
||||
},
|
||||
NamespaceUID: namespace,
|
||||
RuleGroup: group.String(),
|
||||
For: time.Second * 60,
|
||||
NoDataState: models.OK,
|
||||
ExecErrState: models.OkErrState,
|
||||
}
|
||||
}
|
||||
|
||||
func createTestRule(title string, groupTitle string, orgID int64, namespace string) models.AlertRule {
|
||||
return models.AlertRule{
|
||||
OrgID: orgID,
|
||||
@@ -2225,3 +2568,77 @@ func initService(t *testing.T) (*AlertRuleService, *fakes.RuleStore, *fakes.Fake
|
||||
|
||||
return service, ruleStore, provenanceStore, ac
|
||||
}
|
||||
|
||||
// func TestNoGroupRuleGroupIntervalHandling(t *testing.T) {
|
||||
// orgID := rand.Int63()
|
||||
// u := &user.SignedInUser{OrgID: orgID, UserUID: util.GenerateShortUID()}
|
||||
|
||||
// t.Run("UpdateRuleGroup with NoGroupRuleGroup", func(t *testing.T) {
|
||||
// t.Run("should allow interval updates for NoGroupRuleGroup via UpdateRuleGroup", func(t *testing.T) {
|
||||
// service, store, _, ac := initService(t)
|
||||
// ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
|
||||
// return true, nil
|
||||
// }
|
||||
|
||||
// // pre populate a rule with NoGroupRuleGroup
|
||||
// rule := createNoGroupRule("test-rule", orgID, "my-namespace")
|
||||
// store.Rules[orgID] = []*models.AlertRule{&rule} // Pre-populate the store with the rule
|
||||
|
||||
// // Update the rule with a new interval via UpdateRuleGroup
|
||||
// newInterval := int64(120)
|
||||
// createdRule, _, err := service.GetAlertRule(context.Background(), u, rule.UID)
|
||||
// require.NoError(t, err)
|
||||
// err = service.UpdateRuleGroup(context.Background(), u, createdRule.NamespaceUID, createdRule.RuleGroup, newInterval)
|
||||
// require.NoError(t, err)
|
||||
// updatedRule, _, err := service.GetAlertRule(context.Background(), u, createdRule.UID)
|
||||
// require.NoError(t, err)
|
||||
// assert.Equal(t, newInterval, updatedRule.IntervalSeconds, "Rule interval should be updated for NoGroupRuleGroup")
|
||||
|
||||
// })
|
||||
|
||||
// // t.Run("should preserve group interval for normal groups", func(t *testing.T) {
|
||||
// // service, _, _, ac := initService(t)
|
||||
// // ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
|
||||
// // return true, nil
|
||||
// // }
|
||||
|
||||
// // // Create a rule in a normal group
|
||||
// // groupInterval := int64(90)
|
||||
// // rule := createTestRule("test-rule", "normal-group", orgID, "my-namespace")
|
||||
// // rule2 := createTestRule("test-rule-2", "normal-group", orgID, "my-namespace")
|
||||
// // rule2.IntervalSeconds = groupInterval // Set the group interval
|
||||
// // rule.IntervalSeconds = groupInterval
|
||||
// // createdRule, err := service.CreateAlertRule(context.Background(), u, rule, models.ProvenanceNone)
|
||||
// // require.NoError(t, err)
|
||||
// // createdRule2, err := service.CreateAlertRule(context.Background(), u, rule2, models.ProvenanceNone)
|
||||
// // require.NoError(t, err)
|
||||
|
||||
// // // Try to update the rule with a different interval
|
||||
// // createdRule.IntervalSeconds = 120
|
||||
// // updatedRule, err := service.UpdateAlertRule(context.Background(), u, createdRule, models.ProvenanceNone)
|
||||
// // require.NoError(t, err)
|
||||
// // assert.Equal(t, int64(120), updatedRule.IntervalSeconds, "Rule interval should be changed for all rules in normal groups")
|
||||
// // updatedRule2, _, err := service.GetAlertRule(context.Background(), u, createdRule2.UID)
|
||||
// // require.NoError(t, err)
|
||||
// // assert.Equal(t, int64(120), updatedRule2.IntervalSeconds, "All rules in the same group should have the same interval after update")
|
||||
// // })
|
||||
// })
|
||||
|
||||
// t.Run("GetRuleGroup with a NoGroupRuleGroup", func(t *testing.T) {
|
||||
// t.Run("should allow retrieval of sentinel group", func(t *testing.T) {
|
||||
// service, store, _, ac := initService(t)
|
||||
// ac.CanWriteAllRulesFunc = func(ctx context.Context, user identity.Requester) (bool, error) {
|
||||
// return true, nil
|
||||
// }
|
||||
|
||||
// // pre populate a rule with NoGroupRuleGroup
|
||||
// rule := createNoGroupRule("test-rule", orgID, "my-namespace")
|
||||
// store.Rules[orgID] = []*models.AlertRule{&rule} // Pre-populate the store with the rule
|
||||
|
||||
// noGroupGroup, err := service.GetRuleGroup(context.Background(), u, "my-namespace", rule.RuleGroup)
|
||||
// require.NoError(t, err)
|
||||
// assert.Equal(t, rule.RuleGroup, noGroupGroup.Title, "NoGroupRuleGroup should be retrievable by its group title")
|
||||
// assert.Len(t, noGroupGroup.Rules, 1, "NoGroupRuleGroup should contain only the one rule")
|
||||
// })
|
||||
// })
|
||||
// }
|
||||
|
||||
@@ -105,6 +105,11 @@ func (sch *schedule) buildSequence(groupKey groupKey, groupItems []readyToRunIte
|
||||
}
|
||||
|
||||
func (sch *schedule) shouldEvaluateSequentially(groupItems []readyToRunItem) bool {
|
||||
// the no group group shouldn't be evaluated sequentially
|
||||
if len(groupItems) > 0 && models.IsNoGroupRuleGroup(groupItems[0].rule.RuleGroup) {
|
||||
return false
|
||||
}
|
||||
|
||||
// if jitter by rule is enabled, we can't evaluate rules sequentially
|
||||
if sch.jitterEvaluations == JitterByRule {
|
||||
return false
|
||||
|
||||
@@ -782,91 +782,10 @@ func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertR
|
||||
// ListAlertRulesPaginated is a handler for retrieving alert rules of specific organization paginated.
|
||||
func (st DBstore) ListAlertRulesPaginated(ctx context.Context, query *ngmodels.ListAlertRulesExtendedQuery) (result ngmodels.RulesGroup, nextToken string, err error) {
|
||||
err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
q := sess.Table("alert_rule")
|
||||
|
||||
if query.OrgID >= 0 {
|
||||
q = q.Where("org_id = ?", query.OrgID)
|
||||
q, groupsSet, err := st.buildListAlertRulesQuery(sess, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if query.DashboardUID != "" {
|
||||
q = q.Where("dashboard_uid = ?", query.DashboardUID)
|
||||
if query.PanelID != 0 {
|
||||
q = q.Where("panel_id = ?", query.PanelID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(query.NamespaceUIDs) > 0 {
|
||||
args, in := getINSubQueryArgs(query.NamespaceUIDs)
|
||||
q = q.Where(fmt.Sprintf("namespace_uid IN (%s)", strings.Join(in, ",")), args...)
|
||||
}
|
||||
|
||||
if len(query.RuleUIDs) > 0 {
|
||||
args, in := getINSubQueryArgs(query.RuleUIDs)
|
||||
q = q.Where(fmt.Sprintf("uid IN (%s)", strings.Join(in, ",")), args...)
|
||||
}
|
||||
|
||||
var groupsMap map[string]struct{}
|
||||
if len(query.RuleGroups) > 0 {
|
||||
groupsMap = make(map[string]struct{})
|
||||
args, in := getINSubQueryArgs(query.RuleGroups)
|
||||
q = q.Where(fmt.Sprintf("rule_group IN (%s)", strings.Join(in, ",")), args...)
|
||||
for _, group := range query.RuleGroups {
|
||||
groupsMap[group] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if query.ReceiverName != "" {
|
||||
q, err = st.filterByContentInNotificationSettings(query.ReceiverName, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if query.TimeIntervalName != "" {
|
||||
q, err = st.filterByContentInNotificationSettings(query.TimeIntervalName, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if query.HasPrometheusRuleDefinition != nil {
|
||||
q, err = st.filterWithPrometheusRuleDefinition(*query.HasPrometheusRuleDefinition, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: record is nullable but we don't save it as null when it's nil
|
||||
switch query.RuleType {
|
||||
case ngmodels.RuleTypeFilterAlerting:
|
||||
q = q.Where("record = ''")
|
||||
case ngmodels.RuleTypeFilterRecording:
|
||||
q = q.Where("record != ''")
|
||||
case ngmodels.RuleTypeFilterAll:
|
||||
// no additional filter
|
||||
default:
|
||||
return fmt.Errorf("unknown rule type filter %q", query.RuleType)
|
||||
}
|
||||
|
||||
q = q.Asc("namespace_uid", "rule_group", "rule_group_idx", "id")
|
||||
|
||||
if query.ContinueToken != "" {
|
||||
cursor, err := decodeCursor(query.ContinueToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid continue token: %w", err)
|
||||
}
|
||||
|
||||
// Build cursor condition that matches the ORDER BY clause
|
||||
q = buildCursorCondition(q, cursor)
|
||||
}
|
||||
|
||||
if query.Limit > 0 {
|
||||
// Ensure we clamp to the max int available on the platform
|
||||
lim := min(query.Limit, math.MaxInt)
|
||||
// Fetch one extra rule to determine if there are more results
|
||||
q = q.Limit(int(lim) + 1)
|
||||
}
|
||||
|
||||
alertRules := make([]*ngmodels.AlertRule, 0)
|
||||
rule := new(alertRule)
|
||||
rows, err := q.Rows(rule)
|
||||
@@ -879,7 +798,7 @@ func (st DBstore) ListAlertRulesPaginated(ctx context.Context, query *ngmodels.L
|
||||
|
||||
// Deserialize each rule separately in case any of them contain invalid JSON.
|
||||
for rows.Next() {
|
||||
converted, ok := st.handleRuleRow(rows, query, groupsMap)
|
||||
converted, ok := st.handleRuleRow(rows, query, groupsSet)
|
||||
if ok {
|
||||
alertRules = append(alertRules, converted)
|
||||
}
|
||||
@@ -908,6 +827,118 @@ func (st DBstore) ListAlertRulesPaginated(ctx context.Context, query *ngmodels.L
|
||||
return result, nextToken, err
|
||||
}
|
||||
|
||||
func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.ListAlertRulesExtendedQuery) (q *xorm.Session, groupsSet map[string]struct{}, err error) {
|
||||
q = sess.Table("alert_rule")
|
||||
if query.OrgID >= 0 {
|
||||
q = q.Where("org_id = ?", query.OrgID)
|
||||
}
|
||||
|
||||
if query.DashboardUID != "" {
|
||||
q = q.Where("dashboard_uid = ?", query.DashboardUID)
|
||||
if query.PanelID != 0 {
|
||||
q = q.Where("panel_id = ?", query.PanelID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(query.NamespaceUIDs) > 0 {
|
||||
args, in := getINSubQueryArgs(query.NamespaceUIDs)
|
||||
q = q.Where(fmt.Sprintf("namespace_uid IN (%s)", strings.Join(in, ",")), args...)
|
||||
}
|
||||
|
||||
if len(query.RuleUIDs) > 0 {
|
||||
args, in := getINSubQueryArgs(query.RuleUIDs)
|
||||
q = q.Where(fmt.Sprintf("uid IN (%s)", strings.Join(in, ",")), args...)
|
||||
}
|
||||
|
||||
var noGroupRuleGroupRuleUIDs []string
|
||||
var realGroups []string
|
||||
if len(query.RuleGroups) > 0 {
|
||||
groupsSet = make(map[string]struct{})
|
||||
for _, group := range query.RuleGroups {
|
||||
if ngmodels.IsNoGroupRuleGroup(group) {
|
||||
noGroupRuleGroup, err := ngmodels.ParseNoRuleGroup(group)
|
||||
if err != nil {
|
||||
return nil, groupsSet, fmt.Errorf("failed to parse rule group %q: %w", group, err)
|
||||
}
|
||||
noGroupRuleGroupRuleUIDs = append(noGroupRuleGroupRuleUIDs, noGroupRuleGroup.GetRuleUID())
|
||||
} else {
|
||||
realGroups = append(realGroups, group)
|
||||
}
|
||||
groupsSet[group] = struct{}{}
|
||||
}
|
||||
switch {
|
||||
// all real rule groups,
|
||||
case len(realGroups) > 0 && len(noGroupRuleGroupRuleUIDs) == 0:
|
||||
groupArgs, groupIn := getINSubQueryArgs(realGroups)
|
||||
q = q.Where(fmt.Sprintf("rule_group IN (%s)", strings.Join(groupIn, ",")), groupArgs...)
|
||||
// all no-group rule groups
|
||||
case len(realGroups) == 0 && len(noGroupRuleGroupRuleUIDs) > 0:
|
||||
ruleUIDArgs, ruleUIDIn := getINSubQueryArgs(noGroupRuleGroupRuleUIDs)
|
||||
q = q.Where(fmt.Sprintf("uid IN (%s)", strings.Join(ruleUIDIn, ",")), ruleUIDArgs...)
|
||||
// mixed case, we need to perform the or
|
||||
case len(realGroups) > 0 && len(noGroupRuleGroupRuleUIDs) > 0:
|
||||
groupArgs, groupIn := getINSubQueryArgs(realGroups)
|
||||
ruleUIDArgs, ruleUIDIn := getINSubQueryArgs(noGroupRuleGroupRuleUIDs)
|
||||
q = q.Where(fmt.Sprintf("rule_group IN (%s)", strings.Join(groupIn, ",")), groupArgs...).Or(
|
||||
fmt.Sprintf("uid IN (%s)", strings.Join(ruleUIDIn, ",")), ruleUIDArgs...,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if query.ReceiverName != "" {
|
||||
q, err = st.filterByContentInNotificationSettings(query.ReceiverName, q)
|
||||
if err != nil {
|
||||
return nil, groupsSet, err
|
||||
}
|
||||
}
|
||||
|
||||
if query.TimeIntervalName != "" {
|
||||
q, err = st.filterByContentInNotificationSettings(query.TimeIntervalName, q)
|
||||
if err != nil {
|
||||
return nil, groupsSet, err
|
||||
}
|
||||
}
|
||||
|
||||
if query.HasPrometheusRuleDefinition != nil {
|
||||
q, err = st.filterWithPrometheusRuleDefinition(*query.HasPrometheusRuleDefinition, q)
|
||||
if err != nil {
|
||||
return nil, groupsSet, err
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: record is nullable but we don't save it as null when it's nil
|
||||
switch query.RuleType {
|
||||
case ngmodels.RuleTypeFilterAlerting:
|
||||
q = q.Where("record = ''")
|
||||
case ngmodels.RuleTypeFilterRecording:
|
||||
q = q.Where("record != ''")
|
||||
case ngmodels.RuleTypeFilterAll:
|
||||
// no additional filter
|
||||
default:
|
||||
return nil, groupsSet, fmt.Errorf("unknown rule type filter %q", query.RuleType)
|
||||
}
|
||||
|
||||
q = q.Asc("namespace_uid", "rule_group", "rule_group_idx", "id")
|
||||
|
||||
if query.ContinueToken != "" {
|
||||
cursor, err := decodeCursor(query.ContinueToken)
|
||||
if err != nil {
|
||||
return nil, groupsSet, fmt.Errorf("invalid continue token: %w", err)
|
||||
}
|
||||
|
||||
// Build cursor condition that matches the ORDER BY clause
|
||||
q = buildCursorCondition(q, cursor)
|
||||
}
|
||||
|
||||
if query.Limit > 0 {
|
||||
// Ensure we clamp to the max int available on the platform
|
||||
lim := min(query.Limit, math.MaxInt)
|
||||
// Fetch one extra rule to determine if there are more results
|
||||
q = q.Limit(int(lim) + 1)
|
||||
}
|
||||
return q, groupsSet, nil
|
||||
}
|
||||
|
||||
func (st DBstore) handleRuleRow(rows *xorm.Rows, query *ngmodels.ListAlertRulesExtendedQuery, groupsSet map[string]struct{}) (*ngmodels.AlertRule, bool) {
|
||||
rule := new(alertRule)
|
||||
err := rows.Scan(rule)
|
||||
@@ -1217,7 +1248,7 @@ func (st DBstore) validateAlertRule(alertRule ngmodels.AlertRule) error {
|
||||
}
|
||||
|
||||
// enforce max rule group name length.
|
||||
if len(alertRule.RuleGroup) > AlertRuleMaxRuleGroupNameLength {
|
||||
if len(alertRule.RuleGroup) > AlertRuleMaxRuleGroupNameLength && !ngmodels.IsNoGroupRuleGroup(alertRule.RuleGroup) {
|
||||
return fmt.Errorf("%w: rule group name length should not be greater than %d", ngmodels.ErrAlertRuleFailedValidation, AlertRuleMaxRuleGroupNameLength)
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ func alertRuleToModelsAlertRule(ar alertRule, l log.Logger) (models.AlertRule, e
|
||||
NamespaceUID: ar.NamespaceUID,
|
||||
DashboardUID: ar.DashboardUID,
|
||||
PanelID: ar.PanelID,
|
||||
RuleGroup: ar.RuleGroup,
|
||||
RuleGroupIndex: ar.RuleGroupIndex,
|
||||
For: ar.For,
|
||||
KeepFiringFor: ar.KeepFiringFor,
|
||||
@@ -39,6 +38,16 @@ func alertRuleToModelsAlertRule(ar alertRule, l log.Logger) (models.AlertRule, e
|
||||
MissingSeriesEvalsToResolve: ar.MissingSeriesEvalsToResolve,
|
||||
}
|
||||
|
||||
if ar.RuleGroup == "" {
|
||||
noGroupRuleGroup, err := models.NewNoGroupRuleGroup(ar.UID)
|
||||
if err != nil {
|
||||
return models.AlertRule{}, fmt.Errorf("failed to create no group rule group: %w", err)
|
||||
}
|
||||
result.RuleGroup = noGroupRuleGroup.String()
|
||||
} else {
|
||||
result.RuleGroup = ar.RuleGroup
|
||||
}
|
||||
|
||||
if ar.UpdatedBy != nil {
|
||||
result.UpdatedBy = util.Pointer(models.UserUID(*ar.UpdatedBy))
|
||||
}
|
||||
@@ -121,7 +130,6 @@ func alertRuleFromModelsAlertRule(ar models.AlertRule) (alertRule, error) {
|
||||
NamespaceUID: ar.NamespaceUID,
|
||||
DashboardUID: ar.DashboardUID,
|
||||
PanelID: ar.PanelID,
|
||||
RuleGroup: ar.RuleGroup,
|
||||
RuleGroupIndex: ar.RuleGroupIndex,
|
||||
NoDataState: ar.NoDataState.String(),
|
||||
ExecErrState: ar.ExecErrState.String(),
|
||||
@@ -131,6 +139,12 @@ func alertRuleFromModelsAlertRule(ar models.AlertRule) (alertRule, error) {
|
||||
MissingSeriesEvalsToResolve: ar.MissingSeriesEvalsToResolve,
|
||||
}
|
||||
|
||||
if models.IsNoGroupRuleGroup(ar.RuleGroup) {
|
||||
result.RuleGroup = ""
|
||||
} else {
|
||||
result.RuleGroup = ar.RuleGroup
|
||||
}
|
||||
|
||||
if ar.UpdatedBy != nil {
|
||||
result.UpdatedBy = util.Pointer(string(*ar.UpdatedBy))
|
||||
}
|
||||
|
||||
@@ -42,6 +42,27 @@ func TestAlertRuleToModelsAlertRule(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ngmodels.ErrorErrState, converted.ExecErrState)
|
||||
})
|
||||
|
||||
t.Run("should handle NoGroup rules properly", func(t *testing.T) {
|
||||
rule, err := alertRuleFromModelsAlertRule(g.Generate())
|
||||
require.NoError(t, err)
|
||||
rule.RuleGroup = ""
|
||||
projectedRuleGroup, err := ngmodels.NewNoGroupRuleGroup(rule.UID)
|
||||
require.NoError(t, err)
|
||||
|
||||
converted, err := alertRuleToModelsAlertRule(rule, &logtest.Fake{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, projectedRuleGroup.String(), converted.RuleGroup)
|
||||
|
||||
clone, err := alertRuleFromModelsAlertRule(converted)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, rule, clone)
|
||||
require.Empty(t, clone.RuleGroup)
|
||||
|
||||
converted2, err := alertRuleToModelsAlertRule(clone, &logtest.Fake{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, converted, converted2)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAlertRuleVersionToAlertRule(t *testing.T) {
|
||||
|
||||
@@ -179,7 +179,7 @@ func UpdateCalculatedRuleFields(ch *GroupDelta) *GroupDelta {
|
||||
}
|
||||
var toUpdate []RuleDelta
|
||||
for groupKey, rules := range ch.AffectedGroups {
|
||||
if groupKey != ch.GroupKey {
|
||||
if groupKey != ch.GroupKey && !models.IsNoGroupRuleGroup(groupKey.RuleGroup) {
|
||||
rules.SortByGroupIndex()
|
||||
}
|
||||
idx := 1
|
||||
@@ -191,7 +191,7 @@ func UpdateCalculatedRuleFields(ch *GroupDelta) *GroupDelta {
|
||||
Existing: rule,
|
||||
New: rule,
|
||||
}
|
||||
if groupKey != ch.GroupKey {
|
||||
if groupKey != ch.GroupKey && !models.IsNoGroupRuleGroup(groupKey.RuleGroup) {
|
||||
if rule.RuleGroupIndex != idx {
|
||||
upd.New = rule.Copy()
|
||||
upd.New.RuleGroupIndex = idx
|
||||
|
||||
@@ -511,6 +511,19 @@ func (a apiClient) DeleteRulesGroup(t *testing.T, folder string, group string, p
|
||||
return status, string(resp)
|
||||
}
|
||||
|
||||
func (a apiClient) DeleteRulesGroupProvisioning(t *testing.T, folder string, group string) (int, string) {
|
||||
t.Helper()
|
||||
|
||||
u := fmt.Sprintf("%s/api/v1/provisioning/folder/%s/rule-groups/%s", a.url, folder, group)
|
||||
req, err := http.NewRequest(http.MethodDelete, u, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, status, err := sendRequestRaw(t, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
return status, string(resp)
|
||||
}
|
||||
|
||||
func (a apiClient) PostSilence(t *testing.T, s apimodels.PostableSilence) (apimodels.PostSilencesOKBody, int, string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
package alertrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
|
||||
"github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1"
|
||||
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/tests/apis/alerting/rules/common"
|
||||
"github.com/grafana/grafana/pkg/tests/testsuite"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
prom_model "github.com/prometheus/common/model"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testsuite.Run(m)
|
||||
}
|
||||
|
||||
func TestIntegrationResourceIdentifier(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
helper := common.GetTestHelper(t)
|
||||
client := common.NewAlertRuleClient(t, helper.Org1.Admin)
|
||||
|
||||
// Create test folder first
|
||||
common.CreateTestFolder(t, helper, "test-folder")
|
||||
|
||||
rule := ngmodels.RuleGen.With(
|
||||
ngmodels.RuleMuts.WithUniqueUID(),
|
||||
ngmodels.RuleMuts.WithUniqueTitle(),
|
||||
ngmodels.RuleMuts.WithNamespaceUID("test-folder"),
|
||||
ngmodels.RuleMuts.WithGroupName("test-group"),
|
||||
ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second),
|
||||
).Generate()
|
||||
|
||||
newResource := &v0alpha1.AlertRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.AlertRuleSpec{
|
||||
Title: rule.Title,
|
||||
Data: map[string]v0alpha1.AlertRuleQuery{
|
||||
"A": {
|
||||
QueryType: "query",
|
||||
DatasourceUID: v0alpha1.AlertRuleDatasourceUID(rule.Data[0].DatasourceUID),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.AlertRuleRelativeTimeRange{
|
||||
From: v0alpha1.AlertRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.AlertRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.AlertRuleIntervalTrigger{
|
||||
Interval: v0alpha1.AlertRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
|
||||
},
|
||||
NoDataState: string(rule.NoDataState),
|
||||
ExecErrState: string(rule.ExecErrState),
|
||||
},
|
||||
}
|
||||
|
||||
// Test 1: Create with explicit name
|
||||
namedResource := newResource.Copy().(*v0alpha1.AlertRule)
|
||||
namedResource.Name = "explicit-name-rule"
|
||||
namedRule, err := client.Create(ctx, namedResource, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "explicit-name-rule", namedRule.Name)
|
||||
require.NotEmpty(t, namedRule.UID)
|
||||
|
||||
// Test 2: Create without explicit name (auto-generated)
|
||||
autoGenRule, err := client.Create(ctx, newResource, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, autoGenRule.Name)
|
||||
require.NotEmpty(t, autoGenRule.UID)
|
||||
|
||||
// Test 3: Get by identifier
|
||||
retrievedRule, err := client.Get(ctx, autoGenRule.Name, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, autoGenRule.Name, retrievedRule.Name)
|
||||
require.Equal(t, newResource.Spec.Title, retrievedRule.Spec.Title)
|
||||
|
||||
// Test 4: Update (should preserve name)
|
||||
updatedRule := retrievedRule.Copy().(*v0alpha1.AlertRule)
|
||||
updatedRule.Spec.Title = "updated-title"
|
||||
|
||||
finalRule, err := client.Update(ctx, updatedRule, v1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "updated-title", finalRule.Spec.Title)
|
||||
require.Equal(t, retrievedRule.Name, finalRule.Name, "Update should preserve the resource name")
|
||||
require.NotEqual(t, retrievedRule.ResourceVersion, finalRule.ResourceVersion, "Update should change the resource version")
|
||||
|
||||
// Test 5: Verify the update persisted
|
||||
finalRetrieved, err := client.Get(ctx, finalRule.Name, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, finalRule.Spec.Title, finalRetrieved.Spec.Title)
|
||||
require.Equal(t, finalRule.Name, finalRetrieved.Name)
|
||||
require.Equal(t, finalRule.ResourceVersion, finalRetrieved.ResourceVersion)
|
||||
|
||||
// Cleanup
|
||||
require.NoError(t, client.Delete(ctx, namedRule.Name, v1.DeleteOptions{}))
|
||||
require.NoError(t, client.Delete(ctx, finalRule.Name, v1.DeleteOptions{}))
|
||||
}
|
||||
|
||||
// TestIntegrationResourcePermissions is skipped for now as access control is handled in the service layer
|
||||
func TestIntegrationResourcePermissions(t *testing.T) {
|
||||
t.Skip("Access control tests skipped - handled in service layer")
|
||||
}
|
||||
|
||||
// TestIntegrationAccessControl tests basic access control functionality
|
||||
// Access control is primarily handled in the service layer, so this test focuses on basic CRUD operations
|
||||
func TestIntegrationAccessControl(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
helper := common.GetTestHelper(t)
|
||||
|
||||
// Test with admin user for basic functionality
|
||||
adminClient := common.NewAlertRuleClient(t, helper.Org1.Admin)
|
||||
|
||||
// Create test folder first
|
||||
common.CreateTestFolder(t, helper, "test-folder")
|
||||
|
||||
rule := ngmodels.RuleGen.With(
|
||||
ngmodels.RuleMuts.WithUniqueUID(),
|
||||
ngmodels.RuleMuts.WithUniqueTitle(),
|
||||
ngmodels.RuleMuts.WithNamespaceUID("test-folder"),
|
||||
ngmodels.RuleMuts.WithGroupName("test-group"),
|
||||
ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second),
|
||||
).Generate()
|
||||
|
||||
alertRule := &v0alpha1.AlertRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.AlertRuleSpec{
|
||||
Title: rule.Title,
|
||||
Data: map[string]v0alpha1.AlertRuleQuery{
|
||||
"A": {
|
||||
QueryType: "query",
|
||||
DatasourceUID: v0alpha1.AlertRuleDatasourceUID(rule.Data[0].DatasourceUID),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.AlertRuleRelativeTimeRange{
|
||||
From: v0alpha1.AlertRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.AlertRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.AlertRuleIntervalTrigger{
|
||||
Interval: v0alpha1.AlertRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
|
||||
},
|
||||
NoDataState: string(rule.NoDataState),
|
||||
ExecErrState: string(rule.ExecErrState),
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("admin should be able to create rule", func(t *testing.T) {
|
||||
created, err := adminClient.Create(ctx, alertRule, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, created)
|
||||
require.Equal(t, alertRule.Spec.Title, created.Spec.Title)
|
||||
|
||||
// Cleanup
|
||||
defer func() {
|
||||
_ = adminClient.Delete(ctx, created.Name, v1.DeleteOptions{})
|
||||
}()
|
||||
|
||||
t.Run("admin should be able to read rule", func(t *testing.T) {
|
||||
read, err := adminClient.Get(ctx, created.Name, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, created.Spec.Title, read.Spec.Title)
|
||||
})
|
||||
|
||||
t.Run("admin should be able to update rule", func(t *testing.T) {
|
||||
updated := created.Copy().(*v0alpha1.AlertRule)
|
||||
updated.Spec.Title = "updated-title"
|
||||
|
||||
result, err := adminClient.Update(ctx, updated, v1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "updated-title", result.Spec.Title)
|
||||
})
|
||||
|
||||
t.Run("admin should be able to delete rule", func(t *testing.T) {
|
||||
err := adminClient.Delete(ctx, created.Name, v1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationCRUD(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
helper := common.GetTestHelper(t)
|
||||
|
||||
adminClient := common.NewAlertRuleClient(t, helper.Org1.Admin)
|
||||
|
||||
// Create test folder first
|
||||
common.CreateTestFolder(t, helper, "test-folder")
|
||||
|
||||
baseGen := ngmodels.RuleGen.With(
|
||||
ngmodels.RuleMuts.WithUniqueUID(),
|
||||
ngmodels.RuleMuts.WithUniqueTitle(),
|
||||
ngmodels.RuleMuts.WithNamespaceUID("test-folder"),
|
||||
ngmodels.RuleMuts.WithGroupName("test-group"),
|
||||
ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second),
|
||||
)
|
||||
|
||||
t.Run("should be able to create and read rule", func(t *testing.T) {
|
||||
rule := baseGen.Generate()
|
||||
|
||||
alertRule := &v0alpha1.AlertRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
"grafana.com/provenance": "",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.AlertRuleSpec{
|
||||
Title: rule.Title,
|
||||
Data: map[string]v0alpha1.AlertRuleQuery{
|
||||
"A": {
|
||||
QueryType: "query",
|
||||
DatasourceUID: v0alpha1.AlertRuleDatasourceUID(rule.Data[0].DatasourceUID),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.AlertRuleRelativeTimeRange{
|
||||
From: v0alpha1.AlertRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.AlertRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.AlertRuleIntervalTrigger{
|
||||
Interval: v0alpha1.AlertRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
|
||||
},
|
||||
NoDataState: string(rule.NoDataState),
|
||||
ExecErrState: string(rule.ExecErrState),
|
||||
},
|
||||
}
|
||||
|
||||
created, err := adminClient.Create(ctx, alertRule, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, created)
|
||||
|
||||
t.Run("should be able to read what it is created", func(t *testing.T) {
|
||||
get, err := adminClient.Get(ctx, created.Name, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, alertRule.Spec.Title, get.Spec.Title)
|
||||
|
||||
createdDuration, err := prom_model.ParseDuration(string(alertRule.Spec.Trigger.Interval))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, createdDuration.String(), string(get.Spec.Trigger.Interval))
|
||||
|
||||
provenance := get.GetProvenanceStatus()
|
||||
require.Equal(t, v0alpha1.ProvenanceStatusNone, provenance)
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
require.NoError(t, adminClient.Delete(ctx, created.Name, v1.DeleteOptions{}))
|
||||
})
|
||||
|
||||
t.Run("should fail to create rule with invalid provenance status", func(t *testing.T) {
|
||||
rule := baseGen.Generate()
|
||||
|
||||
alertRule := &v0alpha1.AlertRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
"grafana.com/provenance": "invalid",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.AlertRuleSpec{
|
||||
Title: rule.Title,
|
||||
Data: map[string]v0alpha1.AlertRuleQuery{
|
||||
"A": {
|
||||
QueryType: "query",
|
||||
DatasourceUID: v0alpha1.AlertRuleDatasourceUID(rule.Data[0].DatasourceUID),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.AlertRuleRelativeTimeRange{
|
||||
From: v0alpha1.AlertRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.AlertRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.AlertRuleIntervalTrigger{
|
||||
Interval: v0alpha1.AlertRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
|
||||
},
|
||||
NoDataState: string(rule.NoDataState),
|
||||
ExecErrState: string(rule.ExecErrState),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := adminClient.Create(ctx, alertRule, v1.CreateOptions{})
|
||||
require.Error(t, err, "Creating invalid rule should fail")
|
||||
})
|
||||
|
||||
t.Run("should fail to create rule with invalid config", func(t *testing.T) {
|
||||
invalidRule := &v0alpha1.AlertRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.AlertRuleSpec{
|
||||
Title: "invalid-rule",
|
||||
Data: map[string]v0alpha1.AlertRuleQuery{}, // Empty data should fail
|
||||
Trigger: v0alpha1.AlertRuleIntervalTrigger{
|
||||
Interval: "30",
|
||||
},
|
||||
NoDataState: "NoData",
|
||||
ExecErrState: "Error",
|
||||
},
|
||||
}
|
||||
|
||||
_, err := adminClient.Create(ctx, invalidRule, v1.CreateOptions{})
|
||||
require.Errorf(t, err, "Expected error but got successful result")
|
||||
// The validation happens at the service level, so we just need to verify it fails
|
||||
require.Error(t, err, "Creating invalid rule should fail")
|
||||
})
|
||||
|
||||
t.Run("should not be able to add rule to group", func(t *testing.T) {
|
||||
rule := baseGen.Generate()
|
||||
|
||||
alertRule := &v0alpha1.AlertRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.AlertRuleSpec{
|
||||
Title: rule.Title,
|
||||
Data: map[string]v0alpha1.AlertRuleQuery{
|
||||
"A": {
|
||||
QueryType: "query",
|
||||
DatasourceUID: v0alpha1.AlertRuleDatasourceUID(rule.Data[0].DatasourceUID),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.AlertRuleRelativeTimeRange{
|
||||
From: v0alpha1.AlertRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.AlertRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.AlertRuleIntervalTrigger{
|
||||
Interval: v0alpha1.AlertRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
|
||||
},
|
||||
NoDataState: string(rule.NoDataState),
|
||||
ExecErrState: string(rule.ExecErrState),
|
||||
},
|
||||
}
|
||||
|
||||
created, err := adminClient.Create(ctx, alertRule, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, created)
|
||||
|
||||
get, err := adminClient.Get(ctx, created.Name, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, created.Spec.Title, get.Spec.Title)
|
||||
|
||||
// Attempt to update the group name via a patch (should fail)
|
||||
update := get
|
||||
if update.Labels == nil {
|
||||
update.Labels = map[string]string{}
|
||||
}
|
||||
update.Labels[v0alpha1.GroupLabelKey] = "new-group-name"
|
||||
_, err = adminClient.Update(ctx, update, v1.UpdateOptions{})
|
||||
require.Error(t, err, "Updating the group name should fail")
|
||||
|
||||
// Cleanup
|
||||
require.NoError(t, adminClient.Delete(ctx, created.Name, v1.DeleteOptions{}))
|
||||
})
|
||||
t.Run("should not be able to create rule without any source query", func(t *testing.T) {
|
||||
rule := baseGen.Generate()
|
||||
|
||||
alertRule := &v0alpha1.AlertRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.AlertRuleSpec{
|
||||
Title: rule.Title,
|
||||
Data: map[string]v0alpha1.AlertRuleQuery{
|
||||
"A": {
|
||||
QueryType: "query",
|
||||
DatasourceUID: v0alpha1.AlertRuleDatasourceUID(rule.Data[0].DatasourceUID),
|
||||
Model: rule.Data[0].Model,
|
||||
RelativeTimeRange: &v0alpha1.AlertRuleRelativeTimeRange{
|
||||
From: v0alpha1.AlertRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.AlertRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.AlertRuleIntervalTrigger{
|
||||
Interval: v0alpha1.AlertRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
|
||||
},
|
||||
NoDataState: string(rule.NoDataState),
|
||||
ExecErrState: string(rule.ExecErrState),
|
||||
},
|
||||
}
|
||||
|
||||
created, err := adminClient.Create(ctx, alertRule, v1.CreateOptions{})
|
||||
require.ErrorContains(t, err, "no query marked as source")
|
||||
require.Nil(t, created)
|
||||
})
|
||||
t.Run("should not be able to create rule with interval less than base", func(t *testing.T) {
|
||||
rule := baseGen.With(
|
||||
ngmodels.RuleMuts.WithInterval(time.Duration(1) * time.Second),
|
||||
).Generate()
|
||||
|
||||
alertRule := &v0alpha1.AlertRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.AlertRuleSpec{
|
||||
Title: rule.Title,
|
||||
Data: map[string]v0alpha1.AlertRuleQuery{
|
||||
"A": {
|
||||
QueryType: "query",
|
||||
DatasourceUID: v0alpha1.AlertRuleDatasourceUID(rule.Data[0].DatasourceUID),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.AlertRuleRelativeTimeRange{
|
||||
From: v0alpha1.AlertRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.AlertRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.AlertRuleIntervalTrigger{
|
||||
Interval: v0alpha1.AlertRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
|
||||
},
|
||||
NoDataState: string(rule.NoDataState),
|
||||
ExecErrState: string(rule.ExecErrState),
|
||||
},
|
||||
}
|
||||
|
||||
created, err := adminClient.Create(ctx, alertRule, v1.CreateOptions{})
|
||||
require.ErrorContains(t, err, "invalid alert rule")
|
||||
require.Nil(t, created)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationPatch(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
helper := common.GetTestHelper(t)
|
||||
|
||||
adminClient := common.NewAlertRuleClient(t, helper.Org1.Admin)
|
||||
|
||||
// Create test folder first
|
||||
common.CreateTestFolder(t, helper, "test-folder")
|
||||
|
||||
rule := ngmodels.RuleGen.With(
|
||||
ngmodels.RuleMuts.WithUniqueUID(),
|
||||
ngmodels.RuleMuts.WithUniqueTitle(),
|
||||
ngmodels.RuleMuts.WithNamespaceUID("test-folder"),
|
||||
ngmodels.RuleMuts.WithGroupName("test-group"),
|
||||
ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second),
|
||||
).Generate()
|
||||
|
||||
alertRule := &v0alpha1.AlertRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.AlertRuleSpec{
|
||||
Title: rule.Title,
|
||||
Data: map[string]v0alpha1.AlertRuleQuery{
|
||||
"A": {
|
||||
QueryType: "query",
|
||||
DatasourceUID: v0alpha1.AlertRuleDatasourceUID(rule.Data[0].DatasourceUID),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.AlertRuleRelativeTimeRange{
|
||||
From: v0alpha1.AlertRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.AlertRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.AlertRuleIntervalTrigger{
|
||||
Interval: v0alpha1.AlertRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
|
||||
},
|
||||
NoDataState: string(rule.NoDataState),
|
||||
ExecErrState: string(rule.ExecErrState),
|
||||
},
|
||||
}
|
||||
|
||||
current, err := adminClient.Create(ctx, alertRule, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, current)
|
||||
|
||||
t.Run("should patch with json patch", func(t *testing.T) {
|
||||
current, err := adminClient.Get(ctx, current.Name, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
patch := []map[string]any{
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/spec/title",
|
||||
"value": "patched-title",
|
||||
},
|
||||
}
|
||||
|
||||
patchData, err := json.Marshal(patch)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := adminClient.Patch(ctx, current.Name, types.JSONPatchType, patchData, v1.PatchOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "patched-title", result.Spec.Title)
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
require.NoError(t, adminClient.Delete(ctx, current.Name, v1.DeleteOptions{}))
|
||||
}
|
||||
|
||||
func TestIntegrationBasicAPI(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
helper := common.GetTestHelper(t)
|
||||
client := common.NewAlertRuleClient(t, helper.Org1.Admin)
|
||||
|
||||
t.Run("should be able to list rules", func(t *testing.T) {
|
||||
list, err := client.List(ctx, v1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
// Should at least be able to list, even if empty
|
||||
require.NotNil(t, list)
|
||||
})
|
||||
|
||||
t.Run("should handle get of non-existent rule", func(t *testing.T) {
|
||||
_, err := client.Get(ctx, "non-existent", v1.GetOptions{})
|
||||
// The API might return different error types, so just check that it's an error
|
||||
require.Error(t, err)
|
||||
t.Logf("Got error: %s", err)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1"
|
||||
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
||||
"github.com/grafana/grafana/pkg/tests/apis"
|
||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||
"github.com/stretchr/testify/require"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/client-go/dynamic"
|
||||
)
|
||||
|
||||
func NewAlertRuleClient(t *testing.T, user apis.User) *apis.TypedClient[v0alpha1.AlertRule, v0alpha1.AlertRuleList] {
|
||||
t.Helper()
|
||||
|
||||
client, err := dynamic.NewForConfig(user.NewRestConfig())
|
||||
require.NoError(t, err)
|
||||
|
||||
return &apis.TypedClient[v0alpha1.AlertRule, v0alpha1.AlertRuleList]{
|
||||
Client: client.Resource(
|
||||
schema.GroupVersionResource{
|
||||
Group: v0alpha1.AlertRuleKind().Group(),
|
||||
Version: v0alpha1.AlertRuleKind().Version(),
|
||||
Resource: v0alpha1.AlertRuleKind().Plural(),
|
||||
}).Namespace("default"),
|
||||
}
|
||||
}
|
||||
|
||||
func NewRecordingRuleClient(t *testing.T, user apis.User) *apis.TypedClient[v0alpha1.RecordingRule, v0alpha1.RecordingRuleList] {
|
||||
t.Helper()
|
||||
|
||||
client, err := dynamic.NewForConfig(user.NewRestConfig())
|
||||
require.NoError(t, err)
|
||||
|
||||
return &apis.TypedClient[v0alpha1.RecordingRule, v0alpha1.RecordingRuleList]{
|
||||
Client: client.Resource(
|
||||
schema.GroupVersionResource{
|
||||
Group: v0alpha1.RecordingRuleKind().Group(),
|
||||
Version: v0alpha1.RecordingRuleKind().Version(),
|
||||
Resource: v0alpha1.RecordingRuleKind().Plural(),
|
||||
}).Namespace("default"),
|
||||
}
|
||||
}
|
||||
|
||||
func NewFolderClient(t *testing.T, user apis.User) *apis.TypedClient[folders.Folder, folders.FolderList] {
|
||||
t.Helper()
|
||||
|
||||
client, err := dynamic.NewForConfig(user.NewRestConfig())
|
||||
require.NoError(t, err)
|
||||
|
||||
return &apis.TypedClient[folders.Folder, folders.FolderList]{
|
||||
Client: client.Resource(
|
||||
schema.GroupVersionResource{
|
||||
Group: folders.FolderKind().Group(),
|
||||
Version: folders.FolderKind().Version(),
|
||||
Resource: folders.FolderKind().Plural(),
|
||||
}).Namespace("default"),
|
||||
}
|
||||
}
|
||||
|
||||
func GetTestHelper(t *testing.T) *apis.K8sTestHelper {
|
||||
return apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
EnableFeatureToggles: []string{
|
||||
"kubernetesAlertingRules",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func CreateTestFolder(t *testing.T, helper *apis.K8sTestHelper, folderUID string) {
|
||||
ctx := context.Background()
|
||||
folderClient := NewFolderClient(t, helper.Org1.Admin)
|
||||
|
||||
folder := &folders.Folder{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Name: folderUID,
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: folders.FolderSpec{
|
||||
Title: "Test Folder",
|
||||
},
|
||||
}
|
||||
|
||||
_, err := folderClient.Create(ctx, folder, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package compat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1"
|
||||
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/tests/api/alerting"
|
||||
"github.com/grafana/grafana/pkg/tests/apis/alerting/rules/common"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
prom_model "github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func TestIntegrationAlertRuleCompatCreateViaK8s(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
helper := common.GetTestHelper(t)
|
||||
|
||||
k8sClient := common.NewAlertRuleClient(t, helper.Org1.Admin)
|
||||
|
||||
legacyClient := alerting.NewAlertingLegacyAPIClient(helper.GetListenerAddress(), "admin", "admin")
|
||||
|
||||
// Ensure the old provisioning API is enabled
|
||||
allRules, status, _ := legacyClient.GetAllRulesWithStatus(t)
|
||||
require.Equal(t, 200, status)
|
||||
require.NotNil(t, allRules)
|
||||
|
||||
// Create test folder first
|
||||
common.CreateTestFolder(t, helper, "test-folder")
|
||||
|
||||
rule := ngmodels.RuleGen.With(
|
||||
ngmodels.RuleMuts.WithUniqueUID(),
|
||||
ngmodels.RuleMuts.WithUniqueTitle(),
|
||||
ngmodels.RuleMuts.WithNamespaceUID("test-folder"),
|
||||
ngmodels.RuleMuts.WithGroupName("test-group"),
|
||||
ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second),
|
||||
).Generate()
|
||||
|
||||
alertRule := &v0alpha1.AlertRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
// use provenance api to allow use of the provisioning api
|
||||
"grafana.com/provenance": string(ngmodels.ProvenanceAPI),
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.AlertRuleSpec{
|
||||
Title: rule.Title,
|
||||
Data: map[string]v0alpha1.AlertRuleQuery{
|
||||
"A": {
|
||||
QueryType: "query",
|
||||
DatasourceUID: v0alpha1.AlertRuleDatasourceUID(rule.Data[0].DatasourceUID),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.AlertRuleRelativeTimeRange{
|
||||
From: v0alpha1.AlertRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.AlertRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.AlertRuleIntervalTrigger{
|
||||
Interval: v0alpha1.AlertRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
created, err := k8sClient.Create(ctx, alertRule, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, created)
|
||||
|
||||
t.Run("should be able to use the provisioning API with this rule", func(t *testing.T) {
|
||||
retrievedRule, status, _ := legacyClient.GetProvisioningAlertRule(t, created.Name)
|
||||
require.NotNil(t, retrievedRule)
|
||||
require.Equal(t, 200, status)
|
||||
require.Equal(t, created.Spec.Title, retrievedRule.Title)
|
||||
require.Equal(t, "A", retrievedRule.Condition)
|
||||
require.Equal(t, "A", retrievedRule.Data[0].RefID)
|
||||
|
||||
model := map[string]interface{}{}
|
||||
err := json.Unmarshal(retrievedRule.Data[0].Model, &model)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, model)
|
||||
expectedModel, ok := created.Spec.Data["A"].Model.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected model to be a map[string]interface{}, got %T", created.Spec.Data["A"].Model)
|
||||
}
|
||||
for k, v := range expectedModel {
|
||||
require.EqualValues(t, v, model[k], "Model field %s should match", k)
|
||||
}
|
||||
require.Equal(t, created.Annotations["grafana.app/folder"], retrievedRule.FolderUID)
|
||||
|
||||
// get the group to get the interval
|
||||
group, status, _ := legacyClient.GetRuleGroupProvisioning(t, retrievedRule.FolderUID, retrievedRule.RuleGroup)
|
||||
require.NotNil(t, group)
|
||||
require.Equal(t, 200, status)
|
||||
parsedDuration, err := prom_model.ParseDuration(fmt.Sprintf("%ds", group.Interval))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(created.Spec.Trigger.Interval), parsedDuration.String())
|
||||
|
||||
// try to update the rule group title via the provisioning api
|
||||
groupNameUpdate := group
|
||||
groupNameUpdate.Title = "New Group Name"
|
||||
// this should be rejected
|
||||
_, status, data := legacyClient.CreateOrUpdateRuleGroupProvisioning(t, groupNameUpdate)
|
||||
require.Equalf(t, 400, status, "Expected status 400 when changing group name, got %d. Data: %s", status, data)
|
||||
// verify the group name didn't
|
||||
retrievedRule, status, _ = legacyClient.GetProvisioningAlertRule(t, created.Name)
|
||||
require.NotNil(t, retrievedRule)
|
||||
require.Equal(t, 200, status)
|
||||
require.Equal(t, group.Title, retrievedRule.RuleGroup)
|
||||
|
||||
// successful update
|
||||
groupUpdate := group
|
||||
groupUpdate.Rules[0].Title = "Updated " + groupUpdate.Rules[0].Title
|
||||
updatedRule, status, _ := legacyClient.CreateOrUpdateRuleGroupProvisioning(t, groupUpdate)
|
||||
require.Equal(t, 200, status)
|
||||
require.NotNil(t, updatedRule)
|
||||
require.Equal(t, groupUpdate.Rules[0].Title, updatedRule.Rules[0].Title)
|
||||
|
||||
// verify the change is reflected in k8s
|
||||
k8sRetrievedRule, err := k8sClient.Get(ctx, created.Name, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, k8sRetrievedRule)
|
||||
require.Equal(t, updatedRule.Rules[0].Title, k8sRetrievedRule.Spec.Title)
|
||||
|
||||
// delete the rule group via the provisioning API
|
||||
status, body := legacyClient.DeleteRulesGroupProvisioning(t, group.FolderUID, group.Title)
|
||||
require.Equalf(t, 204, status, "Expected status 200 when deleting rule group, got %d. Body: %s", status, body)
|
||||
// verify the rule is deleted in k8s
|
||||
_, err = k8sClient.Get(ctx, created.Name, v1.GetOptions{})
|
||||
require.Error(t, err, "Expected error when getting deleted rule")
|
||||
require.Contains(t, err.Error(), "not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationAlertRuleCompatCreateViaProvisioning(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
helper := common.GetTestHelper(t)
|
||||
|
||||
k8sClient := common.NewAlertRuleClient(t, helper.Org1.Admin)
|
||||
|
||||
legacyClient := alerting.NewAlertingLegacyAPIClient(helper.GetListenerAddress(), "admin", "admin")
|
||||
|
||||
// Ensure the old provisioning API is enabled
|
||||
allRules, status, _ := legacyClient.GetAllRulesWithStatus(t)
|
||||
require.Equal(t, 200, status)
|
||||
require.NotNil(t, allRules)
|
||||
|
||||
// Create test folder first
|
||||
common.CreateTestFolder(t, helper, "test-folder")
|
||||
|
||||
rule := ngmodels.RuleGen.With(
|
||||
ngmodels.RuleMuts.WithUniqueUID(),
|
||||
ngmodels.RuleMuts.WithUniqueTitle(),
|
||||
ngmodels.RuleMuts.WithNamespaceUID("test-folder"),
|
||||
ngmodels.RuleMuts.WithGroupName("test-group"),
|
||||
ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second),
|
||||
).GenerateMany(2)
|
||||
|
||||
ruleGroup := apimodels.AlertRuleGroup{
|
||||
Title: "test-group",
|
||||
FolderUID: "test-folder",
|
||||
Interval: rule[0].IntervalSeconds,
|
||||
Rules: []apimodels.ProvisionedAlertRule{
|
||||
{
|
||||
UID: rule[0].UID,
|
||||
Title: rule[0].Title,
|
||||
OrgID: 1,
|
||||
Data: []apimodels.AlertQuery{
|
||||
{
|
||||
RefID: "A",
|
||||
DatasourceUID: rule[0].Data[0].DatasourceUID,
|
||||
Model: rule[0].Data[0].Model,
|
||||
RelativeTimeRange: apimodels.RelativeTimeRange{
|
||||
From: apimodels.Duration(time.Duration(5) * time.Minute),
|
||||
To: apimodels.Duration(0),
|
||||
},
|
||||
},
|
||||
},
|
||||
Condition: "A",
|
||||
FolderUID: "test-folder",
|
||||
NoDataState: apimodels.NoDataState(rule[0].NoDataState),
|
||||
ExecErrState: apimodels.ExecutionErrorState(rule[0].ExecErrState),
|
||||
},
|
||||
{
|
||||
UID: rule[1].UID,
|
||||
Title: rule[1].Title,
|
||||
OrgID: 1,
|
||||
Data: []apimodels.AlertQuery{
|
||||
{
|
||||
RefID: "A",
|
||||
DatasourceUID: rule[1].Data[0].DatasourceUID,
|
||||
Model: rule[1].Data[0].Model,
|
||||
RelativeTimeRange: apimodels.RelativeTimeRange{
|
||||
From: apimodels.Duration(time.Duration(5) * time.Minute),
|
||||
To: apimodels.Duration(0),
|
||||
},
|
||||
},
|
||||
},
|
||||
Condition: "A",
|
||||
FolderUID: "test-folder",
|
||||
NoDataState: apimodels.NoDataState(rule[1].NoDataState),
|
||||
ExecErrState: apimodels.ExecutionErrorState(rule[1].ExecErrState),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
created, status, body := legacyClient.CreateOrUpdateRuleGroupProvisioning(t, ruleGroup)
|
||||
require.Equalf(t, 200, status, "Expected status 200, got %d. Response body: %s", status, body)
|
||||
require.NotNil(t, created)
|
||||
|
||||
t.Run("should be able to use the k8s API with these rules", func(t *testing.T) {
|
||||
for i, r := range created.Rules {
|
||||
retrievedRule, err := k8sClient.Get(ctx, r.UID, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, retrievedRule)
|
||||
require.Equal(t, r.Title, retrievedRule.Spec.Title)
|
||||
require.NotNil(t, retrievedRule.Spec.Data[r.Data[0].RefID].Source)
|
||||
require.True(t, *retrievedRule.Spec.Data[r.Data[0].RefID].Source)
|
||||
require.Equal(t, r.FolderUID, retrievedRule.Annotations["grafana.app/folder"])
|
||||
require.Equal(t, created.Title, retrievedRule.Labels[v0alpha1.GroupLabelKey])
|
||||
require.Equal(t, fmt.Sprintf("%d", i), retrievedRule.Labels[v0alpha1.GroupIndexLabelKey])
|
||||
require.Equal(t, ngmodels.ProvenanceAPI, ngmodels.Provenance(retrievedRule.GetProvenanceStatus()))
|
||||
require.EqualValues(t, r.Data[0].DatasourceUID, retrievedRule.Spec.Data["A"].DatasourceUID)
|
||||
expectedDuration, err := prom_model.ParseDuration(fmt.Sprintf("%ds", created.Interval))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedDuration.String(), string(retrievedRule.Spec.Trigger.Interval))
|
||||
expectedModel := map[string]interface{}{}
|
||||
err = json.Unmarshal(r.Data[0].Model, &expectedModel)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expectedModel)
|
||||
retrievedModel, ok := retrievedRule.Spec.Data["A"].Model.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected model to be a map[string]interface{}, got %T", retrievedRule.Spec.Data["A"].Model)
|
||||
}
|
||||
for k, v := range expectedModel {
|
||||
require.EqualValues(t, v, retrievedModel[k], "Model field %s should match", k)
|
||||
}
|
||||
require.EqualValues(t, r.NoDataState, retrievedRule.Spec.NoDataState)
|
||||
require.EqualValues(t, r.ExecErrState, retrievedRule.Spec.ExecErrState)
|
||||
|
||||
// change the title of the rule and check that it's updated in k8s and provisioning API
|
||||
updatedRule := retrievedRule.DeepCopy()
|
||||
updatedRule.Spec.Title = "Updated " + retrievedRule.Spec.Title
|
||||
updatedRule, err = k8sClient.Update(ctx, updatedRule, v1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedRule)
|
||||
|
||||
retrievedRule, err = k8sClient.Get(ctx, r.UID, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, retrievedRule)
|
||||
require.Equal(t, updatedRule.Spec.Title, retrievedRule.Spec.Title)
|
||||
|
||||
provisioningRetrievedRule, status, _ := legacyClient.GetProvisioningAlertRule(t, r.UID)
|
||||
require.NotNil(t, provisioningRetrievedRule)
|
||||
require.Equal(t, 200, status)
|
||||
require.Equal(t, updatedRule.Spec.Title, provisioningRetrievedRule.Title)
|
||||
|
||||
// delete the rule via k8s
|
||||
err = k8sClient.Delete(ctx, retrievedRule.Name, v1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
// check that the rule is deleted in the provisioning API
|
||||
_, status, body := legacyClient.GetProvisioningAlertRule(t, r.UID)
|
||||
require.Equal(t, 404, status, "Expected status 404, got %d. Response body: %s", status, body)
|
||||
|
||||
// check that the rule is deleted in k8s
|
||||
_, err = k8sClient.Get(ctx, r.UID, v1.GetOptions{})
|
||||
require.Error(t, err, "Expected error when getting deleted rule")
|
||||
require.Contains(t, err.Error(), "not found", "Expected 'not found' error, got %s", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationAlertRuleCompatCreateViaProvisioningChangeGroupInK8s(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
helper := common.GetTestHelper(t)
|
||||
|
||||
k8sClient := common.NewAlertRuleClient(t, helper.Org1.Admin)
|
||||
|
||||
legacyClient := alerting.NewAlertingLegacyAPIClient(helper.GetListenerAddress(), "admin", "admin")
|
||||
|
||||
// Ensure the old provisioning API is enabled
|
||||
allRules, status, _ := legacyClient.GetAllRulesWithStatus(t)
|
||||
require.Equal(t, 200, status)
|
||||
require.NotNil(t, allRules)
|
||||
|
||||
// Create test folder first
|
||||
common.CreateTestFolder(t, helper, "test-folder")
|
||||
|
||||
rule := ngmodels.RuleGen.With(
|
||||
ngmodels.RuleMuts.WithUniqueUID(),
|
||||
ngmodels.RuleMuts.WithUniqueTitle(),
|
||||
ngmodels.RuleMuts.WithNamespaceUID("test-folder"),
|
||||
ngmodels.RuleMuts.WithGroupName("test-group"),
|
||||
ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second),
|
||||
).GenerateMany(2)
|
||||
|
||||
ruleGroup := apimodels.AlertRuleGroup{
|
||||
Title: "test-group",
|
||||
FolderUID: "test-folder",
|
||||
Interval: rule[0].IntervalSeconds,
|
||||
Rules: []apimodels.ProvisionedAlertRule{
|
||||
{
|
||||
UID: rule[0].UID,
|
||||
Title: rule[0].Title,
|
||||
OrgID: 1,
|
||||
Data: []apimodels.AlertQuery{
|
||||
{
|
||||
RefID: "X",
|
||||
DatasourceUID: rule[0].Data[0].DatasourceUID,
|
||||
Model: rule[0].Data[0].Model,
|
||||
RelativeTimeRange: apimodels.RelativeTimeRange{
|
||||
From: apimodels.Duration(time.Duration(5) * time.Minute),
|
||||
To: apimodels.Duration(0),
|
||||
},
|
||||
},
|
||||
},
|
||||
Condition: "X",
|
||||
FolderUID: "test-folder",
|
||||
NoDataState: apimodels.NoDataState(rule[0].NoDataState),
|
||||
ExecErrState: apimodels.ExecutionErrorState(rule[0].ExecErrState),
|
||||
},
|
||||
{
|
||||
UID: rule[1].UID,
|
||||
Title: rule[1].Title,
|
||||
OrgID: 1,
|
||||
Data: []apimodels.AlertQuery{
|
||||
{
|
||||
RefID: "X",
|
||||
DatasourceUID: rule[1].Data[0].DatasourceUID,
|
||||
Model: rule[1].Data[0].Model,
|
||||
RelativeTimeRange: apimodels.RelativeTimeRange{
|
||||
From: apimodels.Duration(time.Duration(5) * time.Minute),
|
||||
To: apimodels.Duration(0),
|
||||
},
|
||||
},
|
||||
},
|
||||
Condition: "X",
|
||||
FolderUID: "test-folder",
|
||||
NoDataState: apimodels.NoDataState(rule[1].NoDataState),
|
||||
ExecErrState: apimodels.ExecutionErrorState(rule[1].ExecErrState),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
created, status, body := legacyClient.CreateOrUpdateRuleGroupProvisioning(t, ruleGroup)
|
||||
require.Equalf(t, 200, status, "Expected status 200, got %d. Response body: %s", status, body)
|
||||
require.NotNil(t, created)
|
||||
|
||||
t.Run("should be able to use the k8s API to change the group for a rule", func(t *testing.T) {
|
||||
for i, r := range created.Rules {
|
||||
retrievedRule, err := k8sClient.Get(ctx, r.UID, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, retrievedRule)
|
||||
require.Equal(t, r.Title, retrievedRule.Spec.Title)
|
||||
require.NotNil(t, retrievedRule.Spec.Data[r.Data[0].RefID].Source)
|
||||
require.True(t, *retrievedRule.Spec.Data[r.Data[0].RefID].Source)
|
||||
require.Equal(t, r.FolderUID, retrievedRule.Annotations["grafana.app/folder"])
|
||||
require.Equal(t, created.Title, retrievedRule.Labels[v0alpha1.GroupLabelKey])
|
||||
require.Equal(t, fmt.Sprintf("%d", i), retrievedRule.Labels[v0alpha1.GroupIndexLabelKey])
|
||||
require.Equal(t, ngmodels.ProvenanceAPI, ngmodels.Provenance(retrievedRule.GetProvenanceStatus()))
|
||||
require.EqualValues(t, r.Data[0].DatasourceUID, retrievedRule.Spec.Data["X"].DatasourceUID)
|
||||
expectedDuration, err := prom_model.ParseDuration(fmt.Sprintf("%ds", created.Interval))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedDuration.String(), string(retrievedRule.Spec.Trigger.Interval))
|
||||
expectedModel := map[string]interface{}{}
|
||||
err = json.Unmarshal(r.Data[0].Model, &expectedModel)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expectedModel)
|
||||
retrievedModel, ok := retrievedRule.Spec.Data["X"].Model.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected model to be a map[string]interface{}, got %T", retrievedRule.Spec.Data["X"].Model)
|
||||
}
|
||||
for k, v := range expectedModel {
|
||||
require.EqualValues(t, v, retrievedModel[k], "Model field %s should match", k)
|
||||
}
|
||||
require.EqualValues(t, r.NoDataState, retrievedRule.Spec.NoDataState)
|
||||
require.EqualValues(t, r.ExecErrState, retrievedRule.Spec.ExecErrState)
|
||||
|
||||
// - change group should be allowed and reflected in the provisioning api
|
||||
updatedRule := retrievedRule.DeepCopy()
|
||||
updatedRule.Labels[v0alpha1.GroupLabelKey] = "new-group"
|
||||
updatedRule, err = k8sClient.Update(ctx, updatedRule, v1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedRule)
|
||||
|
||||
// verify the change is reflected in k8s
|
||||
retrievedRule, err = k8sClient.Get(ctx, r.UID, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, retrievedRule)
|
||||
// verify the group label changed
|
||||
require.Equal(t, "new-group", retrievedRule.Labels[v0alpha1.GroupLabelKey])
|
||||
|
||||
// verify the change is reflected in the provisioning API
|
||||
provisioningRetrievedRule, status, _ := legacyClient.GetProvisioningAlertRule(t, r.UID)
|
||||
require.NotNil(t, provisioningRetrievedRule)
|
||||
require.Equal(t, 200, status)
|
||||
// verify the group label changed
|
||||
require.Equal(t, "new-group", provisioningRetrievedRule.RuleGroup)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package compat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1"
|
||||
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/tests/api/alerting"
|
||||
"github.com/grafana/grafana/pkg/tests/apis/alerting/rules/common"
|
||||
"github.com/grafana/grafana/pkg/tests/testsuite"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
prom_model "github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testsuite.Run(m)
|
||||
}
|
||||
|
||||
func TestIntegrationRecordingRuleCompatCreateViaK8s(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
helper := common.GetTestHelper(t)
|
||||
|
||||
k8sClient := common.NewRecordingRuleClient(t, helper.Org1.Admin)
|
||||
|
||||
legacyClient := alerting.NewAlertingLegacyAPIClient(helper.GetListenerAddress(), "admin", "admin")
|
||||
|
||||
// Ensure the old provisioning API is enabled
|
||||
allRules, status, _ := legacyClient.GetAllRulesWithStatus(t)
|
||||
require.Equal(t, 200, status)
|
||||
require.NotNil(t, allRules)
|
||||
|
||||
// Create test folder first
|
||||
common.CreateTestFolder(t, helper, "test-folder")
|
||||
|
||||
rule := ngmodels.RuleGen.With(
|
||||
ngmodels.RuleMuts.WithUniqueUID(),
|
||||
ngmodels.RuleMuts.WithUniqueTitle(),
|
||||
ngmodels.RuleMuts.WithNamespaceUID("test-folder"),
|
||||
ngmodels.RuleMuts.WithGroupName("test-group"),
|
||||
ngmodels.RuleMuts.WithAllRecordingRules(),
|
||||
ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second),
|
||||
).Generate()
|
||||
|
||||
recordingRule := &v0alpha1.RecordingRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
// use provenance api to allow use of the provisioning api
|
||||
"grafana.com/provenance": string(ngmodels.ProvenanceAPI),
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.RecordingRuleSpec{
|
||||
Title: rule.Title,
|
||||
Metric: rule.Record.Metric,
|
||||
Data: map[string]v0alpha1.RecordingRuleQuery{
|
||||
"A": {
|
||||
QueryType: "query",
|
||||
DatasourceUID: v0alpha1.RecordingRuleDatasourceUID(rule.Data[0].DatasourceUID),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.RecordingRuleRelativeTimeRange{
|
||||
From: v0alpha1.RecordingRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.RecordingRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.RecordingRuleIntervalTrigger{
|
||||
Interval: v0alpha1.RecordingRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
created, err := k8sClient.Create(ctx, recordingRule, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, created)
|
||||
|
||||
t.Run("should be able to use the provisioning API with this rule", func(t *testing.T) {
|
||||
retrievedRule, status, _ := legacyClient.GetProvisioningAlertRule(t, created.Name)
|
||||
require.NotNil(t, retrievedRule)
|
||||
require.Equal(t, 200, status)
|
||||
require.Equal(t, created.Spec.Title, retrievedRule.Title)
|
||||
require.Equal(t, created.Spec.Metric, retrievedRule.Record.Metric)
|
||||
require.Equal(t, "A", retrievedRule.Data[0].RefID)
|
||||
|
||||
model := map[string]interface{}{}
|
||||
err := json.Unmarshal(retrievedRule.Data[0].Model, &model)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, model)
|
||||
expectedModel, ok := created.Spec.Data["A"].Model.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected model to be a map[string]interface{}, got %T", created.Spec.Data["A"].Model)
|
||||
}
|
||||
for k, v := range expectedModel {
|
||||
require.EqualValues(t, v, model[k], "Model field %s should match", k)
|
||||
}
|
||||
require.Equal(t, created.Annotations["grafana.app/folder"], retrievedRule.FolderUID)
|
||||
|
||||
// get the group to get the interval
|
||||
group, status, _ := legacyClient.GetRuleGroupProvisioning(t, retrievedRule.FolderUID, retrievedRule.RuleGroup)
|
||||
require.NotNil(t, group)
|
||||
require.Equal(t, 200, status)
|
||||
parsedDuration, err := prom_model.ParseDuration(fmt.Sprintf("%ds", group.Interval))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(created.Spec.Trigger.Interval), parsedDuration.String())
|
||||
|
||||
// try to update the rule group title via the provisioning api
|
||||
groupNameUpdate := group
|
||||
groupNameUpdate.Title = "New Group Name"
|
||||
// this should be rejected
|
||||
_, status, data := legacyClient.CreateOrUpdateRuleGroupProvisioning(t, groupNameUpdate)
|
||||
require.Equalf(t, 400, status, "Expected status 400 when changing group name, got %d. Data: %s", status, data)
|
||||
// verify the group name didn't
|
||||
retrievedRule, status, _ = legacyClient.GetProvisioningAlertRule(t, created.Name)
|
||||
require.NotNil(t, retrievedRule)
|
||||
require.Equal(t, 200, status)
|
||||
require.Equal(t, group.Title, retrievedRule.RuleGroup)
|
||||
|
||||
// successful update
|
||||
groupUpdate := group
|
||||
groupUpdate.Rules[0].Title = "Updated " + groupUpdate.Rules[0].Title
|
||||
updatedRule, status, _ := legacyClient.CreateOrUpdateRuleGroupProvisioning(t, groupUpdate)
|
||||
require.Equal(t, 200, status)
|
||||
require.NotNil(t, updatedRule)
|
||||
require.Equal(t, groupUpdate.Rules[0].Title, updatedRule.Rules[0].Title)
|
||||
|
||||
// verify the change is reflected in k8s
|
||||
k8sRetrievedRule, err := k8sClient.Get(ctx, created.Name, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, k8sRetrievedRule)
|
||||
require.Equal(t, updatedRule.Rules[0].Title, k8sRetrievedRule.Spec.Title)
|
||||
|
||||
// delete the rule group via the provisioning API
|
||||
status, body := legacyClient.DeleteRulesGroupProvisioning(t, group.FolderUID, group.Title)
|
||||
require.Equalf(t, 204, status, "Expected status 200 when deleting rule group, got %d. Body: %s", status, body)
|
||||
// verify the rule is deleted in k8s
|
||||
_, err = k8sClient.Get(ctx, created.Name, v1.GetOptions{})
|
||||
require.Error(t, err, "Expected error when getting deleted rule")
|
||||
require.Contains(t, err.Error(), "not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationRecordingRuleCompatCreateViaProvisioning(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
helper := common.GetTestHelper(t)
|
||||
|
||||
k8sClient := common.NewRecordingRuleClient(t, helper.Org1.Admin)
|
||||
|
||||
legacyClient := alerting.NewAlertingLegacyAPIClient(helper.GetListenerAddress(), "admin", "admin")
|
||||
|
||||
// Ensure the old provisioning API is enabled
|
||||
allRules, status, _ := legacyClient.GetAllRulesWithStatus(t)
|
||||
require.Equal(t, 200, status)
|
||||
require.NotNil(t, allRules)
|
||||
|
||||
// Create test folder first
|
||||
common.CreateTestFolder(t, helper, "test-folder")
|
||||
|
||||
rule := ngmodels.RuleGen.With(
|
||||
ngmodels.RuleMuts.WithUniqueUID(),
|
||||
ngmodels.RuleMuts.WithUniqueTitle(),
|
||||
ngmodels.RuleMuts.WithNamespaceUID("test-folder"),
|
||||
ngmodels.RuleMuts.WithGroupName("test-group"),
|
||||
ngmodels.RuleMuts.WithAllRecordingRules(),
|
||||
ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second),
|
||||
).GenerateMany(2)
|
||||
|
||||
ruleGroup := apimodels.AlertRuleGroup{
|
||||
Title: "test-group",
|
||||
FolderUID: "test-folder",
|
||||
Interval: rule[0].IntervalSeconds,
|
||||
Rules: []apimodels.ProvisionedAlertRule{
|
||||
{
|
||||
UID: rule[0].UID,
|
||||
Title: rule[0].Title,
|
||||
OrgID: 1,
|
||||
Data: []apimodels.AlertQuery{
|
||||
{
|
||||
RefID: "A",
|
||||
DatasourceUID: rule[0].Data[0].DatasourceUID,
|
||||
Model: rule[0].Data[0].Model,
|
||||
RelativeTimeRange: apimodels.RelativeTimeRange{
|
||||
From: apimodels.Duration(time.Duration(5) * time.Minute),
|
||||
To: apimodels.Duration(0),
|
||||
},
|
||||
},
|
||||
},
|
||||
Record: &apimodels.Record{
|
||||
Metric: rule[0].Record.Metric,
|
||||
From: "A",
|
||||
TargetDatasourceUID: rule[0].Record.TargetDatasourceUID,
|
||||
},
|
||||
FolderUID: "test-folder",
|
||||
},
|
||||
{
|
||||
UID: rule[1].UID,
|
||||
Title: rule[1].Title,
|
||||
OrgID: 1,
|
||||
Data: []apimodels.AlertQuery{
|
||||
{
|
||||
RefID: "A",
|
||||
DatasourceUID: rule[1].Data[0].DatasourceUID,
|
||||
Model: rule[1].Data[0].Model,
|
||||
RelativeTimeRange: apimodels.RelativeTimeRange{
|
||||
From: apimodels.Duration(time.Duration(5) * time.Minute),
|
||||
To: apimodels.Duration(0),
|
||||
},
|
||||
},
|
||||
},
|
||||
Record: &apimodels.Record{
|
||||
Metric: rule[1].Record.Metric,
|
||||
From: "A",
|
||||
TargetDatasourceUID: rule[1].Record.TargetDatasourceUID,
|
||||
},
|
||||
FolderUID: "test-folder",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
created, status, body := legacyClient.CreateOrUpdateRuleGroupProvisioning(t, ruleGroup)
|
||||
require.Equalf(t, 200, status, "Expected status 200, got %d. Response body: %s", status, body)
|
||||
require.NotNil(t, created)
|
||||
|
||||
t.Run("should be able to use the k8s API with these rules", func(t *testing.T) {
|
||||
for i, r := range created.Rules {
|
||||
retrievedRule, err := k8sClient.Get(ctx, r.UID, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, retrievedRule)
|
||||
require.Equal(t, r.Title, retrievedRule.Spec.Title)
|
||||
require.Equal(t, r.Record.Metric, retrievedRule.Spec.Metric)
|
||||
require.Equal(t, r.FolderUID, retrievedRule.Annotations["grafana.app/folder"])
|
||||
require.Equal(t, created.Title, retrievedRule.Labels[v0alpha1.GroupLabelKey])
|
||||
require.Equal(t, fmt.Sprintf("%d", i), retrievedRule.Labels[v0alpha1.GroupIndexLabelKey])
|
||||
require.Equal(t, ngmodels.ProvenanceAPI, ngmodels.Provenance(retrievedRule.GetProvenanceStatus()))
|
||||
require.EqualValues(t, r.Data[0].DatasourceUID, retrievedRule.Spec.Data["A"].DatasourceUID)
|
||||
expectedDuration, err := prom_model.ParseDuration(fmt.Sprintf("%ds", created.Interval))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedDuration.String(), string(retrievedRule.Spec.Trigger.Interval))
|
||||
expectedModel := map[string]interface{}{}
|
||||
err = json.Unmarshal(r.Data[0].Model, &expectedModel)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expectedModel)
|
||||
retrievedModel, ok := retrievedRule.Spec.Data["A"].Model.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected model to be a map[string]interface{}, got %T", retrievedRule.Spec.Data["A"].Model)
|
||||
}
|
||||
for k, v := range expectedModel {
|
||||
require.EqualValues(t, v, retrievedModel[k], "Model field %s should match", k)
|
||||
}
|
||||
|
||||
// change the title of the rule and check that it's updated in k8s and provisioning API
|
||||
updatedRule := retrievedRule.DeepCopy()
|
||||
updatedRule.Spec.Title = "Updated " + retrievedRule.Spec.Title
|
||||
updatedRule, err = k8sClient.Update(ctx, updatedRule, v1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedRule)
|
||||
|
||||
retrievedRule, err = k8sClient.Get(ctx, r.UID, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, retrievedRule)
|
||||
require.Equal(t, updatedRule.Spec.Title, retrievedRule.Spec.Title)
|
||||
|
||||
provisioningRetrievedRule, status, _ := legacyClient.GetProvisioningAlertRule(t, r.UID)
|
||||
require.NotNil(t, provisioningRetrievedRule)
|
||||
require.Equal(t, 200, status)
|
||||
require.Equal(t, updatedRule.Spec.Title, provisioningRetrievedRule.Title)
|
||||
|
||||
// delete the rule via k8s
|
||||
err = k8sClient.Delete(ctx, retrievedRule.Name, v1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
// check that the rule is deleted in the provisioning API
|
||||
_, status, body := legacyClient.GetProvisioningAlertRule(t, r.UID)
|
||||
require.Equal(t, 404, status, "Expected status 404, got %d. Response body: %s", status, body)
|
||||
|
||||
// check that the rule is deleted in k8s
|
||||
_, err = k8sClient.Get(ctx, r.UID, v1.GetOptions{})
|
||||
require.Error(t, err, "Expected error when getting deleted rule")
|
||||
require.Contains(t, err.Error(), "not found", "Expected 'not found' error, got %s", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationRecordingRuleCompatCreateViaProvisioningChangeGroupInK8s(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
helper := common.GetTestHelper(t)
|
||||
|
||||
k8sClient := common.NewRecordingRuleClient(t, helper.Org1.Admin)
|
||||
|
||||
legacyClient := alerting.NewAlertingLegacyAPIClient(helper.GetListenerAddress(), "admin", "admin")
|
||||
|
||||
// Ensure the old provisioning API is enabled
|
||||
allRules, status, _ := legacyClient.GetAllRulesWithStatus(t)
|
||||
require.Equal(t, 200, status)
|
||||
require.NotNil(t, allRules)
|
||||
|
||||
// Create test folder first
|
||||
common.CreateTestFolder(t, helper, "test-folder")
|
||||
|
||||
rule := ngmodels.RuleGen.With(
|
||||
ngmodels.RuleMuts.WithUniqueUID(),
|
||||
ngmodels.RuleMuts.WithUniqueTitle(),
|
||||
ngmodels.RuleMuts.WithNamespaceUID("test-folder"),
|
||||
ngmodels.RuleMuts.WithGroupName("test-group"),
|
||||
ngmodels.RuleMuts.WithAllRecordingRules(),
|
||||
ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second),
|
||||
).GenerateMany(2)
|
||||
|
||||
ruleGroup := apimodels.AlertRuleGroup{
|
||||
Title: "test-group",
|
||||
FolderUID: "test-folder",
|
||||
Interval: rule[0].IntervalSeconds,
|
||||
Rules: []apimodels.ProvisionedAlertRule{
|
||||
{
|
||||
UID: rule[0].UID,
|
||||
Title: rule[0].Title,
|
||||
OrgID: 1,
|
||||
Data: []apimodels.AlertQuery{
|
||||
{
|
||||
RefID: "X",
|
||||
DatasourceUID: rule[0].Data[0].DatasourceUID,
|
||||
Model: rule[0].Data[0].Model,
|
||||
RelativeTimeRange: apimodels.RelativeTimeRange{
|
||||
From: apimodels.Duration(time.Duration(5) * time.Minute),
|
||||
To: apimodels.Duration(0),
|
||||
},
|
||||
},
|
||||
},
|
||||
Record: &apimodels.Record{
|
||||
Metric: rule[0].Record.Metric,
|
||||
From: "X",
|
||||
TargetDatasourceUID: rule[0].Record.TargetDatasourceUID,
|
||||
},
|
||||
FolderUID: "test-folder",
|
||||
},
|
||||
{
|
||||
UID: rule[1].UID,
|
||||
Title: rule[1].Title,
|
||||
OrgID: 1,
|
||||
Data: []apimodels.AlertQuery{
|
||||
{
|
||||
RefID: "X",
|
||||
DatasourceUID: rule[1].Data[0].DatasourceUID,
|
||||
Model: rule[1].Data[0].Model,
|
||||
RelativeTimeRange: apimodels.RelativeTimeRange{
|
||||
From: apimodels.Duration(time.Duration(5) * time.Minute),
|
||||
To: apimodels.Duration(0),
|
||||
},
|
||||
},
|
||||
},
|
||||
Record: &apimodels.Record{
|
||||
Metric: rule[1].Record.Metric,
|
||||
From: "X",
|
||||
TargetDatasourceUID: rule[1].Record.TargetDatasourceUID,
|
||||
},
|
||||
FolderUID: "test-folder",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
created, status, body := legacyClient.CreateOrUpdateRuleGroupProvisioning(t, ruleGroup)
|
||||
require.Equalf(t, 200, status, "Expected status 200, got %d. Response body: %s", status, body)
|
||||
require.NotNil(t, created)
|
||||
|
||||
t.Run("should be able to use the k8s API to change the group for a rule", func(t *testing.T) {
|
||||
for i, r := range created.Rules {
|
||||
retrievedRule, err := k8sClient.Get(ctx, r.UID, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, retrievedRule)
|
||||
require.Equal(t, r.Title, retrievedRule.Spec.Title)
|
||||
require.Equal(t, r.Record.Metric, retrievedRule.Spec.Metric)
|
||||
require.Equal(t, r.FolderUID, retrievedRule.Annotations["grafana.app/folder"])
|
||||
require.Equal(t, created.Title, retrievedRule.Labels[v0alpha1.GroupLabelKey])
|
||||
require.Equal(t, fmt.Sprintf("%d", i), retrievedRule.Labels[v0alpha1.GroupIndexLabelKey])
|
||||
require.Equal(t, ngmodels.ProvenanceAPI, ngmodels.Provenance(retrievedRule.GetProvenanceStatus()))
|
||||
require.EqualValues(t, r.Data[0].DatasourceUID, retrievedRule.Spec.Data["X"].DatasourceUID)
|
||||
expectedDuration, err := prom_model.ParseDuration(fmt.Sprintf("%ds", created.Interval))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedDuration.String(), string(retrievedRule.Spec.Trigger.Interval))
|
||||
expectedModel := map[string]interface{}{}
|
||||
err = json.Unmarshal(r.Data[0].Model, &expectedModel)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, expectedModel)
|
||||
retrievedModel, ok := retrievedRule.Spec.Data["X"].Model.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected model to be a map[string]interface{}, got %T", retrievedRule.Spec.Data["X"].Model)
|
||||
}
|
||||
for k, v := range expectedModel {
|
||||
require.EqualValues(t, v, retrievedModel[k], "Model field %s should match", k)
|
||||
}
|
||||
|
||||
// - change group should be allowed and reflected in the provisioning api
|
||||
updatedRule := retrievedRule.DeepCopy()
|
||||
updatedRule.Labels[v0alpha1.GroupLabelKey] = "new-group"
|
||||
updatedRule, err = k8sClient.Update(ctx, updatedRule, v1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedRule)
|
||||
|
||||
// verify the change is reflected in k8s
|
||||
retrievedRule, err = k8sClient.Get(ctx, r.UID, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, retrievedRule)
|
||||
// verify the group label changed
|
||||
require.Equal(t, "new-group", retrievedRule.Labels[v0alpha1.GroupLabelKey])
|
||||
|
||||
// verify the change is reflected in the provisioning API
|
||||
provisioningRetrievedRule, status, _ := legacyClient.GetProvisioningAlertRule(t, r.UID)
|
||||
require.NotNil(t, provisioningRetrievedRule)
|
||||
require.Equal(t, 200, status)
|
||||
// verify the group label changed
|
||||
require.Equal(t, "new-group", provisioningRetrievedRule.RuleGroup)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
package recordingrule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
|
||||
"github.com/grafana/grafana/apps/alerting/rules/pkg/apis/alerting/v0alpha1"
|
||||
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/tests/apis/alerting/rules/common"
|
||||
"github.com/grafana/grafana/pkg/tests/testsuite"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
prom_model "github.com/prometheus/common/model"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
testsuite.Run(m)
|
||||
}
|
||||
|
||||
func TestIntegrationResourceIdentifier(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
helper := common.GetTestHelper(t)
|
||||
client := common.NewRecordingRuleClient(t, helper.Org1.Admin)
|
||||
|
||||
// Create test folder first
|
||||
common.CreateTestFolder(t, helper, "test-folder")
|
||||
|
||||
rule := ngmodels.RuleGen.With(
|
||||
ngmodels.RuleMuts.WithUniqueUID(),
|
||||
ngmodels.RuleMuts.WithUniqueTitle(),
|
||||
ngmodels.RuleMuts.WithNamespaceUID("test-folder"),
|
||||
ngmodels.RuleMuts.WithGroupName("test-group"),
|
||||
ngmodels.RuleMuts.WithAllRecordingRules(),
|
||||
ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second),
|
||||
).Generate()
|
||||
|
||||
newResource := &v0alpha1.RecordingRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.RecordingRuleSpec{
|
||||
Title: rule.Title,
|
||||
Metric: rule.Record.Metric,
|
||||
Data: map[string]v0alpha1.RecordingRuleQuery{
|
||||
"A": {
|
||||
QueryType: "query",
|
||||
DatasourceUID: v0alpha1.RecordingRuleDatasourceUID(rule.Data[0].DatasourceUID),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.RecordingRuleRelativeTimeRange{
|
||||
From: v0alpha1.RecordingRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.RecordingRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.RecordingRuleIntervalTrigger{
|
||||
Interval: v0alpha1.RecordingRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Test 1: Create with explicit name
|
||||
namedResource := newResource.Copy().(*v0alpha1.RecordingRule)
|
||||
namedResource.Name = "explicit-name-recording-rule"
|
||||
namedRule, err := client.Create(ctx, namedResource, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "explicit-name-recording-rule", namedRule.Name)
|
||||
require.NotEmpty(t, namedRule.UID)
|
||||
|
||||
// Test 2: Create without explicit name (auto-generated)
|
||||
autoGenRule, err := client.Create(ctx, newResource, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, autoGenRule.Name)
|
||||
require.NotEmpty(t, autoGenRule.UID)
|
||||
|
||||
// Test 3: Get by identifier
|
||||
retrievedRule, err := client.Get(ctx, autoGenRule.Name, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, autoGenRule.Name, retrievedRule.Name)
|
||||
require.Equal(t, newResource.Spec.Title, retrievedRule.Spec.Title)
|
||||
|
||||
// Test 4: Update (should preserve name)
|
||||
updatedRule := retrievedRule.Copy().(*v0alpha1.RecordingRule)
|
||||
updatedRule.Spec.Title = "updated-recording-rule-title"
|
||||
|
||||
finalRule, err := client.Update(ctx, updatedRule, v1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "updated-recording-rule-title", finalRule.Spec.Title)
|
||||
require.Equal(t, retrievedRule.Name, finalRule.Name, "Update should preserve the resource name")
|
||||
// Note: Recording rule backend doesn't implement ResourceVersion, unlike alert rules
|
||||
|
||||
// Test 5: Verify the update persisted
|
||||
finalRetrieved, err := client.Get(ctx, finalRule.Name, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, finalRule.Spec.Title, finalRetrieved.Spec.Title)
|
||||
require.Equal(t, finalRule.Name, finalRetrieved.Name)
|
||||
require.Equal(t, finalRule.ResourceVersion, finalRetrieved.ResourceVersion)
|
||||
|
||||
// Cleanup
|
||||
require.NoError(t, client.Delete(ctx, namedRule.Name, v1.DeleteOptions{}))
|
||||
require.NoError(t, client.Delete(ctx, finalRule.Name, v1.DeleteOptions{}))
|
||||
}
|
||||
|
||||
// TestIntegrationResourcePermissions is skipped for now as access control is handled in the service layer
|
||||
func TestIntegrationResourcePermissions(t *testing.T) {
|
||||
t.Skip("Access control tests skipped - handled in service layer")
|
||||
}
|
||||
|
||||
// TestIntegrationAccessControl tests basic access control functionality
|
||||
// Access control is primarily handled in the service layer, so this test focuses on basic CRUD operations
|
||||
func TestIntegrationAccessControl(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
helper := common.GetTestHelper(t)
|
||||
|
||||
// Test with admin user for basic functionality
|
||||
adminClient := common.NewRecordingRuleClient(t, helper.Org1.Admin)
|
||||
|
||||
// Create test folder first
|
||||
common.CreateTestFolder(t, helper, "test-folder")
|
||||
|
||||
rule := ngmodels.RuleGen.With(
|
||||
ngmodels.RuleMuts.WithUniqueUID(),
|
||||
ngmodels.RuleMuts.WithUniqueTitle(),
|
||||
ngmodels.RuleMuts.WithNamespaceUID("test-folder"),
|
||||
ngmodels.RuleMuts.WithGroupName("test-group"),
|
||||
ngmodels.RuleMuts.WithAllRecordingRules(),
|
||||
ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second),
|
||||
).Generate()
|
||||
|
||||
recordingRule := &v0alpha1.RecordingRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.RecordingRuleSpec{
|
||||
Title: rule.Title,
|
||||
Metric: rule.Record.Metric,
|
||||
Data: map[string]v0alpha1.RecordingRuleQuery{
|
||||
"A": {
|
||||
QueryType: "query",
|
||||
DatasourceUID: v0alpha1.RecordingRuleDatasourceUID(rule.Data[0].DatasourceUID),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.RecordingRuleRelativeTimeRange{
|
||||
From: v0alpha1.RecordingRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.RecordingRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.RecordingRuleIntervalTrigger{
|
||||
Interval: v0alpha1.RecordingRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("admin should be able to create recording rule", func(t *testing.T) {
|
||||
created, err := adminClient.Create(ctx, recordingRule, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, created)
|
||||
require.Equal(t, recordingRule.Spec.Title, created.Spec.Title)
|
||||
|
||||
// Cleanup
|
||||
defer func() {
|
||||
_ = adminClient.Delete(ctx, created.Name, v1.DeleteOptions{})
|
||||
}()
|
||||
|
||||
t.Run("admin should be able to read recording rule", func(t *testing.T) {
|
||||
read, err := adminClient.Get(ctx, created.Name, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, created.Spec.Title, read.Spec.Title)
|
||||
})
|
||||
|
||||
t.Run("admin should be able to update recording rule", func(t *testing.T) {
|
||||
updated := created.Copy().(*v0alpha1.RecordingRule)
|
||||
updated.Spec.Title = "updated-title"
|
||||
|
||||
result, err := adminClient.Update(ctx, updated, v1.UpdateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "updated-title", result.Spec.Title)
|
||||
})
|
||||
|
||||
t.Run("admin should be able to delete recording rule", func(t *testing.T) {
|
||||
err := adminClient.Delete(ctx, created.Name, v1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationCRUD(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
helper := common.GetTestHelper(t)
|
||||
|
||||
adminClient := common.NewRecordingRuleClient(t, helper.Org1.Admin)
|
||||
|
||||
// Create test folder first
|
||||
common.CreateTestFolder(t, helper, "test-folder")
|
||||
|
||||
baseGen := ngmodels.RuleGen.With(
|
||||
ngmodels.RuleMuts.WithUniqueUID(),
|
||||
ngmodels.RuleMuts.WithUniqueTitle(),
|
||||
ngmodels.RuleMuts.WithNamespaceUID("test-folder"),
|
||||
ngmodels.RuleMuts.WithGroupName("test-group"),
|
||||
ngmodels.RuleMuts.WithAllRecordingRules(),
|
||||
ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second),
|
||||
)
|
||||
|
||||
t.Run("should be able to create and read recording rule", func(t *testing.T) {
|
||||
rule := baseGen.Generate()
|
||||
|
||||
recordingRule := &v0alpha1.RecordingRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
"grafana.com/provenance": "",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.RecordingRuleSpec{
|
||||
Title: rule.Title,
|
||||
Metric: rule.Record.Metric,
|
||||
Data: map[string]v0alpha1.RecordingRuleQuery{
|
||||
"A": {
|
||||
QueryType: "query",
|
||||
DatasourceUID: v0alpha1.RecordingRuleDatasourceUID(rule.Data[0].DatasourceUID),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.RecordingRuleRelativeTimeRange{
|
||||
From: v0alpha1.RecordingRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.RecordingRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.RecordingRuleIntervalTrigger{
|
||||
Interval: v0alpha1.RecordingRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
created, err := adminClient.Create(ctx, recordingRule, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, created)
|
||||
|
||||
t.Run("should be able to read what it is created", func(t *testing.T) {
|
||||
get, err := adminClient.Get(ctx, created.Name, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, created.Spec.Title, get.Spec.Title)
|
||||
createdDuration, err := prom_model.ParseDuration(string(recordingRule.Spec.Trigger.Interval))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, createdDuration.String(), string(get.Spec.Trigger.Interval))
|
||||
|
||||
provenance := get.GetProvenanceStatus()
|
||||
require.Equal(t, v0alpha1.ProvenanceStatusNone, provenance)
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
require.NoError(t, adminClient.Delete(ctx, created.Name, v1.DeleteOptions{}))
|
||||
})
|
||||
|
||||
t.Run("should fail to create recording rule with invalid provenance status", func(t *testing.T) {
|
||||
rule := baseGen.Generate()
|
||||
|
||||
recordingRule := &v0alpha1.RecordingRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
"grafana.com/provenance": "invalid",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.RecordingRuleSpec{
|
||||
Title: rule.Title,
|
||||
Metric: rule.Record.Metric,
|
||||
Data: map[string]v0alpha1.RecordingRuleQuery{
|
||||
"A": {
|
||||
QueryType: "query",
|
||||
DatasourceUID: v0alpha1.RecordingRuleDatasourceUID(rule.Data[0].DatasourceUID),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.RecordingRuleRelativeTimeRange{
|
||||
From: v0alpha1.RecordingRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.RecordingRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.RecordingRuleIntervalTrigger{
|
||||
Interval: v0alpha1.RecordingRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := adminClient.Create(ctx, recordingRule, v1.CreateOptions{})
|
||||
require.Error(t, err, "Creating invalid rule should fail")
|
||||
})
|
||||
|
||||
t.Run("should fail to create recording rule with invalid config", func(t *testing.T) {
|
||||
invalidRule := &v0alpha1.RecordingRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.RecordingRuleSpec{
|
||||
Title: "invalid-recording-rule",
|
||||
Data: map[string]v0alpha1.RecordingRuleQuery{}, // Empty data should fail
|
||||
Trigger: v0alpha1.RecordingRuleIntervalTrigger{
|
||||
Interval: "30s",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := adminClient.Create(ctx, invalidRule, v1.CreateOptions{})
|
||||
require.Errorf(t, err, "Expected error but got successful result")
|
||||
// The validation happens at the service level, so we just need to verify it fails
|
||||
require.Error(t, err, "Creating invalid rule should fail")
|
||||
})
|
||||
|
||||
t.Run("should not be able to add rule to group", func(t *testing.T) {
|
||||
rule := baseGen.Generate()
|
||||
|
||||
recordingRule := &v0alpha1.RecordingRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.RecordingRuleSpec{
|
||||
Title: rule.Title,
|
||||
Metric: rule.Record.Metric,
|
||||
Data: map[string]v0alpha1.RecordingRuleQuery{
|
||||
"A": {
|
||||
QueryType: "query",
|
||||
DatasourceUID: v0alpha1.RecordingRuleDatasourceUID(rule.Data[0].DatasourceUID),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.RecordingRuleRelativeTimeRange{
|
||||
From: v0alpha1.RecordingRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.RecordingRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.RecordingRuleIntervalTrigger{
|
||||
Interval: v0alpha1.RecordingRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
created, err := adminClient.Create(ctx, recordingRule, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, created)
|
||||
|
||||
get, err := adminClient.Get(ctx, created.Name, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, created.Spec.Title, get.Spec.Title)
|
||||
|
||||
// Attempt to update the group name via a patch (should fail)
|
||||
update := get
|
||||
if update.Labels == nil {
|
||||
update.Labels = map[string]string{}
|
||||
}
|
||||
update.Labels[v0alpha1.GroupLabelKey] = "new-group-name"
|
||||
_, err = adminClient.Update(ctx, update, v1.UpdateOptions{})
|
||||
require.Error(t, err, "Updating the group name should fail")
|
||||
|
||||
// Cleanup
|
||||
require.NoError(t, adminClient.Delete(ctx, created.Name, v1.DeleteOptions{}))
|
||||
})
|
||||
|
||||
t.Run("should not be able to create rule without any source query", func(t *testing.T) {
|
||||
rule := baseGen.Generate()
|
||||
|
||||
recordingRule := &v0alpha1.RecordingRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.RecordingRuleSpec{
|
||||
Title: rule.Title,
|
||||
Metric: rule.Record.Metric,
|
||||
Data: map[string]v0alpha1.RecordingRuleQuery{
|
||||
"A": {
|
||||
QueryType: "query",
|
||||
DatasourceUID: v0alpha1.RecordingRuleDatasourceUID(rule.Data[0].DatasourceUID),
|
||||
Model: rule.Data[0].Model,
|
||||
RelativeTimeRange: &v0alpha1.RecordingRuleRelativeTimeRange{
|
||||
From: v0alpha1.RecordingRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.RecordingRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.RecordingRuleIntervalTrigger{
|
||||
Interval: v0alpha1.RecordingRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
created, err := adminClient.Create(ctx, recordingRule, v1.CreateOptions{})
|
||||
require.ErrorContains(t, err, "no query marked as source")
|
||||
require.Nil(t, created)
|
||||
})
|
||||
t.Run("should not be able to create rule with interval less than base", func(t *testing.T) {
|
||||
rule := baseGen.With(
|
||||
ngmodels.RuleMuts.WithInterval(time.Duration(1) * time.Second),
|
||||
).Generate()
|
||||
|
||||
recordingRule := &v0alpha1.RecordingRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.RecordingRuleSpec{
|
||||
Title: rule.Title,
|
||||
Metric: rule.Record.Metric,
|
||||
Data: map[string]v0alpha1.RecordingRuleQuery{
|
||||
"A": {
|
||||
QueryType: "query",
|
||||
DatasourceUID: v0alpha1.RecordingRuleDatasourceUID(rule.Data[0].DatasourceUID),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.RecordingRuleRelativeTimeRange{
|
||||
From: v0alpha1.RecordingRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.RecordingRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.RecordingRuleIntervalTrigger{
|
||||
Interval: v0alpha1.RecordingRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
created, err := adminClient.Create(ctx, recordingRule, v1.CreateOptions{})
|
||||
require.ErrorContains(t, err, "invalid alert rule")
|
||||
require.Nil(t, created)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationPatch(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
helper := common.GetTestHelper(t)
|
||||
|
||||
adminClient := common.NewRecordingRuleClient(t, helper.Org1.Admin)
|
||||
|
||||
// Create test folder first
|
||||
common.CreateTestFolder(t, helper, "test-folder")
|
||||
|
||||
rule := ngmodels.RuleGen.With(
|
||||
ngmodels.RuleMuts.WithUniqueUID(),
|
||||
ngmodels.RuleMuts.WithUniqueTitle(),
|
||||
ngmodels.RuleMuts.WithNamespaceUID("test-folder"),
|
||||
ngmodels.RuleMuts.WithGroupName("test-group"),
|
||||
ngmodels.RuleMuts.WithAllRecordingRules(),
|
||||
ngmodels.RuleMuts.WithIntervalMatching(time.Duration(10)*time.Second),
|
||||
).Generate()
|
||||
|
||||
recordingRule := &v0alpha1.RecordingRule{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Annotations: map[string]string{
|
||||
"grafana.app/folder": "test-folder",
|
||||
},
|
||||
},
|
||||
Spec: v0alpha1.RecordingRuleSpec{
|
||||
Title: rule.Title,
|
||||
Metric: rule.Record.Metric,
|
||||
Data: map[string]v0alpha1.RecordingRuleQuery{
|
||||
"A": {
|
||||
QueryType: "query",
|
||||
DatasourceUID: v0alpha1.RecordingRuleDatasourceUID(rule.Data[0].DatasourceUID),
|
||||
Model: rule.Data[0].Model,
|
||||
Source: util.Pointer(true),
|
||||
RelativeTimeRange: &v0alpha1.RecordingRuleRelativeTimeRange{
|
||||
From: v0alpha1.RecordingRulePromDurationWMillis("5m"),
|
||||
To: v0alpha1.RecordingRulePromDurationWMillis("0s"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Trigger: v0alpha1.RecordingRuleIntervalTrigger{
|
||||
Interval: v0alpha1.RecordingRulePromDuration(fmt.Sprintf("%ds", rule.IntervalSeconds)),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
current, err := adminClient.Create(ctx, recordingRule, v1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, current)
|
||||
|
||||
t.Run("should patch with json patch", func(t *testing.T) {
|
||||
current, err := adminClient.Get(ctx, current.Name, v1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
patch := []map[string]any{
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/spec/title",
|
||||
"value": "patched-title",
|
||||
},
|
||||
}
|
||||
|
||||
patchData, err := json.Marshal(patch)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := adminClient.Patch(ctx, current.Name, types.JSONPatchType, patchData, v1.PatchOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "patched-title", result.Spec.Title)
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
require.NoError(t, adminClient.Delete(ctx, current.Name, v1.DeleteOptions{}))
|
||||
}
|
||||
|
||||
func TestIntegrationBasicAPI(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
helper := common.GetTestHelper(t)
|
||||
adminClient := common.NewRecordingRuleClient(t, helper.Org1.Admin)
|
||||
|
||||
t.Run("should be able to list rules", func(t *testing.T) {
|
||||
list, err := adminClient.List(ctx, v1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
// Should at least be able to list, even if empty
|
||||
require.NotNil(t, list)
|
||||
})
|
||||
|
||||
t.Run("should handle get of non-existent rule", func(t *testing.T) {
|
||||
_, err := adminClient.Get(ctx, "non-existent", v1.GetOptions{})
|
||||
// The API might return different error types, so just check that it's an error
|
||||
require.Error(t, err)
|
||||
t.Logf("Got error: %s", err)
|
||||
})
|
||||
}
|
||||
@@ -59,9 +59,10 @@ const (
|
||||
)
|
||||
|
||||
type K8sTestHelper struct {
|
||||
t *testing.T
|
||||
env server.TestEnv
|
||||
Namespacer request.NamespaceMapper
|
||||
t *testing.T
|
||||
listenerAddress string
|
||||
env server.TestEnv
|
||||
Namespacer request.NamespaceMapper
|
||||
|
||||
Org1 OrgUsers // default
|
||||
OrgB OrgUsers // some other id
|
||||
@@ -88,12 +89,13 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper {
|
||||
// The flag only exists to support the transition from the old to the new behavior in dev/ops/prod.
|
||||
opts.EnableFeatureToggles = append(opts.EnableFeatureToggles, featuremgmt.FlagAppPlatformGrpcClientAuth)
|
||||
dir, path := testinfra.CreateGrafDir(t, opts)
|
||||
_, env := testinfra.StartGrafanaEnv(t, dir, path)
|
||||
listenerAddress, env := testinfra.StartGrafanaEnv(t, dir, path)
|
||||
|
||||
c := &K8sTestHelper{
|
||||
env: *env,
|
||||
t: t,
|
||||
Namespacer: request.GetNamespaceMapper(nil),
|
||||
env: *env,
|
||||
listenerAddress: listenerAddress,
|
||||
t: t,
|
||||
Namespacer: request.GetNamespaceMapper(nil),
|
||||
}
|
||||
|
||||
cfgProvider, err := configprovider.ProvideService(c.env.Cfg)
|
||||
@@ -151,6 +153,10 @@ func (c *K8sTestHelper) GetEnv() server.TestEnv {
|
||||
return c.env
|
||||
}
|
||||
|
||||
func (c *K8sTestHelper) GetListenerAddress() string {
|
||||
return c.listenerAddress
|
||||
}
|
||||
|
||||
func (c *K8sTestHelper) Shutdown() {
|
||||
err := c.env.Server.Shutdown(context.Background(), "done")
|
||||
require.NoError(c.t, err)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ func TestIntegrationOpenAPIs(t *testing.T) {
|
||||
featuremgmt.FlagProvisioning,
|
||||
featuremgmt.FlagInvestigationsBackend,
|
||||
featuremgmt.FlagGrafanaAdvisor,
|
||||
featuremgmt.FlagKubernetesAlertingRules,
|
||||
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // all datasources
|
||||
},
|
||||
})
|
||||
@@ -94,6 +95,9 @@ func TestIntegrationOpenAPIs(t *testing.T) {
|
||||
}, {
|
||||
Group: "notifications.alerting.grafana.app",
|
||||
Version: "v0alpha1",
|
||||
}, {
|
||||
Group: "rules.alerting.grafana.app",
|
||||
Version: "v0alpha1",
|
||||
}}
|
||||
for _, gv := range groups {
|
||||
VerifyOpenAPISnapshots(t, dir, gv, h)
|
||||
|
||||
Reference in New Issue
Block a user