From 56e1be8a6074901011a7e7d9240212d609be18e0 Mon Sep 17 00:00:00 2001 From: Tito Lins Date: Wed, 14 Jan 2026 23:49:50 +0100 Subject: [PATCH] Alerting: Support imported Mimir time intervals Implements support for displaying imported Mimir/Prometheus time intervals in Grafana following the templates pattern. Changes: - Add Kind field to MuteTimeInterval model to differentiate Grafana vs Mimir intervals - Add includeImported support to MuteTimingService following templates pattern - Update GetMuteTimings to read and expose imported intervals from ExtraConfigs - Fix GetMuteTiming UID lookup to work with kind-prefixed UIDs - Block create/update/delete operations on imported intervals (read-only) - Wire up service with WithIncludeImported() in ngalert initialization - Update dependency to alerting@c56cf15 which includes TimeIntervalKind All changes follow the templates.go pattern. --- go.mod | 2 +- go.sum | 2 + .../definitions/provisioning_mute_timings.go | 6 +- pkg/services/ngalert/ngalert.go | 4 +- .../ngalert/provisioning/mute_timings.go | 170 ++++++++++++++++-- .../ngalert/provisioning/mute_timings_test.go | 13 +- 6 files changed, 168 insertions(+), 29 deletions(-) diff --git a/go.mod b/go.mod index ade26f2e7d1..2ca54d7f1b8 100644 --- a/go.mod +++ b/go.mod @@ -89,7 +89,7 @@ require ( github.com/googleapis/gax-go/v2 v2.15.0 // @grafana/grafana-backend-group github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad - github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20260114220314-c56cf1580dcf // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics diff --git a/go.sum b/go.sum index f997af7c68e..9d24b12c4c1 100644 --- a/go.sum +++ b/go.sum @@ -1627,6 +1627,8 @@ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5T github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f h1:3bXOyht68qkfvD6Y8z8XoenFbytSSOIkr/s+AqRzj0o= github.com/grafana/alerting v0.0.0-20260112172717-98a49ed9557f/go.mod h1:Ji0SfJChcwjgq8ljy6Y5CcYfHfAYKXjKYeysOoDS/6s= +github.com/grafana/alerting v0.0.0-20260114220314-c56cf1580dcf h1:3q+bASuUgOPd3AVcmfaZCvqZjf7lhB6Oydt8OdShcy8= +github.com/grafana/alerting v0.0.0-20260114220314-c56cf1580dcf/go.mod h1:Ji0SfJChcwjgq8ljy6Y5CcYfHfAYKXjKYeysOoDS/6s= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_mute_timings.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_mute_timings.go index 3df41ff7ebf..3144e524ac1 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_mute_timings.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_mute_timings.go @@ -1,6 +1,7 @@ package definitions import ( + "github.com/grafana/alerting/definition" "github.com/prometheus/alertmanager/config" ) @@ -117,8 +118,9 @@ type MuteTimingHeaders struct { type MuteTimeInterval struct { UID string `json:"-" yaml:"-"` config.MuteTimeInterval `json:",inline" yaml:",inline"` - Version string `json:"version,omitempty"` - Provenance Provenance `json:"provenance,omitempty"` + Version string `json:"version,omitempty"` + Provenance Provenance `json:"provenance,omitempty"` + Kind definition.TimeIntervalKind `json:"-" yaml:"-"` } func (mt *MuteTimeInterval) ResourceType() string { diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 5177107a602..9c56e8f04ab 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -434,8 +434,8 @@ func (ng *AlertNG) init() error { // Provisioning policyService := provisioning.NewNotificationPolicyService(configStore, ng.store, ng.store, ng.Cfg.UnifiedAlerting, ng.Log) contactPointService := provisioning.NewContactPointService(configStore, ng.SecretsService, ng.store, ng.store, provisioningReceiverService, ng.Log, ng.store, ng.ResourcePermissions) - templateService := provisioning.NewTemplateService(configStore, ng.store, ng.store, ng.Log) - muteTimingService := provisioning.NewMuteTimingService(configStore, ng.store, ng.store, ng.Log, ng.store) + templateService := provisioning.NewTemplateService(configStore, ng.store, ng.store, ng.Log).WithIncludeImported() + muteTimingService := provisioning.NewMuteTimingService(configStore, ng.store, ng.store, ng.Log, ng.store).WithIncludeImported() alertRuleService := provisioning.NewAlertRuleService(ng.store, ng.store, ng.folderService, ng.QuotaService, ng.store, int64(ng.Cfg.UnifiedAlerting.DefaultRuleEvaluationInterval.Seconds()), int64(ng.Cfg.UnifiedAlerting.BaseInterval.Seconds()), diff --git a/pkg/services/ngalert/provisioning/mute_timings.go b/pkg/services/ngalert/provisioning/mute_timings.go index fb0db8deae6..8ad2fc5d13b 100644 --- a/pkg/services/ngalert/provisioning/mute_timings.go +++ b/pkg/services/ngalert/provisioning/mute_timings.go @@ -9,6 +9,7 @@ import ( "strings" "unsafe" + "github.com/grafana/alerting/definition" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/timeinterval" "golang.org/x/exp/maps" @@ -27,6 +28,7 @@ type MuteTimingService struct { log log.Logger validator validation.ProvenanceStatusTransitionValidator ruleNotificationsStore AlertRuleNotificationSettingsStore + includeImported bool } func NewMuteTimingService(config alertmanagerConfigStore, prov ProvisioningStore, xact TransactionManager, log log.Logger, ns AlertRuleNotificationSettingsStore) *MuteTimingService { @@ -37,6 +39,19 @@ func NewMuteTimingService(config alertmanagerConfigStore, prov ProvisioningStore log: log, validator: validation.ValidateProvenanceRelaxed, ruleNotificationsStore: ns, + includeImported: false, + } +} + +func (svc *MuteTimingService) WithIncludeImported() *MuteTimingService { + return &MuteTimingService{ + configStore: svc.configStore, + provenanceStore: svc.provenanceStore, + xact: svc.xact, + log: svc.log, + validator: svc.validator, + ruleNotificationsStore: svc.ruleNotificationsStore, + includeImported: true, } } @@ -47,9 +62,15 @@ func (svc *MuteTimingService) GetMuteTimings(ctx context.Context, orgID int64) ( return nil, err } - intervals := getTimeIntervals(rev) + // Get Grafana intervals + grafanaIntervals := make([]config.MuteTimeInterval, 0, len(rev.Config.AlertmanagerConfig.TimeIntervals)+len(rev.Config.AlertmanagerConfig.MuteTimeIntervals)) + for _, interval := range rev.Config.AlertmanagerConfig.TimeIntervals { + grafanaIntervals = append(grafanaIntervals, config.MuteTimeInterval(interval)) + } + grafanaIntervals = append(grafanaIntervals, rev.Config.AlertmanagerConfig.MuteTimeIntervals...) - if len(intervals) == 0 { + // Early return if no intervals (including imported) + if len(grafanaIntervals) == 0 && (!svc.includeImported || len(rev.Config.ExtraConfigs) == 0) { return []definitions.MuteTimeInterval{}, nil } @@ -58,22 +79,51 @@ func (svc *MuteTimingService) GetMuteTimings(ctx context.Context, orgID int64) ( return nil, err } - slices.SortFunc(intervals, func(a, b config.MuteTimeInterval) int { - return strings.Compare(a.Name, b.Name) - }) - result := make([]definitions.MuteTimeInterval, 0, len(intervals)) - for _, interval := range intervals { + result := make([]definitions.MuteTimeInterval, 0, len(grafanaIntervals)) + for _, interval := range grafanaIntervals { version := calculateMuteTimeIntervalFingerprint(interval) def := definitions.MuteTimeInterval{ - UID: legacy_storage.NameToUid(interval.Name), + UID: timeIntervalUID(string(definition.GrafanaTimeIntervalKind), interval.Name), MuteTimeInterval: interval, Version: version, + Kind: definition.GrafanaTimeIntervalKind, } if prov, ok := provenances[def.ResourceID()]; ok { def.Provenance = definitions.Provenance(prov) } result = append(result, def) } + + // Add imported Mimir intervals if requested + if svc.includeImported && len(rev.Config.ExtraConfigs) > 0 { + mimirCfg := rev.Config.ExtraConfigs[0] + cfg, err := mimirCfg.GetAlertmanagerConfig() + if err == nil { + mimirIntervals := make([]config.MuteTimeInterval, 0, len(cfg.GetMuteTimeIntervals())+len(cfg.GetTimeIntervals())) + mimirIntervals = append(mimirIntervals, cfg.GetMuteTimeIntervals()...) + for _, ti := range cfg.GetTimeIntervals() { + mimirIntervals = append(mimirIntervals, config.MuteTimeInterval(ti)) + } + + for _, interval := range mimirIntervals { + version := calculateMuteTimeIntervalFingerprint(interval) + def := definitions.MuteTimeInterval{ + UID: timeIntervalUID(string(definition.MimirTimeIntervalKind), interval.Name), + MuteTimeInterval: interval, + Version: version, + Kind: definition.MimirTimeIntervalKind, + Provenance: definitions.Provenance(models.ProvenanceConvertedPrometheus), + } + result = append(result, def) + } + } + } + + // Sort by name + slices.SortFunc(result, func(a, b definitions.MuteTimeInterval) int { + return strings.Compare(a.Name, b.Name) + }) + return result, nil } @@ -84,28 +134,71 @@ func (svc *MuteTimingService) GetMuteTiming(ctx context.Context, nameOrUID strin return definitions.MuteTimeInterval{}, err } + // Try to find in Grafana intervals first mt, found := getMuteTimingByName(rev, nameOrUID) + kind := definition.GrafanaTimeIntervalKind + provenance := models.ProvenanceNone + + // If not found by name, try UID lookup in Grafana intervals if !found { - name, err := legacy_storage.UidToName(nameOrUID) - if err == nil { - mt, found = getMuteTimingByName(rev, name) + grafanaIntervals := getTimeIntervals(rev, false) + for _, interval := range grafanaIntervals { + if timeIntervalUID(string(definition.GrafanaTimeIntervalKind), interval.Name) == nameOrUID { + mt = interval + found = true + break + } } } + + // If not found in Grafana and includeImported, try Mimir (by name or UID) + if !found && svc.includeImported && len(rev.Config.ExtraConfigs) > 0 { + mimirCfg := rev.Config.ExtraConfigs[0] + cfg, err := mimirCfg.GetAlertmanagerConfig() + if err == nil { + // Collect all Mimir intervals + mimirIntervals := make([]config.MuteTimeInterval, 0, len(cfg.GetMuteTimeIntervals())+len(cfg.GetTimeIntervals())) + mimirIntervals = append(mimirIntervals, cfg.GetMuteTimeIntervals()...) + for _, ti := range cfg.GetTimeIntervals() { + mimirIntervals = append(mimirIntervals, config.MuteTimeInterval(ti)) + } + + for _, interval := range mimirIntervals { + // Check by name or UID + if interval.Name == nameOrUID || + timeIntervalUID(string(definition.MimirTimeIntervalKind), interval.Name) == nameOrUID { + mt = interval + found = true + kind = definition.MimirTimeIntervalKind + provenance = models.ProvenanceConvertedPrometheus + break + } + } + } + } + if !found { return definitions.MuteTimeInterval{}, ErrTimeIntervalNotFound.Errorf("") } result := definitions.MuteTimeInterval{ - UID: legacy_storage.NameToUid(mt.Name), + UID: timeIntervalUID(string(kind), mt.Name), MuteTimeInterval: mt, Version: calculateMuteTimeIntervalFingerprint(mt), + Kind: kind, } - prov, err := svc.provenanceStore.GetProvenance(ctx, &result, orgID) - if err != nil { - return definitions.MuteTimeInterval{}, err + // Only check provenance store for Grafana intervals + if kind == definition.GrafanaTimeIntervalKind { + prov, err := svc.provenanceStore.GetProvenance(ctx, &result, orgID) + if err != nil { + return definitions.MuteTimeInterval{}, err + } + result.Provenance = definitions.Provenance(prov) + } else { + result.Provenance = definitions.Provenance(provenance) } - result.Provenance = definitions.Provenance(prov) + return result, nil } @@ -115,6 +208,11 @@ func (svc *MuteTimingService) CreateMuteTiming(ctx context.Context, mt definitio return definitions.MuteTimeInterval{}, MakeErrTimeIntervalInvalid(err) } + // Block creation of Mimir kind intervals + if mt.Kind == definition.MimirTimeIntervalKind { + return definitions.MuteTimeInterval{}, MakeErrTimeIntervalInvalid(fmt.Errorf("time intervals of kind 'mimir' cannot be created")) + } + revision, err := svc.configStore.Get(ctx, orgID) if err != nil { return definitions.MuteTimeInterval{}, err @@ -168,6 +266,16 @@ func (svc *MuteTimingService) UpdateMuteTiming(ctx context.Context, mt definitio return definitions.MuteTimeInterval{}, ErrTimeIntervalNotFound.Errorf("") } + // Block updates to imported intervals + existingMT := definitions.MuteTimeInterval{MuteTimeInterval: old} + prov, err := svc.provenanceStore.GetProvenance(ctx, &existingMT, orgID) + if err != nil { + return definitions.MuteTimeInterval{}, err + } + if prov == models.ProvenanceConvertedPrometheus { + return definitions.MuteTimeInterval{}, MakeErrTimeIntervalInvalid(fmt.Errorf("cannot update imported time interval from mimir/prometheus")) + } + // check optimistic concurrency err = svc.checkOptimisticConcurrency(old, models.Provenance(mt.Provenance), mt.Version, "update") if err != nil { @@ -243,6 +351,12 @@ func (svc *MuteTimingService) DeleteMuteTiming(ctx context.Context, nameOrUID st if err != nil { return err } + + // Block deletes of imported intervals + if storedProvenance == models.ProvenanceConvertedPrometheus { + return MakeErrTimeIntervalInvalid(fmt.Errorf("cannot delete imported time interval from mimir/prometheus")) + } + if err := svc.validator(storedProvenance, models.Provenance(provenance)); err != nil { return err } @@ -296,7 +410,7 @@ func isTimeIntervalInUseInRoutes(name string, route *definitions.Route) bool { } func getMuteTimingByName(rev *legacy_storage.ConfigRevision, name string) (config.MuteTimeInterval, bool) { - intervals := getTimeIntervals(rev) + intervals := getTimeIntervals(rev, false) idx := slices.IndexFunc(intervals, func(interval config.MuteTimeInterval) bool { return interval.Name == name }) @@ -306,12 +420,26 @@ func getMuteTimingByName(rev *legacy_storage.ConfigRevision, name string) (confi return intervals[idx], true } -func getTimeIntervals(rev *legacy_storage.ConfigRevision) []config.MuteTimeInterval { +func getTimeIntervals(rev *legacy_storage.ConfigRevision, includeImported bool) []config.MuteTimeInterval { result := make([]config.MuteTimeInterval, 0, len(rev.Config.AlertmanagerConfig.TimeIntervals)+len(rev.Config.AlertmanagerConfig.MuteTimeIntervals)) for _, interval := range rev.Config.AlertmanagerConfig.TimeIntervals { result = append(result, config.MuteTimeInterval(interval)) } - return append(result, rev.Config.AlertmanagerConfig.MuteTimeIntervals...) + result = append(result, rev.Config.AlertmanagerConfig.MuteTimeIntervals...) + + // Add imported intervals from Mimir config + if includeImported && len(rev.Config.ExtraConfigs) > 0 { + mimirCfg := rev.Config.ExtraConfigs[0] + cfg, err := mimirCfg.GetAlertmanagerConfig() + if err == nil { + result = append(result, cfg.GetMuteTimeIntervals()...) + for _, ti := range cfg.GetTimeIntervals() { + result = append(result, config.MuteTimeInterval(ti)) + } + } + } + + return result } func updateTimeInterval(rev *legacy_storage.ConfigRevision, interval config.MuteTimeInterval) { @@ -338,6 +466,10 @@ func deleteTimeInterval(rev *legacy_storage.ConfigRevision, interval config.Mute }) } +func timeIntervalUID(kind string, name string) string { + return legacy_storage.NameToUid(fmt.Sprintf("%s|%s", kind, name)) +} + func calculateMuteTimeIntervalFingerprint(interval config.MuteTimeInterval) string { sum := fnv.New64() diff --git a/pkg/services/ngalert/provisioning/mute_timings_test.go b/pkg/services/ngalert/provisioning/mute_timings_test.go index d7ee36728b8..e00533177bb 100644 --- a/pkg/services/ngalert/provisioning/mute_timings_test.go +++ b/pkg/services/ngalert/provisioning/mute_timings_test.go @@ -66,17 +66,18 @@ func TestGetMuteTimings(t *testing.T) { require.Equal(t, "Test1", result[0].Name) require.EqualValues(t, provenances["Test1"], result[0].Provenance) require.NotEmpty(t, result[0].Version) - require.Equal(t, legacy_storage.NameToUid(result[0].Name), result[0].UID) + // UID now includes kind prefix + require.Equal(t, legacy_storage.NameToUid(fmt.Sprintf("grafana|%s", result[0].Name)), result[0].UID) require.Equal(t, "Test2", result[1].Name) require.EqualValues(t, provenances["Test2"], result[1].Provenance) require.NotEmpty(t, result[1].Version) - require.Equal(t, legacy_storage.NameToUid(result[1].Name), result[1].UID) + require.Equal(t, legacy_storage.NameToUid(fmt.Sprintf("grafana|%s", result[1].Name)), result[1].UID) require.Equal(t, "Test3", result[2].Name) require.EqualValues(t, "", result[2].Provenance) require.NotEmpty(t, result[2].Version) - require.Equal(t, legacy_storage.NameToUid(result[2].Name), result[2].UID) + require.Equal(t, legacy_storage.NameToUid(fmt.Sprintf("grafana|%s", result[2].Name)), result[2].UID) require.Len(t, store.Calls, 1) require.Equal(t, "Get", store.Calls[0].Method) @@ -161,7 +162,8 @@ func TestGetMuteTiming(t *testing.T) { require.Equal(t, "Test1", result.Name) require.EqualValues(t, models.ProvenanceAPI, result.Provenance) - require.Equal(t, legacy_storage.NameToUid(result.Name), result.UID) + // UID now includes kind prefix + require.Equal(t, legacy_storage.NameToUid(fmt.Sprintf("grafana|%s", result.Name)), result.UID) require.NotEmpty(t, result.Version) require.Len(t, store.Calls, 1) @@ -192,7 +194,8 @@ func TestGetMuteTiming(t *testing.T) { require.Equal(t, "Test2", result.Name) require.EqualValues(t, models.ProvenanceFile, result.Provenance) - require.Equal(t, legacy_storage.NameToUid(result.Name), result.UID) + // UID now includes kind prefix + require.Equal(t, legacy_storage.NameToUid(fmt.Sprintf("grafana|%s", result.Name)), result.UID) require.NotEmpty(t, result.Version) require.Len(t, store.Calls, 1)