mirror of
https://github.com/grafana/grafana.git
synced 2026-08-18 17:15:08 -05:00
Alerting: Add a general screenshot service and alerting-specific image service. (#49293)
This commit adds a pkg/services/screenshot package for taking and uploading screenshots of Grafana dashboards. It supports taking screenshots of both dashboards and individual panels within a dashboard, using the rendering service. The screenshot package has the following services, most of which can be composed: BrowserScreenshotService (Takes screenshots with headless Chrome) CachableScreenshotService (Caches screenshots taken with another service such as BrowserScreenshotService) NoopScreenshotService (A no-op screenshot service for tests) SingleFlightScreenshotService (Prevents duplicate screenshots when taking screenshots of the same dashboard or panel in parallel) ScreenshotUnavailableService (A screenshot service that returns ErrScreenshotsUnavailable) UploadingScreenshotService (A screenshot service that uploads taken screenshots) The screenshot package does not support wire dependency injection yet. ngalert constructs its own version of the service. See https://github.com/grafana/grafana/issues/49296 This PR also adds an ImageScreenshotService to ngAlert. This is used to take screenshots with a screenshotservice and then store their location reference for use by alert instances and notifiers.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: github.com/grafana/grafana/pkg/services/ngalert/image (interfaces: ImageService)
|
||||
|
||||
// Package image is a generated GoMock package.
|
||||
package image
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
data "github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
models "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
store "github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
)
|
||||
|
||||
// MockImageService is a mock of ImageService interface.
|
||||
type MockImageService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockImageServiceMockRecorder
|
||||
}
|
||||
|
||||
// MockImageServiceMockRecorder is the mock recorder for MockImageService.
|
||||
type MockImageServiceMockRecorder struct {
|
||||
mock *MockImageService
|
||||
}
|
||||
|
||||
// NewMockImageService creates a new mock instance.
|
||||
func NewMockImageService(ctrl *gomock.Controller) *MockImageService {
|
||||
mock := &MockImageService{ctrl: ctrl}
|
||||
mock.recorder = &MockImageServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockImageService) EXPECT() *MockImageServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// NewImage mocks base method.
|
||||
func (m *MockImageService) NewImage(arg0 context.Context, arg1 *models.AlertRule, arg2 data.Labels) (*store.Image, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "NewImage", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(*store.Image)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// NewImage indicates an expected call of NewImage.
|
||||
func (mr *MockImageServiceMockRecorder) NewImage(arg0, arg1, arg2 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewImage", reflect.TypeOf((*MockImageService)(nil).NewImage), arg0, arg1, arg2)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/imguploader"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
"github.com/grafana/grafana/pkg/services/screenshot"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
//go:generate mockgen -destination=mock.go -package=image github.com/grafana/grafana/pkg/services/ngalert/image ImageService
|
||||
type ImageService interface {
|
||||
// NewImage returns a new image for the alert instance.
|
||||
NewImage(ctx context.Context, r *ngmodels.AlertRule) (*store.Image, error)
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrNoDashboard is returned when the alert rule does not have a dashboard.
|
||||
ErrNoDashboard = errors.New("no dashboard")
|
||||
|
||||
// ErrNoPanel is returned when the alert rule does not have a panel in a dashboard.
|
||||
ErrNoPanel = errors.New("no panel")
|
||||
)
|
||||
|
||||
const (
|
||||
screenshotTimeout = 10 * time.Second
|
||||
screenshotCacheTTL = 15 * time.Second
|
||||
)
|
||||
|
||||
// ScreenshotImageService takes screenshots of the panel for an alert rule and
|
||||
// saves the image in the store. The image contains a unique token that can be
|
||||
// passed as an annotation or label to the Alertmanager.
|
||||
type ScreenshotImageService struct {
|
||||
screenshots screenshot.ScreenshotService
|
||||
store store.ImageStore
|
||||
}
|
||||
|
||||
func NewScreenshotImageService(screenshots screenshot.ScreenshotService, store store.ImageStore) ImageService {
|
||||
return &ScreenshotImageService{
|
||||
screenshots: screenshots,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// NewScreenshotImageServiceFromCfg returns a new ScreenshotImageService
|
||||
// from the configuration.
|
||||
func NewScreenshotImageServiceFromCfg(cfg *setting.Cfg, metrics prometheus.Registerer,
|
||||
db *store.DBstore, ds dashboards.DashboardService, rs rendering.Service) (ImageService, error) {
|
||||
if !cfg.UnifiedAlerting.Screenshots.Enabled {
|
||||
return &ScreenshotImageService{
|
||||
screenshots: &screenshot.ScreenshotUnavailableService{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
s := screenshot.NewBrowserScreenshotService(ds, rs)
|
||||
if cfg.UnifiedAlerting.Screenshots.UploadExternalImageStorage {
|
||||
u, err := imguploader.NewImageUploader()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize uploading screenshot service: %w", err)
|
||||
}
|
||||
s = screenshot.NewUploadingScreenshotService(metrics, s, u)
|
||||
}
|
||||
s = screenshot.NewRateLimitScreenshotService(s, cfg.UnifiedAlerting.Screenshots.MaxConcurrentScreenshots)
|
||||
s = screenshot.NewSingleFlightScreenshotService(s)
|
||||
s = screenshot.NewCachableScreenshotService(metrics, screenshotCacheTTL, s)
|
||||
s = screenshot.NewObservableScreenshotService(metrics, s)
|
||||
|
||||
return &ScreenshotImageService{
|
||||
store: db,
|
||||
screenshots: s,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewImage returns a screenshot of the panel for the alert rule. It returns
|
||||
// ErrNoDashboard if the alert rule does not have a dashboard and ErrNoPanel
|
||||
// when the alert rule does not have a panel in a dashboard.
|
||||
func (s *ScreenshotImageService) NewImage(ctx context.Context, r *ngmodels.AlertRule) (*store.Image, error) {
|
||||
if r.DashboardUID == nil {
|
||||
return nil, ErrNoDashboard
|
||||
}
|
||||
if r.PanelID == nil || *r.PanelID == 0 {
|
||||
return nil, ErrNoPanel
|
||||
}
|
||||
|
||||
screenshot, err := s.screenshots.Take(ctx, screenshot.ScreenshotOptions{
|
||||
Timeout: screenshotTimeout,
|
||||
DashboardUID: *r.DashboardUID,
|
||||
PanelID: *r.PanelID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to take screenshot: %w", err)
|
||||
}
|
||||
|
||||
v := store.Image{
|
||||
Path: screenshot.Path,
|
||||
URL: screenshot.URL,
|
||||
}
|
||||
if err := s.store.SaveImage(ctx, &v); err != nil {
|
||||
return nil, fmt.Errorf("failed to save image: %w", err)
|
||||
}
|
||||
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
type NoopImageService struct{}
|
||||
|
||||
func (s *NoopImageService) NewImage(ctx context.Context, r *ngmodels.AlertRule) (*store.Image, error) {
|
||||
return &store.Image{}, nil
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/image"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
|
||||
@@ -25,6 +26,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/notifications"
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
"github.com/grafana/grafana/pkg/services/secrets"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
@@ -33,7 +35,7 @@ import (
|
||||
func ProvideService(cfg *setting.Cfg, dataSourceCache datasources.CacheService, routeRegister routing.RouteRegister,
|
||||
sqlStore *sqlstore.SQLStore, kvStore kvstore.KVStore, expressionService *expr.Service, dataProxy *datasourceproxy.DataSourceProxyService,
|
||||
quotaService *quota.QuotaService, secretsService secrets.Service, notificationService notifications.Service, m *metrics.NGAlert,
|
||||
folderService dashboards.FolderService, ac accesscontrol.AccessControl, dashboardService dashboards.DashboardService) (*AlertNG, error) {
|
||||
folderService dashboards.FolderService, ac accesscontrol.AccessControl, dashboardService dashboards.DashboardService, renderService rendering.Service) (*AlertNG, error) {
|
||||
ng := &AlertNG{
|
||||
Cfg: cfg,
|
||||
DataSourceCache: dataSourceCache,
|
||||
@@ -50,6 +52,7 @@ func ProvideService(cfg *setting.Cfg, dataSourceCache datasources.CacheService,
|
||||
folderService: folderService,
|
||||
accesscontrol: ac,
|
||||
dashboardService: dashboardService,
|
||||
renderService: renderService,
|
||||
}
|
||||
|
||||
if ng.IsDisabled() {
|
||||
@@ -77,6 +80,8 @@ type AlertNG struct {
|
||||
Metrics *metrics.NGAlert
|
||||
NotificationService notifications.Service
|
||||
Log log.Logger
|
||||
renderService rendering.Service
|
||||
imageService image.ImageService
|
||||
schedule schedule.ScheduleService
|
||||
stateManager *state.Manager
|
||||
folderService dashboards.FolderService
|
||||
@@ -106,6 +111,12 @@ func (ng *AlertNG) init() error {
|
||||
return err
|
||||
}
|
||||
|
||||
imageService, err := image.NewScreenshotImageServiceFromCfg(ng.Cfg, ng.Metrics.Registerer, store, ng.dashboardService, ng.renderService)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ng.imageService = imageService
|
||||
|
||||
// Let's make sure we're able to complete an initial sync of Alertmanagers before we start the alerting components.
|
||||
if err := ng.MultiOrgAlertmanager.LoadAndSyncAlertmanagersForOrgs(context.Background()); err != nil {
|
||||
return err
|
||||
@@ -133,7 +144,8 @@ func (ng *AlertNG) init() error {
|
||||
ng.Log.Error("Failed to parse application URL. Continue without it.", "error", err)
|
||||
appUrl = nil
|
||||
}
|
||||
stateManager := state.NewManager(ng.Log, ng.Metrics.GetStateMetrics(), appUrl, store, store, ng.SQLStore, ng.dashboardService)
|
||||
|
||||
stateManager := state.NewManager(ng.Log, ng.Metrics.GetStateMetrics(), appUrl, store, store, ng.SQLStore, ng.dashboardService, ng.imageService)
|
||||
scheduler := schedule.NewScheduler(schedCfg, ng.ExpressionService, appUrl, stateManager)
|
||||
|
||||
ng.stateManager = stateManager
|
||||
|
||||
@@ -9,23 +9,23 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/image"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/schedule"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/tests"
|
||||
|
||||
"github.com/benbjohnson/clock"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var testMetrics = metrics.NewNGAlert(prometheus.NewPedanticRegistry())
|
||||
@@ -107,7 +107,7 @@ func TestWarmStateCache(t *testing.T) {
|
||||
Metrics: testMetrics.GetSchedulerMetrics(),
|
||||
AdminConfigPollInterval: 10 * time.Minute, // do not poll in unit tests.
|
||||
}
|
||||
st := state.NewManager(schedCfg.Logger, testMetrics.GetStateMetrics(), nil, dbstore, dbstore, ng.SQLStore, &dashboards.FakeDashboardService{})
|
||||
st := state.NewManager(schedCfg.Logger, testMetrics.GetStateMetrics(), nil, dbstore, dbstore, ng.SQLStore, &dashboards.FakeDashboardService{}, &image.NoopImageService{})
|
||||
st.Warm(ctx)
|
||||
|
||||
t.Run("instance cache has expected entries", func(t *testing.T) {
|
||||
@@ -159,7 +159,7 @@ func TestAlertingTicker(t *testing.T) {
|
||||
disabledOrgID: {},
|
||||
},
|
||||
}
|
||||
st := state.NewManager(schedCfg.Logger, testMetrics.GetStateMetrics(), nil, dbstore, dbstore, ng.SQLStore, &dashboards.FakeDashboardService{})
|
||||
st := state.NewManager(schedCfg.Logger, testMetrics.GetStateMetrics(), nil, dbstore, dbstore, ng.SQLStore, &dashboards.FakeDashboardService{}, &image.NoopImageService{})
|
||||
appUrl := &url.URL{
|
||||
Scheme: "http",
|
||||
Host: "localhost",
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/annotations"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/image"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
|
||||
@@ -926,7 +927,7 @@ func setupScheduler(t *testing.T, rs store.RuleStore, is store.InstanceStore, ac
|
||||
Metrics: m.GetSchedulerMetrics(),
|
||||
AdminConfigPollInterval: 10 * time.Minute, // do not poll in unit tests.
|
||||
}
|
||||
st := state.NewManager(schedCfg.Logger, m.GetStateMetrics(), nil, rs, is, mockstore.NewSQLStoreMock(), &dashboards.FakeDashboardService{})
|
||||
st := state.NewManager(schedCfg.Logger, m.GetStateMetrics(), nil, rs, is, mockstore.NewSQLStoreMock(), &dashboards.FakeDashboardService{}, &image.NoopImageService{})
|
||||
appUrl := &url.URL{
|
||||
Scheme: "http",
|
||||
Host: "localhost",
|
||||
|
||||
@@ -2,6 +2,7 @@ package state
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -15,9 +16,11 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/annotations"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/image"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
ngModels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/screenshot"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
)
|
||||
|
||||
@@ -41,11 +44,12 @@ type Manager struct {
|
||||
instanceStore store.InstanceStore
|
||||
sqlStore sqlstore.Store
|
||||
dashboardService dashboards.DashboardService
|
||||
imageService image.ImageService
|
||||
}
|
||||
|
||||
func NewManager(logger log.Logger, metrics *metrics.State, externalURL *url.URL,
|
||||
ruleStore store.RuleStore, instanceStore store.InstanceStore, sqlStore sqlstore.Store,
|
||||
dashboardService dashboards.DashboardService) *Manager {
|
||||
dashboardService dashboards.DashboardService, imageService image.ImageService) *Manager {
|
||||
manager := &Manager{
|
||||
cache: newCache(logger, metrics, externalURL),
|
||||
quit: make(chan struct{}),
|
||||
@@ -56,6 +60,7 @@ func NewManager(logger log.Logger, metrics *metrics.State, externalURL *url.URL,
|
||||
instanceStore: instanceStore,
|
||||
sqlStore: sqlStore,
|
||||
dashboardService: dashboardService,
|
||||
imageService: imageService,
|
||||
}
|
||||
go manager.recordMetrics()
|
||||
return manager
|
||||
@@ -165,6 +170,22 @@ func (st *Manager) ProcessEvalResults(ctx context.Context, alertRule *ngModels.A
|
||||
return states
|
||||
}
|
||||
|
||||
//nolint:unused
|
||||
func (st *Manager) newImage(ctx context.Context, alertRule *ngModels.AlertRule, state *State) error {
|
||||
if state.Image == nil {
|
||||
image, err := st.imageService.NewImage(ctx, alertRule)
|
||||
if errors.Is(err, screenshot.ErrScreenshotsUnavailable) {
|
||||
// It's not an error if screenshots are disabled.
|
||||
return nil
|
||||
} else if err != nil {
|
||||
st.log.Error("failed to create image", "error", err)
|
||||
return err
|
||||
}
|
||||
state.Image = image
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set the current state based on evaluation results
|
||||
func (st *Manager) setNextState(ctx context.Context, alertRule *ngModels.AlertRule, result eval.Result) *State {
|
||||
currentState := st.getOrCreate(ctx, alertRule, result)
|
||||
|
||||
@@ -8,24 +8,23 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/annotations"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
|
||||
"github.com/grafana/grafana/pkg/expr"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/tests"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/expr"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/annotations"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/image"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/tests"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
|
||||
)
|
||||
|
||||
var testMetrics = metrics.NewNGAlert(prometheus.NewPedanticRegistry())
|
||||
@@ -38,7 +37,7 @@ func TestDashboardAnnotations(t *testing.T) {
|
||||
_, dbstore := tests.SetupTestEnv(t, 1)
|
||||
|
||||
sqlStore := mockstore.NewSQLStoreMock()
|
||||
st := state.NewManager(log.New("test_stale_results_handler"), testMetrics.GetStateMetrics(), nil, dbstore, dbstore, sqlStore, &dashboards.FakeDashboardService{})
|
||||
st := state.NewManager(log.New("test_stale_results_handler"), testMetrics.GetStateMetrics(), nil, dbstore, dbstore, sqlStore, &dashboards.FakeDashboardService{}, &image.NoopImageService{})
|
||||
|
||||
fakeAnnoRepo := store.NewFakeAnnotationsRepo()
|
||||
annotations.SetRepository(fakeAnnoRepo)
|
||||
@@ -1771,7 +1770,7 @@ func TestProcessEvalResults(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
ss := mockstore.NewSQLStoreMock()
|
||||
st := state.NewManager(log.New("test_state_manager"), testMetrics.GetStateMetrics(), nil, nil, &store.FakeInstanceStore{}, ss, &dashboards.FakeDashboardService{})
|
||||
st := state.NewManager(log.New("test_state_manager"), testMetrics.GetStateMetrics(), nil, nil, &store.FakeInstanceStore{}, ss, &dashboards.FakeDashboardService{}, &image.NoopImageService{})
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
fakeAnnoRepo := store.NewFakeAnnotationsRepo()
|
||||
annotations.SetRepository(fakeAnnoRepo)
|
||||
@@ -1882,7 +1881,7 @@ func TestStaleResultsHandler(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
ctx := context.Background()
|
||||
sqlStore := mockstore.NewSQLStoreMock()
|
||||
st := state.NewManager(log.New("test_stale_results_handler"), testMetrics.GetStateMetrics(), nil, dbstore, dbstore, sqlStore, &dashboards.FakeDashboardService{})
|
||||
st := state.NewManager(log.New("test_stale_results_handler"), testMetrics.GetStateMetrics(), nil, dbstore, dbstore, sqlStore, &dashboards.FakeDashboardService{}, &image.NoopImageService{})
|
||||
st.Warm(ctx)
|
||||
existingStatesForRule := st.GetStatesForRuleUID(rule.OrgID, rule.UID)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/expr"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
ngModels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
)
|
||||
|
||||
type State struct {
|
||||
@@ -29,6 +30,7 @@ type State struct {
|
||||
LastSentAt time.Time
|
||||
Annotations map[string]string
|
||||
Labels data.Labels
|
||||
Image *store.Image
|
||||
Error error
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
ptr "github.com/xorcare/pointer"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNeedsSending(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrImageNotFound is returned when the image does not exist.
|
||||
ErrImageNotFound = errors.New("image not found")
|
||||
)
|
||||
|
||||
type Image struct {
|
||||
ID int64 `xorm:"pk autoincr 'id'"`
|
||||
Token string `xorm:"token"`
|
||||
Path string `xorm:"path"`
|
||||
URL string `xorm:"url"`
|
||||
CreatedAt time.Time `xorm:"created_at"`
|
||||
ExpiresAt time.Time `xorm:"expires_at"`
|
||||
}
|
||||
|
||||
// A XORM interface that lets us clean up our SQL session definition.
|
||||
func (i *Image) TableName() string {
|
||||
return "alert_image"
|
||||
}
|
||||
|
||||
type ImageStore interface {
|
||||
// Get returns the image with the token or ErrImageNotFound.
|
||||
GetImage(ctx context.Context, token string) (*Image, error)
|
||||
|
||||
// Saves the image or returns an error.
|
||||
SaveImage(ctx context.Context, img *Image) error
|
||||
}
|
||||
|
||||
func (st DBstore) GetImage(ctx context.Context, token string) (*Image, error) {
|
||||
var img Image
|
||||
if err := st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
|
||||
exists, err := sess.Where("token = ?", token).Get(&img)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get image: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return ErrImageNotFound
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &img, nil
|
||||
}
|
||||
|
||||
func (st DBstore) SaveImage(ctx context.Context, img *Image) error {
|
||||
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
|
||||
// TODO: Is this a good idea? Do we actually want to automatically expire
|
||||
// rows? See issue https://github.com/grafana/grafana/issues/49366
|
||||
img.ExpiresAt = TimeNow().Add(1 * time.Minute).UTC()
|
||||
if img.ID == 0 { // xorm will fill this field on Insert.
|
||||
token, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create token: %w", err)
|
||||
}
|
||||
img.Token = token.String()
|
||||
img.CreatedAt = TimeNow().UTC()
|
||||
if _, err := sess.Insert(img); err != nil {
|
||||
return fmt.Errorf("failed to insert screenshot: %w", err)
|
||||
}
|
||||
} else {
|
||||
affected, err := sess.ID(img.ID).Update(img)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update screenshot: %v", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return fmt.Errorf("update statement had no effect")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
//nolint:unused
|
||||
func (st DBstore) DeleteExpiredImages(ctx context.Context) error {
|
||||
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
|
||||
n, err := sess.Where("expires_at < ?", TimeNow()).Delete(&Image{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete expired images: %w", err)
|
||||
}
|
||||
st.Logger.Info("deleted expired images", "n", n)
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/tests"
|
||||
)
|
||||
|
||||
func createTestImg(fakeUrl string, fakePath string) *store.Image {
|
||||
return &store.Image{
|
||||
ID: 0,
|
||||
Token: "",
|
||||
Path: fakeUrl + "local",
|
||||
URL: fakeUrl,
|
||||
}
|
||||
}
|
||||
|
||||
func addID(img *store.Image, id int64) *store.Image {
|
||||
img.ID = id
|
||||
return img
|
||||
}
|
||||
|
||||
func addToken(img *store.Image) *store.Image {
|
||||
token, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
panic("wat")
|
||||
}
|
||||
img.Token = token.String()
|
||||
return img
|
||||
}
|
||||
|
||||
func TestSaveAndGetImage(t *testing.T) {
|
||||
mockTimeNow()
|
||||
ctx := context.Background()
|
||||
_, dbstore := tests.SetupTestEnv(t, baseIntervalSeconds)
|
||||
|
||||
// Here are some images to save.
|
||||
imgs := []struct {
|
||||
name string
|
||||
img *store.Image
|
||||
errors bool
|
||||
}{
|
||||
{
|
||||
"with file path",
|
||||
createTestImg("", "path"),
|
||||
false,
|
||||
},
|
||||
{
|
||||
"with URL",
|
||||
createTestImg("url", ""),
|
||||
false,
|
||||
},
|
||||
{
|
||||
"ID already set, should not change",
|
||||
addToken(addID(createTestImg("Foo", ""), 123)),
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range imgs {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
err := dbstore.SaveImage(ctx, test.img)
|
||||
if test.errors {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
returned, err := dbstore.GetImage(ctx, test.img.Token)
|
||||
assert.NoError(t, err, "Shouldn't error when getting the image")
|
||||
assert.Equal(t, test.img, returned)
|
||||
|
||||
// Save again to test update path.
|
||||
err = dbstore.SaveImage(ctx, test.img)
|
||||
require.NoError(t, err, "Should have no error on second write")
|
||||
returned, err = dbstore.GetImage(ctx, test.img.Token)
|
||||
assert.NoError(t, err, "Shouldn't error when getting the image a second time")
|
||||
assert.Equal(t, test.img, returned)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteExpiredImages(t *testing.T) {
|
||||
mockTimeNow()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||
defer cancel()
|
||||
_, dbstore := tests.SetupTestEnv(t, baseIntervalSeconds)
|
||||
|
||||
// Save two images.
|
||||
imgs := []*store.Image{
|
||||
createTestImg("", ""),
|
||||
createTestImg("", ""),
|
||||
}
|
||||
|
||||
for _, img := range imgs {
|
||||
err := dbstore.SaveImage(ctx, img)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Wait until timeout.
|
||||
for i := 0; i < 120; i++ {
|
||||
store.TimeNow()
|
||||
}
|
||||
|
||||
// Call expired
|
||||
err := dbstore.DeleteExpiredImages(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// All images are gone.
|
||||
img, err := dbstore.GetImage(ctx, imgs[0].Token)
|
||||
require.Nil(t, img)
|
||||
require.Error(t, err)
|
||||
|
||||
img, err = dbstore.GetImage(ctx, imgs[1].Token)
|
||||
require.Nil(t, img)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
const baseIntervalSeconds = 10
|
||||
|
||||
// Every time this is called, time advances by 1 second.
|
||||
func mockTimeNow() {
|
||||
var timeSeed int64
|
||||
store.TimeNow = func() time.Time {
|
||||
|
||||
@@ -66,7 +66,7 @@ func SetupTestEnv(t *testing.T, baseInterval time.Duration) (*ngalert.AlertNG, *
|
||||
|
||||
ng, err := ngalert.ProvideService(
|
||||
cfg, nil, routing.NewRouteRegister(), sqlStore, nil, nil, nil, nil,
|
||||
secretsService, nil, m, folderService, ac, &dashboards.FakeDashboardService{},
|
||||
secretsService, nil, m, folderService, ac, &dashboards.FakeDashboardService{}, nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
return ng, &store.DBstore{
|
||||
|
||||
Reference in New Issue
Block a user