2020-11-12 07:11:30 -06:00
|
|
|
package ngalert
|
|
|
|
|
|
|
|
import (
|
2020-12-17 08:00:09 -06:00
|
|
|
"context"
|
2022-07-12 14:13:04 -05:00
|
|
|
"fmt"
|
2021-09-30 11:51:20 -05:00
|
|
|
"net/url"
|
2023-01-26 03:31:20 -06:00
|
|
|
"time"
|
2020-12-17 08:00:09 -06:00
|
|
|
|
2021-10-07 09:33:50 -05:00
|
|
|
"github.com/benbjohnson/clock"
|
Alerting: Support UTF-8 (#81512)
This pull request updates our fork of Alertmanager to commit 65bdab0, which is based on commit 5658f8c in Prometheus Alertmanager.
It applies the changes from grafana/alerting#155 which removes the overrides for validation of alerts, labels and silences that we had put in place to allow alerts and silences to work for non-Prometheus datasources. However, as this is now supported in Alertmanager with the UTF-8 work, we can use the new upstream functions and remove these overrides.
The compat package is a package in Alertmanager that takes care of backwards compatibility when parsing matchers, validating alerts, labels and silences. It has three modes: classic mode, UTF-8 strict mode, fallback mode. These modes are controlled via compat.InitFromFlags. Grafana initializes the compat package without any feature flags, which is the equivalent of fallback mode. Classic and UTF-8 strict mode are used in Mimir.
While Grafana Managed Alerts have no need for fallback mode, Grafana can still be used as an interface to manage the configurations of Mimir Alertmanagers and view configurations of Prometheus Alertmanager, and those installations might not have migrated or being running on older versions. Such installations behave as if in classic mode, and Grafana must be able to parse their configurations to interact with them for some period of time. As such, Grafana uses fallback mode until we are ready to drop support for outdated installations of Mimir and the Prometheus Alertmanager.
2024-02-06 02:33:47 -06:00
|
|
|
"github.com/prometheus/alertmanager/featurecontrol"
|
|
|
|
"github.com/prometheus/alertmanager/matchers/compat"
|
2022-03-08 08:22:16 -06:00
|
|
|
"golang.org/x/sync/errgroup"
|
|
|
|
|
2020-11-12 07:11:30 -06:00
|
|
|
"github.com/grafana/grafana/pkg/api/routing"
|
2022-06-17 12:10:49 -05:00
|
|
|
"github.com/grafana/grafana/pkg/bus"
|
2022-08-01 18:28:38 -05:00
|
|
|
"github.com/grafana/grafana/pkg/events"
|
2021-11-10 04:52:16 -06:00
|
|
|
"github.com/grafana/grafana/pkg/expr"
|
2022-10-19 08:02:15 -05:00
|
|
|
"github.com/grafana/grafana/pkg/infra/db"
|
2021-09-09 11:25:22 -05:00
|
|
|
"github.com/grafana/grafana/pkg/infra/kvstore"
|
2020-11-12 07:11:30 -06:00
|
|
|
"github.com/grafana/grafana/pkg/infra/log"
|
2023-01-06 20:21:43 -06:00
|
|
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
2022-03-08 08:22:16 -06:00
|
|
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
2022-09-19 02:54:37 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/annotations"
|
2022-02-16 07:15:44 -06:00
|
|
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
2021-03-24 09:20:44 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/datasourceproxy"
|
2020-11-12 07:11:30 -06:00
|
|
|
"github.com/grafana/grafana/pkg/services/datasources"
|
2022-10-06 01:22:58 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
2022-10-10 14:47:53 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/folder"
|
2021-09-09 11:25:22 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/ngalert/api"
|
|
|
|
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
2022-05-22 09:33:49 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/ngalert/image"
|
2021-09-09 11:25:22 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
2022-08-01 18:28:38 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
2021-03-24 09:20:44 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
|
2022-04-05 16:48:51 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
|
2023-10-20 07:08:13 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/ngalert/remote"
|
2021-03-24 09:20:44 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/ngalert/schedule"
|
2022-07-12 14:13:04 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/ngalert/sender"
|
2021-09-09 11:25:22 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
2022-10-05 15:32:20 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/ngalert/state/historian"
|
2021-09-09 11:25:22 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
2022-01-26 09:42:40 -06:00
|
|
|
"github.com/grafana/grafana/pkg/services/notifications"
|
2023-09-11 06:59:24 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
2021-09-09 11:25:22 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/quota"
|
2022-05-22 09:33:49 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/rendering"
|
2021-11-04 11:47:21 -05:00
|
|
|
"github.com/grafana/grafana/pkg/services/secrets"
|
2020-11-12 07:11:30 -06:00
|
|
|
"github.com/grafana/grafana/pkg/setting"
|
|
|
|
)
|
|
|
|
|
2022-10-06 01:22:58 -05:00
|
|
|
func ProvideService(
|
|
|
|
cfg *setting.Cfg,
|
|
|
|
featureToggles featuremgmt.FeatureToggles,
|
|
|
|
dataSourceCache datasources.CacheService,
|
|
|
|
dataSourceService datasources.DataSourceService,
|
|
|
|
routeRegister routing.RouteRegister,
|
2022-10-19 08:02:15 -05:00
|
|
|
sqlStore db.DB,
|
2022-10-06 01:22:58 -05:00
|
|
|
kvStore kvstore.KVStore,
|
|
|
|
expressionService *expr.Service,
|
|
|
|
dataProxy *datasourceproxy.DataSourceProxyService,
|
|
|
|
quotaService quota.Service,
|
|
|
|
secretsService secrets.Service,
|
|
|
|
notificationService notifications.Service,
|
|
|
|
m *metrics.NGAlert,
|
2022-10-10 14:47:53 -05:00
|
|
|
folderService folder.Service,
|
2022-10-06 01:22:58 -05:00
|
|
|
ac accesscontrol.AccessControl,
|
|
|
|
dashboardService dashboards.DashboardService,
|
|
|
|
renderService rendering.Service,
|
|
|
|
bus bus.Bus,
|
|
|
|
accesscontrolService accesscontrol.Service,
|
|
|
|
annotationsRepo annotations.Repository,
|
2023-09-11 06:59:24 -05:00
|
|
|
pluginsStore pluginstore.Store,
|
2023-01-06 20:21:43 -06:00
|
|
|
tracer tracing.Tracer,
|
2023-06-02 09:38:02 -05:00
|
|
|
ruleStore *store.DBstore,
|
2022-10-06 01:22:58 -05:00
|
|
|
) (*AlertNG, error) {
|
2021-08-25 08:11:22 -05:00
|
|
|
ng := &AlertNG{
|
2022-08-26 02:59:34 -05:00
|
|
|
Cfg: cfg,
|
2022-10-06 01:22:58 -05:00
|
|
|
FeatureToggles: featureToggles,
|
2022-08-26 02:59:34 -05:00
|
|
|
DataSourceCache: dataSourceCache,
|
|
|
|
DataSourceService: dataSourceService,
|
|
|
|
RouteRegister: routeRegister,
|
|
|
|
SQLStore: sqlStore,
|
|
|
|
KVStore: kvStore,
|
|
|
|
ExpressionService: expressionService,
|
|
|
|
DataProxy: dataProxy,
|
|
|
|
QuotaService: quotaService,
|
|
|
|
SecretsService: secretsService,
|
|
|
|
Metrics: m,
|
|
|
|
Log: log.New("ngalert"),
|
|
|
|
NotificationService: notificationService,
|
|
|
|
folderService: folderService,
|
|
|
|
accesscontrol: ac,
|
|
|
|
dashboardService: dashboardService,
|
|
|
|
renderService: renderService,
|
|
|
|
bus: bus,
|
|
|
|
accesscontrolService: accesscontrolService,
|
2022-09-19 02:54:37 -05:00
|
|
|
annotationsRepo: annotationsRepo,
|
2022-12-08 03:44:02 -06:00
|
|
|
pluginsStore: pluginsStore,
|
2023-01-06 20:21:43 -06:00
|
|
|
tracer: tracer,
|
2023-06-02 09:38:02 -05:00
|
|
|
store: ruleStore,
|
2021-08-25 08:11:22 -05:00
|
|
|
}
|
|
|
|
|
2024-03-14 09:36:35 -05:00
|
|
|
if ng.IsDisabled() {
|
2021-08-25 08:11:22 -05:00
|
|
|
return ng, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := ng.init(); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return ng, nil
|
|
|
|
}
|
|
|
|
|
2020-11-12 07:11:30 -06:00
|
|
|
// AlertNG is the service for evaluating the condition of an alert definition.
|
|
|
|
type AlertNG struct {
|
2022-01-26 09:42:40 -06:00
|
|
|
Cfg *setting.Cfg
|
2022-10-06 01:22:58 -05:00
|
|
|
FeatureToggles featuremgmt.FeatureToggles
|
2022-01-26 09:42:40 -06:00
|
|
|
DataSourceCache datasources.CacheService
|
2022-07-20 09:50:49 -05:00
|
|
|
DataSourceService datasources.DataSourceService
|
2022-01-26 09:42:40 -06:00
|
|
|
RouteRegister routing.RouteRegister
|
2022-10-14 14:33:06 -05:00
|
|
|
SQLStore db.DB
|
2022-01-26 09:42:40 -06:00
|
|
|
KVStore kvstore.KVStore
|
|
|
|
ExpressionService *expr.Service
|
|
|
|
DataProxy *datasourceproxy.DataSourceProxyService
|
2022-07-15 11:06:44 -05:00
|
|
|
QuotaService quota.Service
|
2022-01-26 09:42:40 -06:00
|
|
|
SecretsService secrets.Service
|
|
|
|
Metrics *metrics.NGAlert
|
|
|
|
NotificationService notifications.Service
|
|
|
|
Log log.Logger
|
2022-05-22 09:33:49 -05:00
|
|
|
renderService rendering.Service
|
2023-06-27 06:11:22 -05:00
|
|
|
ImageService image.ImageService
|
2022-01-26 09:42:40 -06:00
|
|
|
schedule schedule.ScheduleService
|
|
|
|
stateManager *state.Manager
|
2022-10-10 14:47:53 -05:00
|
|
|
folderService folder.Service
|
2022-05-17 13:52:22 -05:00
|
|
|
dashboardService dashboards.DashboardService
|
2023-04-24 11:18:44 -05:00
|
|
|
api *api.API
|
2021-08-06 07:06:56 -05:00
|
|
|
|
|
|
|
// Alerting notification services
|
2021-08-24 05:28:09 -05:00
|
|
|
MultiOrgAlertmanager *notifier.MultiOrgAlertmanager
|
2022-07-12 14:13:04 -05:00
|
|
|
AlertsRouter *sender.AlertsRouter
|
2022-03-08 08:22:16 -06:00
|
|
|
accesscontrol accesscontrol.AccessControl
|
2022-08-26 02:59:34 -05:00
|
|
|
accesscontrolService accesscontrol.Service
|
2022-09-19 02:54:37 -05:00
|
|
|
annotationsRepo annotations.Repository
|
2022-11-04 13:23:08 -05:00
|
|
|
store *store.DBstore
|
2022-06-17 12:10:49 -05:00
|
|
|
|
2022-12-08 03:44:02 -06:00
|
|
|
bus bus.Bus
|
2023-09-11 06:59:24 -05:00
|
|
|
pluginsStore pluginstore.Store
|
2023-01-06 20:21:43 -06:00
|
|
|
tracer tracing.Tracer
|
2020-11-12 07:11:30 -06:00
|
|
|
}
|
|
|
|
|
2021-08-25 08:11:22 -05:00
|
|
|
func (ng *AlertNG) init() error {
|
2023-01-26 03:31:20 -06:00
|
|
|
// AlertNG should be initialized before the cancellation deadline of initCtx
|
|
|
|
initCtx, cancelFunc := context.WithTimeout(context.Background(), 30*time.Second)
|
|
|
|
defer cancelFunc()
|
|
|
|
|
2023-06-02 09:38:02 -05:00
|
|
|
ng.store.Logger = ng.Log
|
2021-05-13 13:01:38 -05:00
|
|
|
|
Alerting: Support UTF-8 (#81512)
This pull request updates our fork of Alertmanager to commit 65bdab0, which is based on commit 5658f8c in Prometheus Alertmanager.
It applies the changes from grafana/alerting#155 which removes the overrides for validation of alerts, labels and silences that we had put in place to allow alerts and silences to work for non-Prometheus datasources. However, as this is now supported in Alertmanager with the UTF-8 work, we can use the new upstream functions and remove these overrides.
The compat package is a package in Alertmanager that takes care of backwards compatibility when parsing matchers, validating alerts, labels and silences. It has three modes: classic mode, UTF-8 strict mode, fallback mode. These modes are controlled via compat.InitFromFlags. Grafana initializes the compat package without any feature flags, which is the equivalent of fallback mode. Classic and UTF-8 strict mode are used in Mimir.
While Grafana Managed Alerts have no need for fallback mode, Grafana can still be used as an interface to manage the configurations of Mimir Alertmanagers and view configurations of Prometheus Alertmanager, and those installations might not have migrated or being running on older versions. Such installations behave as if in classic mode, and Grafana must be able to parse their configurations to interact with them for some period of time. As such, Grafana uses fallback mode until we are ready to drop support for outdated installations of Mimir and the Prometheus Alertmanager.
2024-02-06 02:33:47 -06:00
|
|
|
// This initializes the compat package in fallback mode with logging. It parses first
|
|
|
|
// using the UTF-8 parser and then fallsback to the classic parser on error.
|
|
|
|
// UTF-8 is permitted in label names. This should be removed when the compat package
|
|
|
|
// is removed from Alertmanager.
|
2024-02-08 05:25:27 -06:00
|
|
|
compat.InitFromFlags(ng.Log, featurecontrol.NoopFlags{})
|
Alerting: Support UTF-8 (#81512)
This pull request updates our fork of Alertmanager to commit 65bdab0, which is based on commit 5658f8c in Prometheus Alertmanager.
It applies the changes from grafana/alerting#155 which removes the overrides for validation of alerts, labels and silences that we had put in place to allow alerts and silences to work for non-Prometheus datasources. However, as this is now supported in Alertmanager with the UTF-8 work, we can use the new upstream functions and remove these overrides.
The compat package is a package in Alertmanager that takes care of backwards compatibility when parsing matchers, validating alerts, labels and silences. It has three modes: classic mode, UTF-8 strict mode, fallback mode. These modes are controlled via compat.InitFromFlags. Grafana initializes the compat package without any feature flags, which is the equivalent of fallback mode. Classic and UTF-8 strict mode are used in Mimir.
While Grafana Managed Alerts have no need for fallback mode, Grafana can still be used as an interface to manage the configurations of Mimir Alertmanagers and view configurations of Prometheus Alertmanager, and those installations might not have migrated or being running on older versions. Such installations behave as if in classic mode, and Grafana must be able to parse their configurations to interact with them for some period of time. As such, Grafana uses fallback mode until we are ready to drop support for outdated installations of Mimir and the Prometheus Alertmanager.
2024-02-06 02:33:47 -06:00
|
|
|
|
2023-12-21 08:26:31 -06:00
|
|
|
// If enabled, configure the remote Alertmanager.
|
|
|
|
// - If several toggles are enabled, the order of precedence is RemoteOnly, RemotePrimary, RemoteSecondary
|
|
|
|
// - If no toggles are enabled, we default to using only the internal Alertmanager
|
|
|
|
// We currently support only remote secondary mode, so in case other toggles are enabled we fall back to remote secondary.
|
2023-10-20 07:08:13 -05:00
|
|
|
var overrides []notifier.Option
|
2023-12-21 08:26:31 -06:00
|
|
|
moaLogger := log.New("ngalert.multiorg.alertmanager")
|
|
|
|
remoteOnly := ng.FeatureToggles.IsEnabled(initCtx, featuremgmt.FlagAlertmanagerRemoteOnly)
|
|
|
|
remotePrimary := ng.FeatureToggles.IsEnabled(initCtx, featuremgmt.FlagAlertmanagerRemotePrimary)
|
|
|
|
remoteSecondary := ng.FeatureToggles.IsEnabled(initCtx, featuremgmt.FlagAlertmanagerRemoteSecondary)
|
2023-10-20 07:08:13 -05:00
|
|
|
if ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Enable {
|
2023-12-21 08:26:31 -06:00
|
|
|
switch {
|
|
|
|
case remoteOnly, remotePrimary:
|
|
|
|
ng.Log.Warn("Only remote secondary mode is supported at the moment, falling back to remote secondary")
|
|
|
|
fallthrough
|
|
|
|
|
|
|
|
case remoteSecondary:
|
|
|
|
ng.Log.Debug("Starting Grafana with remote secondary mode enabled")
|
2024-01-10 04:18:24 -06:00
|
|
|
m := ng.Metrics.GetRemoteAlertmanagerMetrics()
|
|
|
|
m.Info.WithLabelValues(metrics.ModeRemoteSecondary).Set(1)
|
|
|
|
|
2023-12-21 08:26:31 -06:00
|
|
|
// This function will be used by the MOA to create new Alertmanagers.
|
|
|
|
override := notifier.WithAlertmanagerOverride(func(factoryFn notifier.OrgAlertmanagerFactory) notifier.OrgAlertmanagerFactory {
|
|
|
|
return func(ctx context.Context, orgID int64) (notifier.Alertmanager, error) {
|
|
|
|
// Create internal Alertmanager.
|
|
|
|
internalAM, err := factoryFn(ctx, orgID)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Create remote Alertmanager.
|
2024-03-19 06:12:03 -05:00
|
|
|
remoteAM, err := createRemoteAlertmanager(orgID, ng.Cfg.UnifiedAlerting.RemoteAlertmanager, ng.KVStore, ng.SecretsService.Decrypt, m)
|
2023-12-21 08:26:31 -06:00
|
|
|
if err != nil {
|
|
|
|
moaLogger.Error("Failed to create remote Alertmanager, falling back to using only the internal one", "err", err)
|
|
|
|
return internalAM, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Use both Alertmanager implementations in the forked Alertmanager.
|
|
|
|
cfg := remote.RemoteSecondaryConfig{
|
|
|
|
Logger: log.New("ngalert.forked-alertmanager.remote-secondary"),
|
|
|
|
OrgID: orgID,
|
|
|
|
Store: ng.store,
|
|
|
|
SyncInterval: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.SyncInterval,
|
|
|
|
}
|
|
|
|
return remote.NewRemoteSecondaryForkedAlertmanager(cfg, internalAM, remoteAM)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
overrides = append(overrides, override)
|
|
|
|
|
|
|
|
default:
|
|
|
|
ng.Log.Error("A mode should be selected when enabling the remote Alertmanager, falling back to using only the internal Alertmanager")
|
|
|
|
}
|
2023-10-20 07:08:13 -05:00
|
|
|
}
|
2023-12-21 08:26:31 -06:00
|
|
|
|
|
|
|
decryptFn := ng.SecretsService.GetDecryptedValue
|
|
|
|
multiOrgMetrics := ng.Metrics.GetMultiOrgAlertmanagerMetrics()
|
2024-02-15 08:45:10 -06:00
|
|
|
moa, err := notifier.NewMultiOrgAlertmanager(ng.Cfg, ng.store, ng.store, ng.KVStore, ng.store, decryptFn, multiOrgMetrics, ng.NotificationService, moaLogger, ng.SecretsService, ng.FeatureToggles, overrides...)
|
2021-09-16 09:33:51 -05:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2023-12-21 08:26:31 -06:00
|
|
|
ng.MultiOrgAlertmanager = moa
|
2021-08-24 05:28:09 -05:00
|
|
|
|
2023-06-02 09:38:02 -05:00
|
|
|
imageService, err := image.NewScreenshotImageServiceFromCfg(ng.Cfg, ng.store, ng.dashboardService, ng.renderService, ng.Metrics.Registerer)
|
2022-05-22 09:33:49 -05:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2023-06-27 06:11:22 -05:00
|
|
|
ng.ImageService = imageService
|
2022-05-22 09:33:49 -05:00
|
|
|
|
2021-08-24 05:28:09 -05:00
|
|
|
// Let's make sure we're able to complete an initial sync of Alertmanagers before we start the alerting components.
|
2023-01-26 03:31:20 -06:00
|
|
|
if err := ng.MultiOrgAlertmanager.LoadAndSyncAlertmanagersForOrgs(initCtx); err != nil {
|
2022-07-12 14:13:04 -05:00
|
|
|
return fmt.Errorf("failed to initialize alerting because multiorg alertmanager manager failed to warm up: %w", err)
|
2021-01-22 11:27:33 -06:00
|
|
|
}
|
2021-09-30 11:51:20 -05:00
|
|
|
|
|
|
|
appUrl, err := url.Parse(ng.Cfg.AppURL)
|
|
|
|
if err != nil {
|
2022-10-19 16:36:54 -05:00
|
|
|
ng.Log.Error("Failed to parse application URL. Continue without it.", "error", err)
|
2021-09-30 11:51:20 -05:00
|
|
|
appUrl = nil
|
|
|
|
}
|
2022-05-22 09:33:49 -05:00
|
|
|
|
2022-07-12 14:13:04 -05:00
|
|
|
clk := clock.New()
|
|
|
|
|
2023-06-02 09:38:02 -05:00
|
|
|
alertsRouter := sender.NewAlertsRouter(ng.MultiOrgAlertmanager, ng.store, clk, appUrl, ng.Cfg.UnifiedAlerting.DisabledOrgs,
|
2022-07-20 09:50:49 -05:00
|
|
|
ng.Cfg.UnifiedAlerting.AdminConfigPollInterval, ng.DataSourceService, ng.SecretsService)
|
2022-07-12 14:13:04 -05:00
|
|
|
|
|
|
|
// Make sure we sync at least once as Grafana starts to get the router up and running before we start sending any alerts.
|
|
|
|
if err := alertsRouter.SyncAndApplyConfigFromDatabase(); err != nil {
|
|
|
|
return fmt.Errorf("failed to initialize alerting because alert notifications router failed to warm up: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
ng.AlertsRouter = alertsRouter
|
|
|
|
|
2022-12-08 03:44:02 -06:00
|
|
|
evalFactory := eval.NewEvaluatorFactory(ng.Cfg.UnifiedAlerting, ng.DataSourceCache, ng.ExpressionService, ng.pluginsStore)
|
2022-07-12 14:13:04 -05:00
|
|
|
schedCfg := schedule.SchedulerCfg{
|
2022-12-02 16:02:07 -06:00
|
|
|
MaxAttempts: ng.Cfg.UnifiedAlerting.MaxAttempts,
|
|
|
|
C: clk,
|
|
|
|
BaseInterval: ng.Cfg.UnifiedAlerting.BaseInterval,
|
|
|
|
MinRuleInterval: ng.Cfg.UnifiedAlerting.MinInterval,
|
|
|
|
DisableGrafanaFolder: ng.Cfg.UnifiedAlerting.ReservedLabels.IsReservedLabelDisabled(models.FolderTitleLabel),
|
2024-02-09 15:53:58 -06:00
|
|
|
JitterEvaluations: schedule.JitterStrategyFrom(ng.Cfg.UnifiedAlerting, ng.FeatureToggles),
|
2022-12-02 16:02:07 -06:00
|
|
|
AppURL: appUrl,
|
|
|
|
EvaluatorFactory: evalFactory,
|
2023-06-02 09:38:02 -05:00
|
|
|
RuleStore: ng.store,
|
2022-12-02 16:02:07 -06:00
|
|
|
Metrics: ng.Metrics.GetSchedulerMetrics(),
|
|
|
|
AlertSender: alertsRouter,
|
2023-01-06 20:21:43 -06:00
|
|
|
Tracer: ng.tracer,
|
2023-09-20 08:07:02 -05:00
|
|
|
Log: log.New("ngalert.scheduler"),
|
2022-07-12 14:13:04 -05:00
|
|
|
}
|
|
|
|
|
2023-03-30 13:53:21 -05:00
|
|
|
// There are a set of feature toggles available that act as short-circuits for common configurations.
|
|
|
|
// If any are set, override the config accordingly.
|
2023-12-12 16:43:09 -06:00
|
|
|
ApplyStateHistoryFeatureToggles(&ng.Cfg.UnifiedAlerting.StateHistory, ng.FeatureToggles, ng.Log)
|
2023-03-13 15:54:46 -05:00
|
|
|
history, err := configureHistorianBackend(initCtx, ng.Cfg.UnifiedAlerting.StateHistory, ng.annotationsRepo, ng.dashboardService, ng.store, ng.Metrics.GetHistorianMetrics(), ng.Log)
|
2023-01-06 12:06:01 -06:00
|
|
|
if err != nil {
|
|
|
|
return err
|
2023-01-05 12:21:07 -06:00
|
|
|
}
|
2023-01-10 15:26:15 -06:00
|
|
|
cfg := state.ManagerCfg{
|
2023-08-15 09:27:15 -05:00
|
|
|
Metrics: ng.Metrics.GetStateMetrics(),
|
|
|
|
ExternalURL: appUrl,
|
|
|
|
InstanceStore: ng.store,
|
|
|
|
Images: ng.ImageService,
|
|
|
|
Clock: clk,
|
|
|
|
Historian: history,
|
2023-11-14 14:50:27 -06:00
|
|
|
DoNotSaveNormalState: ng.FeatureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingNoNormalState),
|
|
|
|
ApplyNoDataAndErrorToAllStates: ng.FeatureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingNoDataErrorExecution),
|
2024-01-17 06:33:13 -06:00
|
|
|
MaxStateSaveConcurrency: ng.Cfg.UnifiedAlerting.MaxStateSaveConcurrency,
|
2024-02-13 08:29:03 -06:00
|
|
|
RulesPerRuleGroupLimit: ng.Cfg.UnifiedAlerting.RulesPerRuleGroupLimit,
|
2023-08-16 02:04:18 -05:00
|
|
|
Tracer: ng.tracer,
|
2023-09-20 08:07:02 -05:00
|
|
|
Log: log.New("ngalert.state.manager"),
|
2023-01-10 15:26:15 -06:00
|
|
|
}
|
2024-01-23 10:03:30 -06:00
|
|
|
logger := log.New("ngalert.state.manager.persist")
|
|
|
|
statePersister := state.NewSyncStatePersisiter(logger, cfg)
|
|
|
|
if ng.FeatureToggles.IsEnabledGlobally(featuremgmt.FlagAlertingSaveStatePeriodic) {
|
|
|
|
ticker := clock.New().Ticker(ng.Cfg.UnifiedAlerting.StatePeriodicSaveInterval)
|
|
|
|
statePersister = state.NewAsyncStatePersister(logger, ticker, cfg)
|
|
|
|
}
|
2024-01-17 06:33:13 -06:00
|
|
|
stateManager := state.NewManager(cfg, statePersister)
|
2022-12-02 16:02:07 -06:00
|
|
|
scheduler := schedule.NewScheduler(schedCfg, stateManager)
|
2022-08-01 18:28:38 -05:00
|
|
|
|
|
|
|
// if it is required to include folder title to the alerts, we need to subscribe to changes of alert title
|
|
|
|
if !ng.Cfg.UnifiedAlerting.ReservedLabels.IsReservedLabelDisabled(models.FolderTitleLabel) {
|
2023-06-02 09:38:02 -05:00
|
|
|
subscribeToFolderChanges(ng.Log, ng.bus, ng.store)
|
2022-08-01 18:28:38 -05:00
|
|
|
}
|
2021-08-13 07:14:36 -05:00
|
|
|
|
2021-08-25 08:11:22 -05:00
|
|
|
ng.stateManager = stateManager
|
2021-09-30 11:51:20 -05:00
|
|
|
ng.schedule = scheduler
|
2021-03-03 09:52:19 -06:00
|
|
|
|
2024-02-01 13:42:59 -06:00
|
|
|
receiverService := notifier.NewReceiverService(ng.accesscontrol, ng.store, ng.store, ng.SecretsService, ng.store, ng.Log)
|
|
|
|
|
2022-04-05 16:48:51 -05:00
|
|
|
// Provisioning
|
2023-06-02 09:38:02 -05:00
|
|
|
policyService := provisioning.NewNotificationPolicyService(ng.store, ng.store, ng.store, ng.Cfg.UnifiedAlerting, ng.Log)
|
2024-02-15 08:45:10 -06:00
|
|
|
contactPointService := provisioning.NewContactPointService(ng.store, ng.SecretsService, ng.store, ng.store, receiverService, ng.Log, ng.store)
|
2023-06-02 09:38:02 -05:00
|
|
|
templateService := provisioning.NewTemplateService(ng.store, ng.store, ng.store, ng.Log)
|
|
|
|
muteTimingService := provisioning.NewMuteTimingService(ng.store, ng.store, ng.store, ng.Log)
|
2024-03-14 08:58:25 -05:00
|
|
|
alertRuleService := provisioning.NewAlertRuleService(ng.store, ng.store, ng.folderService, ng.dashboardService, ng.QuotaService, ng.store,
|
2022-06-09 02:28:32 -05:00
|
|
|
int64(ng.Cfg.UnifiedAlerting.DefaultRuleEvaluationInterval.Seconds()),
|
2024-02-13 08:29:03 -06:00
|
|
|
int64(ng.Cfg.UnifiedAlerting.BaseInterval.Seconds()),
|
2024-02-15 08:45:10 -06:00
|
|
|
ng.Cfg.UnifiedAlerting.RulesPerRuleGroupLimit, ng.Log, notifier.NewNotificationSettingsValidationService(ng.store))
|
2022-04-05 16:48:51 -05:00
|
|
|
|
2023-04-24 11:18:44 -05:00
|
|
|
ng.api = &api.API{
|
2021-08-24 05:28:09 -05:00
|
|
|
Cfg: ng.Cfg,
|
2021-08-25 08:11:22 -05:00
|
|
|
DatasourceCache: ng.DataSourceCache,
|
2022-07-20 09:50:49 -05:00
|
|
|
DatasourceService: ng.DataSourceService,
|
2021-08-24 05:28:09 -05:00
|
|
|
RouteRegister: ng.RouteRegister,
|
|
|
|
DataProxy: ng.DataProxy,
|
|
|
|
QuotaService: ng.QuotaService,
|
2023-06-02 09:38:02 -05:00
|
|
|
TransactionManager: ng.store,
|
|
|
|
RuleStore: ng.store,
|
|
|
|
AlertingStore: ng.store,
|
|
|
|
AdminConfigStore: ng.store,
|
|
|
|
ProvenanceStore: ng.store,
|
2021-08-24 05:28:09 -05:00
|
|
|
MultiOrgAlertmanager: ng.MultiOrgAlertmanager,
|
|
|
|
StateManager: ng.stateManager,
|
2022-03-08 08:22:16 -06:00
|
|
|
AccessControl: ng.accesscontrol,
|
2022-04-05 16:48:51 -05:00
|
|
|
Policies: policyService,
|
2024-02-06 08:49:47 -06:00
|
|
|
ReceiverService: receiverService,
|
2022-04-13 15:15:55 -05:00
|
|
|
ContactPointService: contactPointService,
|
2022-04-28 13:51:57 -05:00
|
|
|
Templates: templateService,
|
2022-05-17 13:42:48 -05:00
|
|
|
MuteTimings: muteTimingService,
|
2022-06-02 07:48:53 -05:00
|
|
|
AlertRules: alertRuleService,
|
2022-07-12 14:13:04 -05:00
|
|
|
AlertsRouter: alertsRouter,
|
2022-11-02 09:13:39 -05:00
|
|
|
EvaluatorFactory: evalFactory,
|
2022-12-14 08:44:14 -06:00
|
|
|
FeatureManager: ng.FeatureToggles,
|
|
|
|
AppUrl: appUrl,
|
2023-02-02 11:34:00 -06:00
|
|
|
Historian: history,
|
2023-04-24 11:18:44 -05:00
|
|
|
Hooks: api.NewHooks(ng.Log),
|
2023-08-16 02:04:18 -05:00
|
|
|
Tracer: ng.tracer,
|
2021-03-24 09:20:44 -05:00
|
|
|
}
|
2023-04-24 11:18:44 -05:00
|
|
|
ng.api.RegisterAPIEndpoints(ng.Metrics.GetAPIMetrics())
|
2021-03-03 09:52:19 -06:00
|
|
|
|
2023-12-01 10:47:19 -06:00
|
|
|
if err := RegisterQuotas(ng.Cfg, ng.QuotaService, ng.store); err != nil {
|
2022-11-14 13:08:10 -06:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2022-10-26 18:16:02 -05:00
|
|
|
log.RegisterContextualLogProvider(func(ctx context.Context) ([]interface{}, bool) {
|
|
|
|
key, ok := models.RuleKeyFromContext(ctx)
|
|
|
|
if !ok {
|
|
|
|
return nil, false
|
|
|
|
}
|
|
|
|
return key.LogContext(), true
|
|
|
|
})
|
|
|
|
|
2022-08-26 02:59:34 -05:00
|
|
|
return DeclareFixedRoles(ng.accesscontrolService)
|
2020-11-12 07:11:30 -06:00
|
|
|
}
|
|
|
|
|
2023-03-14 17:02:51 -05:00
|
|
|
func subscribeToFolderChanges(logger log.Logger, bus bus.Bus, dbStore api.RuleStore) {
|
2022-08-01 18:28:38 -05:00
|
|
|
// if folder title is changed, we update all alert rules in that folder to make sure that all peers (in HA mode) will update folder title and
|
|
|
|
// clean up the current state
|
2023-11-21 15:06:20 -06:00
|
|
|
bus.AddEventListener(func(ctx context.Context, evt *events.FolderTitleUpdated) error {
|
|
|
|
logger.Info("Got folder title updated event. updating rules in the folder", "folderUID", evt.UID)
|
|
|
|
_, err := dbStore.IncreaseVersionForAllRulesInNamespace(ctx, evt.OrgID, evt.UID)
|
|
|
|
if err != nil {
|
|
|
|
logger.Error("Failed to update alert rules in the folder after its title was changed", "error", err, "folderUID", evt.UID, "folder", evt.Title)
|
|
|
|
return err
|
|
|
|
}
|
2022-08-01 18:28:38 -05:00
|
|
|
return nil
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2021-07-27 05:52:59 -05:00
|
|
|
// Run starts the scheduler and Alertmanager.
|
2020-12-17 08:00:09 -06:00
|
|
|
func (ng *AlertNG) Run(ctx context.Context) error {
|
2023-12-01 03:17:32 -06:00
|
|
|
ng.Log.Debug("Starting", "execute_alerts", ng.Cfg.UnifiedAlerting.ExecuteAlerts)
|
2021-05-13 13:01:38 -05:00
|
|
|
|
|
|
|
children, subCtx := errgroup.WithContext(ctx)
|
2021-09-28 05:00:16 -05:00
|
|
|
|
2022-07-12 14:13:04 -05:00
|
|
|
children.Go(func() error {
|
|
|
|
return ng.MultiOrgAlertmanager.Run(subCtx)
|
|
|
|
})
|
|
|
|
children.Go(func() error {
|
|
|
|
return ng.AlertsRouter.Run(subCtx)
|
|
|
|
})
|
|
|
|
|
2024-03-14 09:36:35 -05:00
|
|
|
if ng.Cfg.UnifiedAlerting.ExecuteAlerts {
|
2023-12-01 03:17:32 -06:00
|
|
|
// Only Warm() the state manager if we are actually executing alerts.
|
|
|
|
// Doing so when we are not executing alerts is wasteful and could lead
|
|
|
|
// to misleading rule status queries, as the status returned will be
|
|
|
|
// always based on the state loaded from the database at startup, and
|
|
|
|
// not the most recent evaluation state.
|
|
|
|
//
|
|
|
|
// Also note that this runs synchronously to ensure state is loaded
|
|
|
|
// before rule evaluation begins, hence we use ctx and not subCtx.
|
|
|
|
//
|
|
|
|
ng.stateManager.Warm(ctx, ng.store)
|
|
|
|
|
2021-09-28 05:00:16 -05:00
|
|
|
children.Go(func() error {
|
|
|
|
return ng.schedule.Run(subCtx)
|
|
|
|
})
|
2024-01-23 10:03:30 -06:00
|
|
|
children.Go(func() error {
|
|
|
|
return ng.stateManager.Run(subCtx)
|
|
|
|
})
|
2021-09-28 05:00:16 -05:00
|
|
|
}
|
2021-05-13 13:01:38 -05:00
|
|
|
return children.Wait()
|
2020-12-17 08:00:09 -06:00
|
|
|
}
|
|
|
|
|
2023-03-17 06:19:18 -05:00
|
|
|
// IsDisabled returns true if the alerting service is disabled for this instance.
|
2020-11-12 07:11:30 -06:00
|
|
|
func (ng *AlertNG) IsDisabled() bool {
|
2024-03-14 09:36:35 -05:00
|
|
|
if ng.Cfg == nil {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
return !ng.Cfg.UnifiedAlerting.IsEnabled()
|
2020-11-12 07:11:30 -06:00
|
|
|
}
|
2022-11-14 13:08:10 -06:00
|
|
|
|
2023-04-24 11:18:44 -05:00
|
|
|
// GetHooks returns a facility for replacing handlers for paths. The handler hook for a path
|
|
|
|
// is invoked after all other middleware is invoked (authentication, instrumentation).
|
|
|
|
func (ng *AlertNG) GetHooks() *api.Hooks {
|
|
|
|
return ng.api.Hooks
|
|
|
|
}
|
|
|
|
|
2023-02-02 11:34:00 -06:00
|
|
|
type Historian interface {
|
|
|
|
api.Historian
|
|
|
|
state.Historian
|
|
|
|
}
|
|
|
|
|
2023-03-13 15:54:46 -05:00
|
|
|
func configureHistorianBackend(ctx context.Context, cfg setting.UnifiedAlertingStateHistorySettings, ar annotations.Repository, ds dashboards.DashboardService, rs historian.RuleStore, met *metrics.Historian, l log.Logger) (Historian, error) {
|
2023-01-06 12:06:01 -06:00
|
|
|
if !cfg.Enabled {
|
2023-03-06 10:40:37 -06:00
|
|
|
met.Info.WithLabelValues("noop").Set(0)
|
2023-01-06 12:06:01 -06:00
|
|
|
return historian.NewNopHistorian(), nil
|
|
|
|
}
|
|
|
|
|
2023-03-17 12:41:18 -05:00
|
|
|
backend, err := historian.ParseBackendType(cfg.Backend)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
met.Info.WithLabelValues(backend.String()).Set(1)
|
|
|
|
if backend == historian.BackendTypeMultiple {
|
|
|
|
primaryCfg := cfg
|
|
|
|
primaryCfg.Backend = cfg.MultiPrimary
|
|
|
|
primary, err := configureHistorianBackend(ctx, primaryCfg, ar, ds, rs, met, l)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("multi-backend target \"%s\" was misconfigured: %w", cfg.MultiPrimary, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
var secondaries []historian.Backend
|
|
|
|
for _, b := range cfg.MultiSecondaries {
|
|
|
|
secCfg := cfg
|
|
|
|
secCfg.Backend = b
|
|
|
|
sec, err := configureHistorianBackend(ctx, secCfg, ar, ds, rs, met, l)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("multi-backend target \"%s\" was miconfigured: %w", b, err)
|
|
|
|
}
|
|
|
|
secondaries = append(secondaries, sec)
|
|
|
|
}
|
|
|
|
|
|
|
|
l.Info("State history is operating in multi-backend mode", "primary", cfg.MultiPrimary, "secondaries", cfg.MultiSecondaries)
|
|
|
|
return historian.NewMultipleBackend(primary, secondaries...), nil
|
|
|
|
}
|
|
|
|
if backend == historian.BackendTypeAnnotations {
|
2023-07-18 08:18:55 -05:00
|
|
|
store := historian.NewAnnotationStore(ar, ds, met)
|
|
|
|
return historian.NewAnnotationBackend(store, rs, met), nil
|
2023-01-06 12:06:01 -06:00
|
|
|
}
|
2023-03-17 12:41:18 -05:00
|
|
|
if backend == historian.BackendTypeLoki {
|
2023-01-30 16:30:05 -06:00
|
|
|
lcfg, err := historian.NewLokiConfig(cfg)
|
2023-01-17 13:58:52 -06:00
|
|
|
if err != nil {
|
2023-01-30 16:30:05 -06:00
|
|
|
return nil, fmt.Errorf("invalid remote loki configuration: %w", err)
|
2023-01-17 13:58:52 -06:00
|
|
|
}
|
2023-02-23 17:52:02 -06:00
|
|
|
req := historian.NewRequester()
|
|
|
|
backend := historian.NewRemoteLokiBackend(lcfg, req, met)
|
2023-01-30 16:30:05 -06:00
|
|
|
|
2023-01-26 03:31:20 -06:00
|
|
|
testConnCtx, cancelFunc := context.WithTimeout(ctx, 10*time.Second)
|
|
|
|
defer cancelFunc()
|
|
|
|
if err := backend.TestConnection(testConnCtx); err != nil {
|
2023-03-13 15:54:46 -05:00
|
|
|
l.Error("Failed to communicate with configured remote Loki backend, state history may not be persisted", "error", err)
|
2023-01-17 13:58:52 -06:00
|
|
|
}
|
|
|
|
return backend, nil
|
2023-01-06 12:06:01 -06:00
|
|
|
}
|
|
|
|
|
2023-03-17 12:41:18 -05:00
|
|
|
return nil, fmt.Errorf("unrecognized state history backend: %s", backend)
|
2023-01-06 12:06:01 -06:00
|
|
|
}
|
2023-03-30 13:53:21 -05:00
|
|
|
|
2023-12-12 16:43:09 -06:00
|
|
|
// ApplyStateHistoryFeatureToggles edits state history configuration to comply with currently active feature toggles.
|
|
|
|
func ApplyStateHistoryFeatureToggles(cfg *setting.UnifiedAlertingStateHistorySettings, ft featuremgmt.FeatureToggles, logger log.Logger) {
|
2023-03-30 13:53:21 -05:00
|
|
|
backend, _ := historian.ParseBackendType(cfg.Backend)
|
|
|
|
// These feature toggles represent specific, common backend configurations.
|
|
|
|
// If all toggles are enabled, we listen to the state history config as written.
|
|
|
|
// If any of them are disabled, we ignore the configured backend and treat the toggles as an override.
|
|
|
|
// If multiple toggles are disabled, we go with the most "restrictive" one.
|
2023-11-14 14:50:27 -06:00
|
|
|
if !ft.IsEnabledGlobally(featuremgmt.FlagAlertStateHistoryLokiSecondary) {
|
2023-03-30 13:53:21 -05:00
|
|
|
// If we cannot even treat Loki as a secondary, we must use annotations only.
|
|
|
|
if backend == historian.BackendTypeMultiple || backend == historian.BackendTypeLoki {
|
|
|
|
logger.Info("Forcing Annotation backend due to state history feature toggles")
|
|
|
|
cfg.Backend = historian.BackendTypeAnnotations.String()
|
|
|
|
cfg.MultiPrimary = ""
|
|
|
|
cfg.MultiSecondaries = make([]string, 0)
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
2023-11-14 14:50:27 -06:00
|
|
|
if !ft.IsEnabledGlobally(featuremgmt.FlagAlertStateHistoryLokiPrimary) {
|
2023-03-30 13:53:21 -05:00
|
|
|
// If we're using multiple backends, Loki must be the secondary.
|
|
|
|
if backend == historian.BackendTypeMultiple {
|
|
|
|
logger.Info("Coercing Loki to a secondary backend due to state history feature toggles")
|
|
|
|
cfg.MultiPrimary = historian.BackendTypeAnnotations.String()
|
|
|
|
cfg.MultiSecondaries = []string{historian.BackendTypeLoki.String()}
|
|
|
|
}
|
|
|
|
// If we're using loki, we are only allowed to use it as a secondary. Dual write to it, plus annotations.
|
|
|
|
if backend == historian.BackendTypeLoki {
|
|
|
|
logger.Info("Coercing Loki to dual writes with a secondary backend due to state history feature toggles")
|
|
|
|
cfg.Backend = historian.BackendTypeMultiple.String()
|
|
|
|
cfg.MultiPrimary = historian.BackendTypeAnnotations.String()
|
|
|
|
cfg.MultiSecondaries = []string{historian.BackendTypeLoki.String()}
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
2023-11-14 14:50:27 -06:00
|
|
|
if !ft.IsEnabledGlobally(featuremgmt.FlagAlertStateHistoryLokiOnly) {
|
2023-03-30 13:53:21 -05:00
|
|
|
// If we're not allowed to use Loki only, make it the primary but keep the annotation writes.
|
|
|
|
if backend == historian.BackendTypeLoki {
|
|
|
|
logger.Info("Forcing dual writes to Loki and Annotations due to state history feature toggles")
|
|
|
|
cfg.Backend = historian.BackendTypeMultiple.String()
|
|
|
|
cfg.MultiPrimary = historian.BackendTypeLoki.String()
|
|
|
|
cfg.MultiSecondaries = []string{historian.BackendTypeAnnotations.String()}
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
2023-12-21 08:26:31 -06:00
|
|
|
|
2024-03-19 06:12:03 -05:00
|
|
|
func createRemoteAlertmanager(orgID int64, amCfg setting.RemoteAlertmanagerSettings, kvstore kvstore.KVStore, decryptFn remote.DecryptFn, m *metrics.RemoteAlertmanager) (*remote.Alertmanager, error) {
|
2023-12-21 08:26:31 -06:00
|
|
|
externalAMCfg := remote.AlertmanagerConfig{
|
|
|
|
OrgID: orgID,
|
|
|
|
URL: amCfg.URL,
|
|
|
|
TenantID: amCfg.TenantID,
|
|
|
|
BasicAuthPassword: amCfg.Password,
|
|
|
|
}
|
|
|
|
// We won't be handling files on disk, we can pass an empty string as workingDirPath.
|
|
|
|
stateStore := notifier.NewFileStore(orgID, kvstore, "")
|
2024-03-19 06:12:03 -05:00
|
|
|
return remote.NewAlertmanager(externalAMCfg, stateStore, decryptFn, m)
|
2023-12-21 08:26:31 -06:00
|
|
|
}
|