mirror of
https://github.com/grafana/grafana.git
synced 2024-11-27 03:11:01 -06:00
11d8bcbea9
* Guardian: Use dashboard UID instead of ID * Apply suggestions from code review Introduce several guardian constructors and each time use the most appropriate one.
548 lines
17 KiB
Go
548 lines
17 KiB
Go
package thumbs
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/segmentio/encoding/json"
|
|
|
|
"github.com/grafana/grafana/pkg/services/datasources/permissions"
|
|
"github.com/grafana/grafana/pkg/services/searchV2"
|
|
|
|
"github.com/grafana/grafana/pkg/api/response"
|
|
"github.com/grafana/grafana/pkg/infra/db"
|
|
"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/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 db.DB
|
|
crawlLockServiceActionName string
|
|
log log.Logger
|
|
canRunCrawler bool
|
|
settings setting.DashboardPreviewsSettings
|
|
dashboardService dashboards.DashboardService
|
|
dsUidsLookup getDatasourceUidsForDashboard
|
|
dsPermissionsService permissions.DatasourcePermissionsService
|
|
licensing models.Licensing
|
|
searchService searchV2.SearchService
|
|
}
|
|
|
|
type crawlerScheduleOptions struct {
|
|
crawlInterval time.Duration
|
|
tickerInterval time.Duration
|
|
maxCrawlDuration time.Duration
|
|
crawlerMode CrawlerMode
|
|
thumbnailKind ThumbnailKind
|
|
themes []models.Theme
|
|
auth CrawlerAuth
|
|
}
|
|
|
|
func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
|
|
lockService *serverlock.ServerLockService, renderService rendering.Service,
|
|
gl *live.GrafanaLive, store db.DB, authSetupService CrawlerAuthSetupService,
|
|
dashboardService dashboards.DashboardService, dashboardThumbsService DashboardThumbService, searchService searchV2.SearchService,
|
|
dsPermissionsService permissions.DatasourcePermissionsService, licensing models.Licensing) Service {
|
|
if !features.IsEnabled(featuremgmt.FlagDashboardPreviews) {
|
|
return &dummyService{}
|
|
}
|
|
logger := log.New("previews_service")
|
|
|
|
thumbnailRepo := newThumbnailRepo(dashboardThumbsService, searchService)
|
|
|
|
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))
|
|
}
|
|
|
|
dsUidsLookup := &dsUidsLookup{
|
|
searchService: searchService,
|
|
crawlerAuth: crawlerAuth,
|
|
features: features,
|
|
}
|
|
|
|
t := &thumbService{
|
|
licensing: licensing,
|
|
renderingService: renderService,
|
|
renderer: newSimpleCrawler(renderService, gl, thumbnailRepo, cfg, cfg.DashboardPreviews, dsUidsLookup.getDatasourceUidsForDashboard),
|
|
thumbnailRepo: thumbnailRepo,
|
|
store: store,
|
|
features: features,
|
|
lockService: lockService,
|
|
crawlLockServiceActionName: "dashboard-crawler",
|
|
searchService: searchService,
|
|
log: logger,
|
|
canRunCrawler: canRunCrawler,
|
|
dsUidsLookup: dsUidsLookup.getDatasourceUidsForDashboard,
|
|
settings: cfg.DashboardPreviews,
|
|
dsPermissionsService: dsPermissionsService,
|
|
scheduleOptions: crawlerScheduleOptions{
|
|
tickerInterval: 5 * time.Minute,
|
|
crawlInterval: cfg.DashboardPreviews.SchedulerInterval,
|
|
maxCrawlDuration: cfg.DashboardPreviews.MaxCrawlDuration,
|
|
crawlerMode: CrawlerModeThumbs,
|
|
thumbnailKind: 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 := 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, err := hs.getStatus(c, req.UID, checkSave)
|
|
if err != nil {
|
|
c.JSON(status, map[string]string{"error": err.Error()})
|
|
return nil
|
|
}
|
|
|
|
if status != 200 {
|
|
c.JSON(status, map[string]string{"error": fmt.Sprintf("code: %d", status)})
|
|
return nil
|
|
}
|
|
return req
|
|
}
|
|
|
|
type updateThumbnailStateRequest struct {
|
|
State 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, DashboardThumbnailMeta{
|
|
DashboardUID: req.UID,
|
|
OrgId: req.OrgID,
|
|
Theme: req.Theme,
|
|
Kind: 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(), DashboardThumbnailMeta{
|
|
DashboardUID: req.UID,
|
|
OrgId: req.OrgID,
|
|
Theme: req.Theme,
|
|
Kind: ThumbnailKindDefault,
|
|
})
|
|
|
|
if errors.Is(err, dashboards.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
|
|
}
|
|
|
|
if !hs.hasAccessToPreview(c, res, req) {
|
|
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) hasAccessToPreview(c *models.ReqContext, res *DashboardThumbnail, req *previewRequest) bool {
|
|
if !hs.licensing.FeatureEnabled("accesscontrol.enforcement") {
|
|
return true
|
|
}
|
|
|
|
if hs.searchService.IsDisabled() {
|
|
c.JSON(404, map[string]string{"dashboardUID": req.UID, "error": "unknown"})
|
|
return false
|
|
}
|
|
|
|
if res.DsUIDs == "" {
|
|
hs.log.Debug("dashboard preview is stale; no datasource uids", "dashboardUid", req.UID)
|
|
c.JSON(404, map[string]string{"dashboardUID": req.UID, "error": "unknown"})
|
|
return false
|
|
}
|
|
|
|
var dsUids []string
|
|
err := json.Unmarshal([]byte(res.DsUIDs), &dsUids)
|
|
|
|
if err != nil {
|
|
hs.log.Error("Error when retrieving datasource uids", "dashboardUid", req.UID, "err", err)
|
|
c.JSON(404, map[string]string{"dashboardUID": req.UID, "error": "unknown"})
|
|
return false
|
|
}
|
|
|
|
accessibleDatasources, err := hs.dsPermissionsService.FilterDatasourceUidsBasedOnQueryPermissions(c.Req.Context(), c.SignedInUser, dsUids)
|
|
if err != nil && !errors.Is(err, permissions.ErrNotImplemented) {
|
|
hs.log.Error("Error when filtering datasource uids", "dashboardUid", req.UID, "err", err)
|
|
c.JSON(500, map[string]string{"dashboardUID": req.UID, "error": "unknown"})
|
|
return false
|
|
}
|
|
|
|
if !errors.Is(err, permissions.ErrNotImplemented) {
|
|
canQueryAllDatasources := len(accessibleDatasources) == len(dsUids)
|
|
if !canQueryAllDatasources {
|
|
hs.log.Info("Denied access to dashboard preview", "dashboardUid", req.UID, "err", err, "dashboardDatasources", dsUids, "accessibleDatasources", accessibleDatasources)
|
|
c.JSON(404, map[string]string{"dashboardUID": req.UID, "error": "unknown"})
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
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(ctx)
|
|
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(ctx context.Context) dashboardPreviewsSystemRequirements {
|
|
res, err := hs.renderingService.HasCapability(ctx, 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 := io.ReadAll(file)
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
c.JSON(400, map[string]string{"error": "error reading file"})
|
|
return
|
|
}
|
|
|
|
dsUids, err := hs.dsUidsLookup(c.Req.Context(), req.UID, req.OrgID)
|
|
if err != nil {
|
|
hs.log.Error("error looking up datasource ids", "err", err, "dashboardUid", req.UID)
|
|
c.JSON(500, map[string]string{"error": "internal server error"})
|
|
return
|
|
}
|
|
|
|
_, err = hs.thumbnailRepo.saveFromBytes(c.Req.Context(), fileBytes, getMimeType(handler.Filename), DashboardThumbnailMeta{
|
|
DashboardUID: req.UID,
|
|
OrgId: req.OrgID,
|
|
Theme: req.Theme,
|
|
Kind: req.Kind,
|
|
}, DashboardVersionForManualThumbnailUpload, dsUids)
|
|
|
|
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, 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, error) {
|
|
guardian, err := guardian.NewByUID(c.Req.Context(), uid, c.OrgID, c.SignedInUser)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if checkSave {
|
|
if canSave, err := guardian.CanSave(); err != nil || !canSave {
|
|
return 403, nil // forbidden
|
|
}
|
|
return 200, nil
|
|
}
|
|
|
|
if canView, err := guardian.CanView(); err != nil || !canView {
|
|
return 403, nil // forbidden
|
|
}
|
|
|
|
return 200, nil // found and OK
|
|
}
|
|
|
|
func (hs *thumbService) runOnDemandCrawl(parentCtx context.Context, theme models.Theme, mode CrawlerMode, kind 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
|
|
}
|
|
}
|
|
}
|