mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06: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:
338
pkg/services/screenshot/screenshot_test.go
Normal file
338
pkg/services/screenshot/screenshot_test.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package screenshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/imguploader"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestScreenshotOptions(t *testing.T) {
|
||||
o := ScreenshotOptions{}
|
||||
assert.Equal(t, ScreenshotOptions{}, o)
|
||||
|
||||
o = o.SetDefaults()
|
||||
assert.Equal(t, ScreenshotOptions{
|
||||
Width: DefaultWidth,
|
||||
Height: DefaultHeight,
|
||||
Theme: DefaultTheme,
|
||||
Timeout: DefaultTimeout,
|
||||
}, o)
|
||||
|
||||
o.Width = 100
|
||||
o = o.SetDefaults()
|
||||
assert.Equal(t, ScreenshotOptions{
|
||||
Width: 100,
|
||||
Height: DefaultHeight,
|
||||
Theme: DefaultTheme,
|
||||
Timeout: DefaultTimeout,
|
||||
}, o)
|
||||
|
||||
o.Height = 100
|
||||
o = o.SetDefaults()
|
||||
assert.Equal(t, ScreenshotOptions{
|
||||
Width: 100,
|
||||
Height: 100,
|
||||
Theme: DefaultTheme,
|
||||
Timeout: DefaultTimeout,
|
||||
}, o)
|
||||
|
||||
o.Theme = "Not a theme"
|
||||
o = o.SetDefaults()
|
||||
assert.Equal(t, ScreenshotOptions{
|
||||
Width: 100,
|
||||
Height: 100,
|
||||
Theme: DefaultTheme,
|
||||
Timeout: DefaultTimeout,
|
||||
}, o)
|
||||
|
||||
o.Timeout = DefaultTimeout + 1
|
||||
assert.Equal(t, ScreenshotOptions{
|
||||
Width: 100,
|
||||
Height: 100,
|
||||
Theme: DefaultTheme,
|
||||
Timeout: DefaultTimeout + 1,
|
||||
}, o)
|
||||
}
|
||||
|
||||
func TestBrowserScreenshotService(t *testing.T) {
|
||||
c := gomock.NewController(t)
|
||||
defer c.Finish()
|
||||
|
||||
d := dashboards.FakeDashboardService{}
|
||||
r := rendering.NewMockService(c)
|
||||
s := NewBrowserScreenshotService(&d, r)
|
||||
|
||||
// a non-existent dashboard should return error
|
||||
d.GetDashboardFn = func(ctx context.Context, cmd *models.GetDashboardQuery) error {
|
||||
return models.ErrDashboardNotFound
|
||||
}
|
||||
ctx := context.Background()
|
||||
opts := ScreenshotOptions{}
|
||||
screenshot, err := s.Take(ctx, opts)
|
||||
assert.EqualError(t, err, "Dashboard not found")
|
||||
assert.Nil(t, screenshot)
|
||||
|
||||
d.GetDashboardFn = func(ctx context.Context, cmd *models.GetDashboardQuery) error {
|
||||
cmd.Result = &models.Dashboard{Id: 1, Uid: "foo", Slug: "bar", OrgId: 2}
|
||||
return nil
|
||||
}
|
||||
|
||||
renderOpts := rendering.Opts{
|
||||
AuthOpts: rendering.AuthOpts{
|
||||
OrgID: 2,
|
||||
OrgRole: models.ROLE_ADMIN,
|
||||
},
|
||||
ErrorOpts: rendering.ErrorOpts{
|
||||
ErrorConcurrentLimitReached: true,
|
||||
ErrorRenderUnavailable: true,
|
||||
},
|
||||
TimeoutOpts: rendering.TimeoutOpts{
|
||||
Timeout: DefaultTimeout,
|
||||
},
|
||||
Width: DefaultWidth,
|
||||
Height: DefaultHeight,
|
||||
Theme: DefaultTheme,
|
||||
Path: "d-solo/foo/bar?orgId=2&panelId=4",
|
||||
ConcurrentLimit: setting.AlertingRenderLimit,
|
||||
}
|
||||
|
||||
opts.DashboardUID = "foo"
|
||||
opts.PanelID = 4
|
||||
r.EXPECT().
|
||||
Render(ctx, renderOpts, nil).
|
||||
Return(&rendering.RenderResult{FilePath: "panel.png"}, nil)
|
||||
screenshot, err = s.Take(ctx, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "panel.png"}, *screenshot)
|
||||
|
||||
// a timeout should return error
|
||||
r.EXPECT().
|
||||
Render(ctx, renderOpts, nil).
|
||||
Return(nil, rendering.ErrTimeout)
|
||||
screenshot, err = s.Take(ctx, opts)
|
||||
assert.EqualError(t, err, fmt.Sprintf("failed to take screenshot: %s", rendering.ErrTimeout))
|
||||
assert.Nil(t, screenshot)
|
||||
}
|
||||
|
||||
func TestCachableScreenshotService(t *testing.T) {
|
||||
c := gomock.NewController(t)
|
||||
defer c.Finish()
|
||||
|
||||
m := NewMockScreenshotService(c)
|
||||
s := NewCachableScreenshotService(prometheus.DefaultRegisterer, time.Second, m)
|
||||
|
||||
ctx := context.Background()
|
||||
opts := ScreenshotOptions{DashboardUID: "foo", PanelID: 1}
|
||||
|
||||
// should be a miss and ask the mock service to take a screenshot
|
||||
m.EXPECT().Take(ctx, opts).Return(&Screenshot{Path: "panel.png"}, nil)
|
||||
screenshot, err := s.Take(ctx, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "panel.png"}, *screenshot)
|
||||
|
||||
// should be a hit
|
||||
screenshot, err = s.Take(ctx, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "panel.png"}, *screenshot)
|
||||
|
||||
// wait 1s and the cached screenshot should have expired
|
||||
<-time.After(time.Second)
|
||||
|
||||
// should be a miss as the cached screenshot has expired
|
||||
m.EXPECT().Take(ctx, opts).Return(&Screenshot{Path: "panel.png"}, nil)
|
||||
screenshot, err = s.Take(ctx, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "panel.png"}, *screenshot)
|
||||
}
|
||||
|
||||
func TestNoopScreenshotService(t *testing.T) {
|
||||
s := NoopScreenshotService{}
|
||||
screenshot, err := s.Take(context.Background(), ScreenshotOptions{})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, screenshot)
|
||||
}
|
||||
|
||||
func TestScreenshotUnavailableService(t *testing.T) {
|
||||
s := ScreenshotUnavailableService{}
|
||||
screenshot, err := s.Take(context.Background(), ScreenshotOptions{})
|
||||
assert.Equal(t, err, ErrScreenshotsUnavailable)
|
||||
assert.Nil(t, screenshot)
|
||||
}
|
||||
|
||||
func TestSingleFlightScreenshotService(t *testing.T) {
|
||||
c := gomock.NewController(t)
|
||||
defer c.Finish()
|
||||
|
||||
m := NewMockScreenshotService(c)
|
||||
s := NewSingleFlightScreenshotService(m)
|
||||
|
||||
ctx := context.Background()
|
||||
opts := ScreenshotOptions{DashboardUID: "foo", PanelID: 1}
|
||||
|
||||
// expect 1 invocation of the mock service for the same options
|
||||
m.EXPECT().Take(ctx, opts).
|
||||
Do(func(_ context.Context, _ ScreenshotOptions) { <-time.After(time.Second) }).
|
||||
Return(&Screenshot{Path: "panel.png"}, nil)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
screenshot, err := s.Take(ctx, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "panel.png"}, *screenshot)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// expect two invocations of the mock service for different dashboards
|
||||
opts1 := ScreenshotOptions{DashboardUID: "foo", PanelID: 1}
|
||||
opts2 := ScreenshotOptions{DashboardUID: "bar", PanelID: 1}
|
||||
m.EXPECT().Take(ctx, opts1).Return(&Screenshot{Path: "foo.png"}, nil)
|
||||
m.EXPECT().Take(ctx, opts2).Return(&Screenshot{Path: "bar.png"}, nil)
|
||||
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
screenshot, err := s.Take(ctx, opts1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "foo.png"}, *screenshot)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
screenshot, err := s.Take(ctx, opts2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "bar.png"}, *screenshot)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
// expect two invocations of the mock service for different panels in the same dashboard
|
||||
opts1 = ScreenshotOptions{DashboardUID: "foo", PanelID: 1}
|
||||
opts2 = ScreenshotOptions{DashboardUID: "foo", PanelID: 2}
|
||||
m.EXPECT().Take(ctx, opts1).Return(&Screenshot{Path: "panel1.png"}, nil)
|
||||
m.EXPECT().Take(ctx, opts2).Return(&Screenshot{Path: "panel2.png"}, nil)
|
||||
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
screenshot, err := s.Take(ctx, opts1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "panel1.png"}, *screenshot)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
screenshot, err := s.Take(ctx, opts2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "panel2.png"}, *screenshot)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
// expect two invocations of the mock service for different panels in the same dashboard
|
||||
opts1 = ScreenshotOptions{DashboardUID: "foo", PanelID: 1, Theme: models.ThemeDark}
|
||||
opts2 = ScreenshotOptions{DashboardUID: "foo", PanelID: 1, Theme: models.ThemeLight}
|
||||
m.EXPECT().Take(ctx, opts1).Return(&Screenshot{Path: "dark.png"}, nil)
|
||||
m.EXPECT().Take(ctx, opts2).Return(&Screenshot{Path: "light.png"}, nil)
|
||||
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
screenshot, err := s.Take(ctx, opts1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "dark.png"}, *screenshot)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
screenshot, err := s.Take(ctx, opts2)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "light.png"}, *screenshot)
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestRateLimitScreenshotService(t *testing.T) {
|
||||
c := gomock.NewController(t)
|
||||
defer c.Finish()
|
||||
|
||||
m := NewMockScreenshotService(c)
|
||||
s := NewRateLimitScreenshotService(m, 1)
|
||||
ctx := context.Background()
|
||||
opts := ScreenshotOptions{DashboardUID: "foo", PanelID: 1}
|
||||
|
||||
var v int64
|
||||
for i := 0; i < 10; i++ {
|
||||
m.EXPECT().Take(ctx, opts).
|
||||
Do(func(_ context.Context, _ ScreenshotOptions) {
|
||||
// v should be 0 to show that no tokens have been acquired
|
||||
assert.Equal(t, int64(0), atomic.LoadInt64(&v))
|
||||
atomic.AddInt64(&v, 1)
|
||||
assert.Equal(t, int64(1), atomic.LoadInt64(&v))
|
||||
|
||||
// interrupt so other goroutines can attempt to acquire the token
|
||||
<-time.After(time.Microsecond)
|
||||
|
||||
// v should be 1 to show that no other goroutines acquired the token
|
||||
assert.Equal(t, int64(1), atomic.LoadInt64(&v))
|
||||
atomic.AddInt64(&v, -1)
|
||||
assert.Equal(t, int64(0), atomic.LoadInt64(&v))
|
||||
}).
|
||||
Return(&Screenshot{Path: "foo.png"}, nil)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result, err := s.Take(ctx, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{Path: "foo.png"}, *result)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestUploadingScreenshotService(t *testing.T) {
|
||||
c := gomock.NewController(t)
|
||||
defer c.Finish()
|
||||
|
||||
m := NewMockScreenshotService(c)
|
||||
u := imguploader.NewMockImageUploader(c)
|
||||
s := NewUploadingScreenshotService(prometheus.DefaultRegisterer, m, u)
|
||||
|
||||
ctx := context.Background()
|
||||
opts := ScreenshotOptions{DashboardUID: "foo", PanelID: 1}
|
||||
|
||||
m.EXPECT().Take(ctx, opts).Return(&Screenshot{Path: "foo.png"}, nil)
|
||||
u.EXPECT().Upload(ctx, "foo.png").Return("https://example.com/foo.png", nil)
|
||||
screenshot, err := s.Take(ctx, opts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, Screenshot{
|
||||
Path: "foo.png",
|
||||
URL: "https://example.com/foo.png",
|
||||
}, *screenshot)
|
||||
|
||||
// error on upload should still return screenshot on disk
|
||||
m.EXPECT().Take(ctx, opts).Return(&Screenshot{Path: "foo.png"}, nil)
|
||||
u.EXPECT().Upload(ctx, "foo.png").Return("", errors.New("service is unavailable"))
|
||||
screenshot, err = s.Take(ctx, opts)
|
||||
assert.EqualError(t, err, "failed to upload screenshot: service is unavailable")
|
||||
assert.Equal(t, Screenshot{
|
||||
Path: "foo.png",
|
||||
}, *screenshot)
|
||||
}
|
||||
Reference in New Issue
Block a user