grafana/pkg/services/thumbs/service.go
Artur Wierzbicki 644503f5e6
Previews: use ETag header (#51008)
* use etag header for previews

* use unix millis rather than tz-dependant string
2022-06-17 18:02:03 +04:00

482 lines
14 KiB
Go

package thumbs
import (
"context"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"time"
"github.com/segmentio/encoding/json"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/registry"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/live"
"github.com/grafana/grafana/pkg/services/rendering"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
)
type Service interface {
registry.ProvidesUsageStats
Run(ctx context.Context) error
Enabled() bool
GetImage(c *models.ReqContext)
GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig
// from dashboard page
SetImage(c *models.ReqContext) // form post
UpdateThumbnailState(c *models.ReqContext)
// Must be admin
StartCrawler(c *models.ReqContext) response.Response
StopCrawler(c *models.ReqContext) response.Response
CrawlerStatus(c *models.ReqContext) response.Response
}
type thumbService struct {
scheduleOptions crawlerScheduleOptions
renderer dashRenderer
renderingService rendering.Service
thumbnailRepo thumbnailRepo
lockService *serverlock.ServerLockService
features featuremgmt.FeatureToggles
store sqlstore.Store
crawlLockServiceActionName string
log log.Logger
canRunCrawler bool
settings setting.DashboardPreviewsSettings
dashboardService dashboards.DashboardService
}
type crawlerScheduleOptions struct {
crawlInterval time.Duration
tickerInterval time.Duration
maxCrawlDuration time.Duration
crawlerMode CrawlerMode
thumbnailKind models.ThumbnailKind
themes []models.Theme
auth CrawlerAuth
}
func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
lockService *serverlock.ServerLockService, renderService rendering.Service,
gl *live.GrafanaLive, store *sqlstore.SQLStore, authSetupService CrawlerAuthSetupService,
dashboardService dashboards.DashboardService) Service {
if !features.IsEnabled(featuremgmt.FlagDashboardPreviews) {
return &dummyService{}
}
logger := log.New("previews_service")
thumbnailRepo := newThumbnailRepo(store)
canRunCrawler := true
authSetupStarted := time.Now()
crawlerAuth, err := authSetupService.Setup(context.Background())
if err != nil {
logger.Error("Crawler auth setup failed", "err", err, "crawlerAuthSetupTime", time.Since(authSetupStarted))
canRunCrawler = false
} else {
logger.Info("Crawler auth setup complete", "crawlerAuthSetupTime", time.Since(authSetupStarted))
}
t := &thumbService{
renderingService: renderService,
renderer: newSimpleCrawler(renderService, gl, thumbnailRepo, cfg, cfg.DashboardPreviews),
thumbnailRepo: thumbnailRepo,
store: store,
features: features,
lockService: lockService,
crawlLockServiceActionName: "dashboard-crawler",
log: logger,
canRunCrawler: canRunCrawler,
settings: cfg.DashboardPreviews,
scheduleOptions: crawlerScheduleOptions{
tickerInterval: 5 * time.Minute,
crawlInterval: cfg.DashboardPreviews.SchedulerInterval,
maxCrawlDuration: cfg.DashboardPreviews.MaxCrawlDuration,
crawlerMode: CrawlerModeThumbs,
thumbnailKind: models.ThumbnailKindDefault,
themes: []models.Theme{models.ThemeDark, models.ThemeLight},
auth: crawlerAuth,
},
dashboardService: dashboardService,
}
return t
}
func (hs *thumbService) GetUsageStats(ctx context.Context) map[string]interface{} {
s := hs.getDashboardPreviewsSetupSettings(ctx)
stats := make(map[string]interface{})
if s.SystemRequirements.Met {
stats["stats.dashboard_previews.system_req_met.count"] = 1
}
if s.ThumbnailsExist {
stats["stats.dashboard_previews.thumbnails_exist.count"] = 1
}
return stats
}
func (hs *thumbService) Enabled() bool {
return hs.features.IsEnabled(featuremgmt.FlagDashboardPreviews)
}
func (hs *thumbService) parseImageReq(c *models.ReqContext, checkSave bool) *previewRequest {
params := web.Params(c.Req)
kind, err := models.ParseThumbnailKind(params[":kind"])
if err != nil {
c.JSON(400, map[string]string{"error": "invalid size"})
return nil
}
theme, err := models.ParseTheme(params[":theme"])
if err != nil {
c.JSON(400, map[string]string{"error": "invalid theme"})
return nil
}
req := &previewRequest{
OrgID: c.OrgId,
UID: params[":uid"],
Theme: theme,
Kind: kind,
}
if len(req.UID) < 1 {
c.JSON(400, map[string]string{"error": "missing UID"})
return nil
}
// Check permissions and status
status := hs.getStatus(c, req.UID, checkSave)
if status != 200 {
c.JSON(status, map[string]string{"error": fmt.Sprintf("code: %d", status)})
return nil
}
return req
}
type updateThumbnailStateRequest struct {
State models.ThumbnailState `json:"state" binding:"Required"`
}
func (hs *thumbService) UpdateThumbnailState(c *models.ReqContext) {
req := hs.parseImageReq(c, false)
if req == nil {
return // already returned value
}
var body = &updateThumbnailStateRequest{}
err := web.Bind(c.Req, body)
if err != nil {
hs.log.Error("Error parsing update thumbnail state request", "dashboardUid", req.UID, "err", err.Error())
c.JSON(500, map[string]string{"dashboardUID": req.UID, "error": "unknown"})
return
}
err = hs.thumbnailRepo.updateThumbnailState(c.Req.Context(), body.State, models.DashboardThumbnailMeta{
DashboardUID: req.UID,
OrgId: req.OrgID,
Theme: req.Theme,
Kind: models.ThumbnailKindDefault,
})
if err != nil {
hs.log.Error("Error when trying to update thumbnail state", "dashboardUid", req.UID, "err", err.Error(), "newState", body.State)
c.JSON(500, map[string]string{"dashboardUID": req.UID, "error": "unknown"})
return
}
hs.log.Info("Updated dashboard thumbnail state", "dashboardUid", req.UID, "theme", req.Theme, "newState", body.State)
c.JSON(http.StatusOK, map[string]string{"success": "true"})
}
func (hs *thumbService) GetImage(c *models.ReqContext) {
req := hs.parseImageReq(c, false)
if req == nil {
return // already returned value
}
res, err := hs.thumbnailRepo.getThumbnail(c.Req.Context(), models.DashboardThumbnailMeta{
DashboardUID: req.UID,
OrgId: req.OrgID,
Theme: req.Theme,
Kind: models.ThumbnailKindDefault,
})
if errors.Is(err, models.ErrDashboardThumbnailNotFound) {
c.Resp.WriteHeader(404)
return
}
if err != nil || res == nil {
hs.log.Error("Error when retrieving thumbnail", "dashboardUid", req.UID, "err", err.Error())
c.JSON(500, map[string]string{"dashboardUID": req.UID, "error": "unknown"})
return
}
currentEtag := fmt.Sprintf("%d", res.Updated.Unix())
c.Resp.Header().Set("ETag", currentEtag)
previousEtag := c.Req.Header.Get("If-None-Match")
if previousEtag == currentEtag {
c.Resp.WriteHeader(http.StatusNotModified)
return
}
c.Resp.Header().Set("Content-Type", res.MimeType)
if _, err := c.Resp.Write(res.Image); err != nil {
hs.log.Error("Error writing to response", "dashboardUid", req.UID, "err", err)
}
}
func (hs *thumbService) GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig {
return hs.getDashboardPreviewsSetupSettings(c.Req.Context())
}
func (hs *thumbService) getDashboardPreviewsSetupSettings(ctx context.Context) dashboardPreviewsSetupConfig {
systemRequirements := hs.getSystemRequirements()
thumbnailsExist, err := hs.thumbnailRepo.doThumbnailsExist(ctx)
if err != nil {
return dashboardPreviewsSetupConfig{
SystemRequirements: systemRequirements,
ThumbnailsExist: false,
}
}
return dashboardPreviewsSetupConfig{
SystemRequirements: systemRequirements,
ThumbnailsExist: thumbnailsExist,
}
}
func (hs *thumbService) getSystemRequirements() dashboardPreviewsSystemRequirements {
res, err := hs.renderingService.HasCapability(rendering.ScalingDownImages)
if err != nil {
hs.log.Error("Error when verifying dashboard previews system requirements thumbnail", "err", err.Error())
return dashboardPreviewsSystemRequirements{
Met: false,
}
}
if !res.IsSupported {
return dashboardPreviewsSystemRequirements{
Met: false,
RequiredImageRendererPluginVersion: res.SemverConstraint,
}
}
return dashboardPreviewsSystemRequirements{
Met: true,
}
}
// Hack for now -- lets you upload images explicitly
func (hs *thumbService) SetImage(c *models.ReqContext) {
req := hs.parseImageReq(c, false)
if req == nil {
return // already returned value
}
r := c.Req
// Parse our multipart form, 10 << 20 specifies a maximum
// upload of 10 MB files.
err := r.ParseMultipartForm(10 << 20)
if err != nil {
c.JSON(400, map[string]string{"error": "invalid upload size"})
return
}
// FormFile returns the first file for the given key `myFile`
// it also returns the FileHeader so we can get the Filename,
// the Header and the size of the file
file, handler, err := r.FormFile("file")
if err != nil {
c.JSON(400, map[string]string{"error": "missing multi-part form field named 'file'"})
fmt.Println("error", err)
return
}
defer func() {
_ = file.Close()
}()
hs.log.Info("Uploaded File: %+v\n", handler.Filename)
hs.log.Info("File Size: %+v\n", handler.Size)
hs.log.Info("MIME Header: %+v\n", handler.Header)
fileBytes, err := ioutil.ReadAll(file)
if err != nil {
fmt.Println(err)
c.JSON(400, map[string]string{"error": "error reading file"})
return
}
_, err = hs.thumbnailRepo.saveFromBytes(c.Req.Context(), fileBytes, getMimeType(handler.Filename), models.DashboardThumbnailMeta{
DashboardUID: req.UID,
OrgId: req.OrgID,
Theme: req.Theme,
Kind: req.Kind,
}, models.DashboardVersionForManualThumbnailUpload)
if err != nil {
c.JSON(400, map[string]string{"error": "error saving thumbnail file"})
fmt.Println("error", err)
return
}
c.JSON(http.StatusOK, map[string]int{"OK": len(fileBytes)})
}
func (hs *thumbService) StartCrawler(c *models.ReqContext) response.Response {
body, err := io.ReadAll(c.Req.Body)
if err != nil {
return response.Error(500, "error reading bytes", err)
}
cmd := &crawlCmd{}
err = json.Unmarshal(body, cmd)
if err != nil {
return response.Error(500, "error parsing bytes", err)
}
if cmd.Mode == "" {
cmd.Mode = CrawlerModeThumbs
}
go hs.runOnDemandCrawl(context.Background(), cmd.Theme, cmd.Mode, models.ThumbnailKindDefault, rendering.AuthOpts{
OrgID: c.OrgId,
UserID: c.UserId,
OrgRole: c.OrgRole,
})
status, err := hs.renderer.Status()
if err != nil {
return response.Error(500, "error starting", err)
}
return response.JSON(http.StatusOK, status)
}
func (hs *thumbService) StopCrawler(c *models.ReqContext) response.Response {
msg, err := hs.renderer.Stop()
if err != nil {
return response.Error(500, "error starting", err)
}
return response.JSON(http.StatusOK, msg)
}
func (hs *thumbService) CrawlerStatus(c *models.ReqContext) response.Response {
msg, err := hs.renderer.Status()
if err != nil {
return response.Error(500, "error starting", err)
}
return response.JSON(http.StatusOK, msg)
}
// Ideally this service would not require first looking up the full dashboard just to bet the id!
func (hs *thumbService) getStatus(c *models.ReqContext, uid string, checkSave bool) int {
dashboardID, err := hs.getDashboardId(c, uid)
if err != nil {
return 404
}
guardian := guardian.New(c.Req.Context(), dashboardID, c.OrgId, c.SignedInUser)
if checkSave {
if canSave, err := guardian.CanSave(); err != nil || !canSave {
return 403 // forbidden
}
return 200
}
if canView, err := guardian.CanView(); err != nil || !canView {
return 403 // forbidden
}
return 200 // found and OK
}
func (hs *thumbService) getDashboardId(c *models.ReqContext, uid string) (int64, error) {
query := models.GetDashboardQuery{Uid: uid, OrgId: c.OrgId}
if err := hs.dashboardService.GetDashboard(c.Req.Context(), &query); err != nil {
return 0, err
}
return query.Result.Id, nil
}
func (hs *thumbService) runOnDemandCrawl(parentCtx context.Context, theme models.Theme, mode CrawlerMode, kind models.ThumbnailKind, authOpts rendering.AuthOpts) {
if !hs.canRunCrawler {
return
}
crawlerCtx, cancel := context.WithTimeout(parentCtx, hs.scheduleOptions.maxCrawlDuration)
defer cancel()
// wait for at least a minute after the last completed run
interval := time.Minute
err := hs.lockService.LockAndExecute(crawlerCtx, hs.crawlLockServiceActionName, interval, func(ctx context.Context) {
if err := hs.renderer.Run(crawlerCtx, hs.scheduleOptions.auth, mode, theme, kind); err != nil {
hs.log.Error("On demand crawl error", "mode", mode, "theme", theme, "kind", kind, "userId", authOpts.UserID, "orgId", authOpts.OrgID, "orgRole", authOpts.OrgRole)
}
})
if err != nil {
hs.log.Error("On demand crawl lock error", "err", err)
}
}
func (hs *thumbService) runScheduledCrawl(parentCtx context.Context) {
crawlerCtx, cancel := context.WithTimeout(parentCtx, hs.scheduleOptions.maxCrawlDuration)
defer cancel()
err := hs.lockService.LockAndExecute(crawlerCtx, hs.crawlLockServiceActionName, hs.scheduleOptions.crawlInterval, func(ctx context.Context) {
for _, theme := range hs.scheduleOptions.themes {
if err := hs.renderer.Run(crawlerCtx, hs.scheduleOptions.auth, hs.scheduleOptions.crawlerMode, theme, hs.scheduleOptions.thumbnailKind); err != nil {
hs.log.Error("Scheduled crawl error", "theme", theme, "kind", hs.scheduleOptions.thumbnailKind, "err", err)
}
}
})
if err != nil {
hs.log.Error("Scheduled crawl lock error", "err", err)
}
}
func (hs *thumbService) Run(ctx context.Context) error {
if !hs.canRunCrawler {
return nil
}
gc := time.NewTicker(hs.scheduleOptions.tickerInterval)
for {
select {
case <-gc.C:
go hs.runScheduledCrawl(ctx)
case <-ctx.Done():
hs.log.Debug("Grafana is shutting down - stopping dashboard crawler")
gc.Stop()
return nil
}
}
}