Storage: support git + github backed roots (#52192)

This commit is contained in:
Ryan McKinley
2022-07-28 23:26:44 -07:00
committed by GitHub
parent e2044cde13
commit 197acd73c0
26 changed files with 1391 additions and 275 deletions
+1 -11
View File
@@ -256,17 +256,7 @@ func (hs *HTTPServer) registerRoutes() {
})
if hs.Features.IsEnabled(featuremgmt.FlagStorage) {
apiRoute.Group("/storage", func(storageRoute routing.RouteRegister) {
storageRoute.Get("/list/", routing.Wrap(hs.StorageService.List))
storageRoute.Get("/list/*", routing.Wrap(hs.StorageService.List))
storageRoute.Get("/read/*", routing.Wrap(hs.StorageService.Read))
// Write paths
storageRoute.Post("/delete/*", reqGrafanaAdmin, routing.Wrap(hs.StorageService.Delete))
storageRoute.Post("/upload", reqGrafanaAdmin, routing.Wrap(hs.StorageService.Upload))
storageRoute.Post("/createFolder", reqGrafanaAdmin, routing.Wrap(hs.StorageService.CreateFolder))
storageRoute.Post("/deleteFolder", reqGrafanaAdmin, routing.Wrap(hs.StorageService.DeleteFolder))
})
apiRoute.Group("/storage", hs.StorageService.RegisterHTTPRoutes)
}
// current org
+2 -2
View File
@@ -128,7 +128,7 @@ type HTTPServer struct {
LivePushGateway *pushhttp.Gateway
ThumbService thumbs.Service
ExportService export.ExportService
StorageService store.HTTPStorageService
StorageService store.StorageService
ContextHandler *contexthandler.ContextHandler
SQLStore sqlstore.Store
AlertEngine *alerting.AlertEngine
@@ -202,7 +202,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
pluginsUpdateChecker *updatechecker.PluginsService, searchUsersService searchusers.Service,
dataSourcesService datasources.DataSourceService, secretsService secrets.Service, queryDataService *query.Service,
ldapGroups ldap.Groups, teamGuardian teamguardian.TeamGuardian, serviceaccountsService serviceaccounts.Service,
authInfoService login.AuthInfoService, storageService store.HTTPStorageService,
authInfoService login.AuthInfoService, storageService store.StorageService,
notificationService *notifications.NotificationService, dashboardService dashboards.DashboardService,
dashboardProvisioningService dashboards.DashboardProvisioningService, folderService dashboards.FolderService,
datasourcePermissionsService permissions.DatasourcePermissionsService, alertNotificationService *alerting.AlertNotificationService,
-1
View File
@@ -200,7 +200,6 @@ var wireBasicSet = wire.NewSet(
search.ProvideService,
searchV2.ProvideService,
store.ProvideService,
store.ProvideHTTPService,
export.ProvideService,
live.ProvideService,
pushhttp.ProvideService,
+4 -3
View File
@@ -136,9 +136,10 @@ var (
State: FeatureStateAlpha,
},
{
Name: "dashboardsFromStorage",
Description: "Load dashboards from the generic storage interface",
State: FeatureStateAlpha,
Name: "dashboardsFromStorage",
Description: "Load dashboards from the generic storage interface",
State: FeatureStateAlpha,
RequiresDevMode: true, // Also a gate on automatic git storage (for now)
},
{
Name: "export",
+113 -1
View File
@@ -1,10 +1,110 @@
package store
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
)
// For now this file is stored in $GRAFANA_HOME/conf/storage.json and updated from the UI
type GlobalStorageConfig struct {
filepath string // Local file path
// Defined in grafana.ini
AllowUnsanitizedSvgUpload bool `json:"allowUnsanitizedSvgUpload"`
// Add dev environment
AddDevEnv bool `json:"addDevEnv"`
// Paths under 'root' (NOTE: this is applied to all orgs)
Roots []RootStorageConfig `json:"roots"`
}
func LoadStorageConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles) (*GlobalStorageConfig, error) {
changed := false
fpath := filepath.Join(cfg.DataPath, "storage", "storage.json")
g := &GlobalStorageConfig{}
if _, err := os.Stat(fpath); err == nil {
// nolint:gosec
// We can ignore the gosec G304 warning since the path is hardcoded above
body, err := ioutil.ReadFile(fpath)
if err != nil {
return g, err
}
err = json.Unmarshal(body, g)
if err != nil {
return g, err
}
} else {
g.AddDevEnv = true
changed = true
}
if g.Roots == nil && features.IsEnabled(featuremgmt.FlagDashboardsFromStorage) {
g.Roots = append(g.Roots, RootStorageConfig{
Type: "git",
Prefix: "it-A",
Name: "Repository that requires pull requests",
Git: &StorageGitConfig{
Remote: "https://github.com/grafana/hackathon-2022-03-git-dash-A",
Branch: "main",
Root: "dashboards", // the dashboard files
RequirePullRequest: true,
AccessToken: "$GRAFANA_STORAGE_GITHUB_ACCESS_TOKEN",
},
})
g.Roots = append(g.Roots, RootStorageConfig{
Type: "git",
Prefix: "it-B",
Name: "Another repo (can push to main)",
Git: &StorageGitConfig{
Remote: "https://github.com/grafana/hackathon-2022-03-git-dash-B",
Branch: "main",
Root: "dashboards", // the dashboard files
RequirePullRequest: false,
AccessToken: "$GRAFANA_STORAGE_GITHUB_ACCESS_TOKEN",
},
})
changed = true
}
g.filepath = fpath
// Also configured from ini files
if cfg.Storage.AllowUnsanitizedSvgUpload {
g.AllowUnsanitizedSvgUpload = true
}
// Save a template version in config
if changed && setting.Env != setting.Prod {
return g, g.save()
}
return g, nil
}
func (c *GlobalStorageConfig) save() error {
out, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
err = os.MkdirAll(filepath.Dir(c.filepath), 0700)
if err != nil {
return err
}
return ioutil.WriteFile(c.filepath, out, 0600)
}
type RootStorageConfig struct {
Type string `json:"type"`
Prefix string `json:"prefix"`
Name string `json:"name"`
Description string `json:"description"`
Disabled bool `json:"disabled,omitempty"`
// Depending on type, these will be configured
Disk *StorageLocalDiskConfig `json:"disk,omitempty"`
@@ -26,7 +126,8 @@ type StorageGitConfig struct {
// Pull interval?
// Requires pull request?
RequirePullRequest bool `json:"requirePullRequest"`
RequirePullRequest bool `json:"requirePullRequest"`
PullInterval string `json:"pullInterval"`
// SECURE JSON :grimicing:
AccessToken string `json:"accessToken,omitempty"` // Simplest auth method for github
@@ -52,3 +153,14 @@ type StorageGCSConfig struct {
CredentialsFile string `json:"credentialsFile"`
}
func newStorage(cfg RootStorageConfig, localWorkCache string) (storageRuntime, error) {
switch cfg.Type {
case rootStorageTypeDisk:
return newDiskStorage(RootStorageMeta{}, cfg), nil
case rootStorageTypeGit:
return newGitStorage(RootStorageMeta{}, cfg, localWorkCache), nil
}
return nil, fmt.Errorf("unsupported store: " + cfg.Type)
}
+182
View File
@@ -0,0 +1,182 @@
package store
import (
"context"
"fmt"
"net/url"
"strings"
"time"
"github.com/google/go-github/v45/github"
"golang.org/x/oauth2"
)
type githubHelper struct {
repoOwner string
repoName string
client *github.Client
}
func newGithubHelper(ctx context.Context, uri string, token string) (*githubHelper, error) {
v, err := url.Parse(uri)
if err != nil {
return nil, err
}
path := strings.TrimPrefix(v.Path, "/")
path = strings.TrimSuffix(path, ".git")
idx := strings.Index(path, "/")
if idx < 1 {
return nil, fmt.Errorf("invalid url")
}
if token == "" {
return nil, fmt.Errorf("unauthorized: No token present")
}
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
tc := oauth2.NewClient(ctx, ts)
return &githubHelper{
client: github.NewClient(tc),
repoOwner: path[:idx],
repoName: path[idx+1:],
}, nil
}
func (g *githubHelper) getRef(ctx context.Context, branch string) (*github.Reference, *github.Response, error) {
return g.client.Git.GetRef(ctx, g.repoOwner, g.repoName, "refs/heads/"+branch)
}
func (g *githubHelper) createRef(ctx context.Context, base string, branch string) (ref *github.Reference, rsp *github.Response, err error) {
var baseRef *github.Reference
if baseRef, rsp, err = g.client.Git.GetRef(ctx, g.repoOwner, g.repoName, "refs/heads/"+base); err != nil {
return nil, rsp, err
}
newRef := &github.Reference{
Ref: github.String("refs/heads/" + branch),
Object: &github.GitObject{SHA: baseRef.Object.SHA},
}
return g.client.Git.CreateRef(ctx, g.repoOwner, g.repoName, newRef)
}
func (g *githubHelper) getRepo(ctx context.Context) (*github.Repository, *github.Response, error) {
return g.client.Repositories.Get(ctx, g.repoOwner, g.repoName)
}
// pushCommit creates the commit in the given reference using the given tree.
func (g *githubHelper) pushCommit(ctx context.Context, ref *github.Reference, cmd *WriteValueRequest) (err error) {
// Create a tree with what to commit.
entries := []*github.TreeEntry{
{
Path: github.String(cmd.Path),
Type: github.String("blob"),
Content: github.String(string(cmd.Body)),
Mode: github.String("100644"),
},
}
tree, _, err := g.client.Git.CreateTree(ctx, g.repoOwner, g.repoName, *ref.Object.SHA, entries)
if err != nil {
return err
}
// Get the parent commit to attach the commit to.
parent, _, err := g.client.Repositories.GetCommit(ctx, g.repoOwner, g.repoName, *ref.Object.SHA, nil)
if err != nil {
return err
}
// This is not always populated, but is needed.
parent.Commit.SHA = parent.SHA
user := cmd.User
name := firstRealString(user.Name, user.Login, user.Email, "?")
email := firstRealString(user.Email, user.Login, user.Name, "?")
// Create the commit using the tree.
date := time.Now()
author := &github.CommitAuthor{
Date: &date,
Name: &name,
Email: &email,
}
commit := &github.Commit{Author: author, Message: &cmd.Message, Tree: tree, Parents: []*github.Commit{parent.Commit}}
newCommit, _, err := g.client.Git.CreateCommit(ctx, g.repoOwner, g.repoName, commit)
if err != nil {
return err
}
// Attach the commit to the main branch.
ref.Object.SHA = newCommit.SHA
_, _, err = g.client.Git.UpdateRef(ctx, g.repoOwner, g.repoName, ref, false)
return err
}
type makePRCommand struct {
title string
body string
headBranch string
baseBranch string
}
func (g *githubHelper) createPR(ctx context.Context, cmd makePRCommand) (*github.PullRequest, *github.Response, error) {
newPR := &github.NewPullRequest{
Title: &cmd.title,
Head: &cmd.headBranch,
Base: &cmd.baseBranch,
Body: &cmd.body,
MaintainerCanModify: github.Bool(true),
}
return g.client.PullRequests.Create(ctx, g.repoOwner, g.repoName, newPR)
}
// func (g *githubHelper) getPR(config *Config, prSubject string) (*github.PullRequest, error) {
// opts := github.PullRequestListOptions{}
// prs, _, err := githubClient.PullRequests.List(ctx, config.RepoOwner, config.RepoName, &opts)
// if err != nil {
// return nil, err
// }
// for _, pr := range prs {
// log.Printf("PR: %s %s", *pr.Title, prSubject)
// if *pr.Title == prSubject {
// return pr, nil
// }
// }
// return nil, nil
// }
// func (g *githubHelper) pushPR(config *Config, prSubject, prBranch, prFilename, prContent, commitMessage string) error {
// pr, err := getPR(config, prSubject)
// if err != nil {
// return err
// }
// if pr != nil {
// log.Println("Extending Existing PR", *pr.Title)
// ref, err := getRef(config, pr.GetHead().GetRef())
// if err != nil {
// return err
// }
// err = pushCommit(config, ref, prFilename, prContent, commitMessage)
// if err != nil {
// return err
// }
// } else {
// log.Println("Creating PR")
// ref, err := createRef(config, prBranch)
// if err != nil {
// return err
// }
// err = pushCommit(config, ref, prFilename, prContent, commitMessage)
// if err != nil {
// return err
// }
// pr, err = createPR(config, prSubject, prBranch)
// if err != nil {
// return err
// }
// }
// return nil
// }
+70 -35
View File
@@ -10,34 +10,14 @@ import (
"strings"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/quota"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
)
// HTTPStorageService passes raw HTTP requests to a well typed storage service
type HTTPStorageService interface {
List(c *models.ReqContext) response.Response
Read(c *models.ReqContext) response.Response
Delete(c *models.ReqContext) response.Response
DeleteFolder(c *models.ReqContext) response.Response
CreateFolder(c *models.ReqContext) response.Response
Upload(c *models.ReqContext) response.Response
}
type httpStorage struct {
store StorageService
quotaService quota.Service
}
func ProvideHTTPService(store StorageService, quotaService quota.Service) HTTPStorageService {
return &httpStorage{
store: store,
quotaService: quotaService,
}
}
func UploadErrorToStatusCode(err error) int {
switch {
case errors.Is(err, ErrStorageNotFound):
@@ -60,7 +40,37 @@ func UploadErrorToStatusCode(err error) int {
}
}
func (s *httpStorage) Upload(c *models.ReqContext) response.Response {
func (s *standardStorageService) RegisterHTTPRoutes(storageRoute routing.RouteRegister) {
storageRoute.Get("/list/", routing.Wrap(s.list))
storageRoute.Get("/list/*", routing.Wrap(s.list))
storageRoute.Get("/read/*", routing.Wrap(s.read))
storageRoute.Get("/options/*", routing.Wrap(s.getOptions))
// Write paths
reqGrafanaAdmin := middleware.ReqGrafanaAdmin
storageRoute.Post("/write/*", reqGrafanaAdmin, routing.Wrap(s.doWrite))
storageRoute.Post("/delete/*", reqGrafanaAdmin, routing.Wrap(s.doDelete))
storageRoute.Post("/upload", reqGrafanaAdmin, routing.Wrap(s.doUpload))
storageRoute.Post("/createFolder", reqGrafanaAdmin, routing.Wrap(s.doCreateFolder))
storageRoute.Post("/deleteFolder", reqGrafanaAdmin, routing.Wrap(s.doDeleteFolder))
storageRoute.Get("/config", reqGrafanaAdmin, routing.Wrap(s.getConfig))
}
func (s *standardStorageService) doWrite(c *models.ReqContext) response.Response {
scope, path := getPathAndScope(c)
cmd := &WriteValueRequest{}
if err := web.Bind(c.Req, cmd); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
cmd.Path = scope + "/" + path
rsp, err := s.write(c.Req.Context(), c.SignedInUser, cmd)
if err != nil {
return response.Error(http.StatusBadRequest, "save error", err)
}
return response.JSON(200, rsp)
}
func (s *standardStorageService) doUpload(c *models.ReqContext) response.Response {
// assumes we are only uploading to the SQL database - TODO: refactor once we introduce object stores
quotaReached, err := s.quotaService.CheckQuotaReached(c.Req.Context(), "file", nil)
if err != nil {
@@ -127,7 +137,7 @@ func (s *httpStorage) Upload(c *models.ReqContext) response.Response {
entityType = EntityTypeImage
}
err = s.store.Upload(c.Req.Context(), c.SignedInUser, &UploadRequest{
err = s.Upload(c.Req.Context(), c.SignedInUser, &UploadRequest{
Contents: data,
EntityType: entityType,
Path: path,
@@ -157,10 +167,10 @@ func getMultipartFormValue(req *http.Request, key string) string {
return v[0]
}
func (s *httpStorage) Read(c *models.ReqContext) response.Response {
func (s *standardStorageService) read(c *models.ReqContext) response.Response {
// full path is api/storage/read/upload/example.jpg, but we only want the part after read
scope, path := getPathAndScope(c)
file, err := s.store.Read(c.Req.Context(), c.SignedInUser, scope+"/"+path)
file, err := s.Read(c.Req.Context(), c.SignedInUser, scope+"/"+path)
if err != nil {
return response.Error(400, "cannot call read", err)
}
@@ -176,11 +186,20 @@ func (s *httpStorage) Read(c *models.ReqContext) response.Response {
return response.Respond(200, file.Contents)
}
func (s *httpStorage) Delete(c *models.ReqContext) response.Response {
func (s *standardStorageService) getOptions(c *models.ReqContext) response.Response {
scope, path := getPathAndScope(c)
opts, err := s.getWorkflowOptions(c.Req.Context(), c.SignedInUser, scope+"/"+path)
if err != nil {
return response.Error(400, err.Error(), err)
}
return response.JSON(200, opts)
}
func (s *standardStorageService) doDelete(c *models.ReqContext) response.Response {
// full path is api/storage/delete/upload/example.jpg, but we only want the part after upload
scope, path := getPathAndScope(c)
err := s.store.Delete(c.Req.Context(), c.SignedInUser, scope+"/"+path)
err := s.Delete(c.Req.Context(), c.SignedInUser, scope+"/"+path)
if err != nil {
return response.Error(400, "failed to delete the file: "+err.Error(), err)
}
@@ -191,7 +210,7 @@ func (s *httpStorage) Delete(c *models.ReqContext) response.Response {
})
}
func (s *httpStorage) DeleteFolder(c *models.ReqContext) response.Response {
func (s *standardStorageService) doDeleteFolder(c *models.ReqContext) response.Response {
body, err := io.ReadAll(c.Req.Body)
if err != nil {
return response.Error(500, "error reading bytes", err)
@@ -209,7 +228,7 @@ func (s *httpStorage) DeleteFolder(c *models.ReqContext) response.Response {
// full path is api/storage/delete/upload/example.jpg, but we only want the part after upload
_, path := getPathAndScope(c)
if err := s.store.DeleteFolder(c.Req.Context(), c.SignedInUser, cmd); err != nil {
if err := s.DeleteFolder(c.Req.Context(), c.SignedInUser, cmd); err != nil {
return response.Error(400, "failed to delete the folder: "+err.Error(), err)
}
@@ -220,7 +239,7 @@ func (s *httpStorage) DeleteFolder(c *models.ReqContext) response.Response {
})
}
func (s *httpStorage) CreateFolder(c *models.ReqContext) response.Response {
func (s *standardStorageService) doCreateFolder(c *models.ReqContext) response.Response {
body, err := io.ReadAll(c.Req.Body)
if err != nil {
return response.Error(500, "error reading bytes", err)
@@ -236,7 +255,7 @@ func (s *httpStorage) CreateFolder(c *models.ReqContext) response.Response {
return response.Error(400, "empty path", err)
}
if err := s.store.CreateFolder(c.Req.Context(), c.SignedInUser, cmd); err != nil {
if err := s.CreateFolder(c.Req.Context(), c.SignedInUser, cmd); err != nil {
return response.Error(400, "failed to create the folder: "+err.Error(), err)
}
@@ -247,10 +266,10 @@ func (s *httpStorage) CreateFolder(c *models.ReqContext) response.Response {
})
}
func (s *httpStorage) List(c *models.ReqContext) response.Response {
func (s *standardStorageService) list(c *models.ReqContext) response.Response {
params := web.Params(c.Req)
path := params["*"]
frame, err := s.store.List(c.Req.Context(), c.SignedInUser, path)
frame, err := s.List(c.Req.Context(), c.SignedInUser, path)
if err != nil {
return response.Error(400, "error reading path", err)
}
@@ -259,3 +278,19 @@ func (s *httpStorage) List(c *models.ReqContext) response.Response {
}
return response.JSONStreaming(http.StatusOK, frame)
}
func (s *standardStorageService) getConfig(c *models.ReqContext) response.Response {
roots := make([]RootStorageMeta, 0)
orgId := c.OrgId
t := s.tree
t.assureOrgIsInitialized(orgId)
for _, f := range t.rootsByOrgId[ac.GlobalOrgID] {
roots = append(roots, f.Meta())
}
if orgId != ac.GlobalOrgID {
for _, f := range t.rootsByOrgId[orgId] {
roots = append(roots, f.Meta())
}
}
return response.JSON(200, roots)
}
+1 -1
View File
@@ -20,7 +20,7 @@ func (s *standardStorageService) sanitizeContents(ctx context.Context, user *mod
Content: req.Contents,
})
if err != nil {
if s.cfg.allowUnsanitizedSvgUpload {
if s.cfg != nil && s.cfg.AllowUnsanitizedSvgUpload {
grafanaStorageLogger.Debug("allowing unsanitized svg upload", "filename", req.Path, "sanitizationError", err)
return req.Contents, nil
} else {
+156 -40
View File
@@ -1,17 +1,22 @@
package store
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/filestorage"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/registry"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
)
@@ -24,6 +29,7 @@ var ErrValidationFailed = errors.New("request validation failed")
var ErrFileAlreadyExists = errors.New("file exists")
var ErrStorageNotFound = errors.New("storage not found")
var ErrAccessDenied = errors.New("access denied")
var ErrOnlyDashboardSaveSupported = errors.New("only dashboard save is currently supported")
const RootPublicStatic = "public-static"
const RootResources = "resources"
@@ -52,6 +58,9 @@ type CreateFolderCmd struct {
type StorageService interface {
registry.BackgroundService
// Register the HTTP
RegisterHTTPRoutes(routing.RouteRegister)
// List folder contents
List(ctx context.Context, user *models.SignedInUser, path string) (*StorageListFrame, error)
@@ -72,20 +81,31 @@ type StorageService interface {
sanitizeUploadRequest(ctx context.Context, user *models.SignedInUser, req *UploadRequest, storagePath string) (*filestorage.UpsertFileCommand, error)
}
type storageServiceConfig struct {
allowUnsanitizedSvgUpload bool
}
type standardStorageService struct {
sql *sqlstore.SQLStore
tree *nestedTree
cfg storageServiceConfig
authService storageAuthService
sql *sqlstore.SQLStore
tree *nestedTree
cfg *GlobalStorageConfig
authService storageAuthService
quotaService quota.Service
}
func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles, cfg *setting.Cfg) StorageService {
func ProvideService(
sql *sqlstore.SQLStore,
features featuremgmt.FeatureToggles,
cfg *setting.Cfg,
quotaService quota.Service,
) StorageService {
settings, err := LoadStorageConfig(cfg, features)
if err != nil {
grafanaStorageLogger.Warn("error loading storage config", "error", err)
}
// always exists
globalRoots := []storageRuntime{
newDiskStorage(RootStorageConfig{
newDiskStorage(RootStorageMeta{
ReadOnly: true,
Builtin: true,
}, RootStorageConfig{
Prefix: RootPublicStatic,
Name: "Public static files",
Description: "Access files from the static public files",
@@ -98,14 +118,16 @@ func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles,
"/maps/",
},
},
}).setReadOnly(true).setBuiltin(true),
}),
}
// Development dashboards
if setting.Env != setting.Prod {
if settings.AddDevEnv && setting.Env != setting.Prod {
devenv := filepath.Join(cfg.StaticRootPath, "..", "devenv")
if _, err := os.Stat(devenv); !os.IsNotExist(err) {
s := newDiskStorage(RootStorageConfig{
s := newDiskStorage(RootStorageMeta{
ReadOnly: false,
}, RootStorageConfig{
Prefix: RootDevenv,
Name: "Development Environment",
Description: "Explore files within the developer environment directly",
@@ -114,8 +136,21 @@ func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles,
Roots: []string{
"/dev-dashboards/",
},
}}).setReadOnly(false)
}})
globalRoots = append(globalRoots, s)
}
}
for _, root := range settings.Roots {
if root.Prefix == "" {
grafanaStorageLogger.Warn("Invalid root configuration", "cfg", root)
continue
}
s, err := newStorage(root, filepath.Join(cfg.DataPath, "storage", "cache", root.Prefix))
if err != nil {
grafanaStorageLogger.Warn("error loading storage config", "error", err)
}
if s != nil {
globalRoots = append(globalRoots, s)
}
}
@@ -125,19 +160,21 @@ func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles,
// Custom upload files
storages = append(storages,
newSQLStorage(RootResources,
newSQLStorage(RootStorageMeta{
Builtin: true,
}, RootResources,
"Resources",
"Upload custom resource files",
&StorageSQLConfig{}, sql, orgId).
setBuiltin(true))
&StorageSQLConfig{}, sql, orgId))
// System settings
storages = append(storages,
newSQLStorage(RootSystem,
newSQLStorage(RootStorageMeta{
Builtin: true,
}, RootResources,
"System",
"Grafana system storage",
&StorageSQLConfig{}, sql, orgId).
setBuiltin(true))
&StorageSQLConfig{}, sql, orgId))
return storages
}
@@ -179,25 +216,18 @@ func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles,
return nil
}
switch storageName {
case RootDevenv:
return map[string]filestorage.PathFilter{
ActionFilesRead: allowAllPathFilter,
ActionFilesWrite: denyAllPathFilter,
ActionFilesDelete: denyAllPathFilter,
}
case RootResources:
return map[string]filestorage.PathFilter{
ActionFilesRead: allowAllPathFilter,
ActionFilesWrite: allowAllPathFilter,
ActionFilesDelete: allowAllPathFilter,
}
default:
return nil
// Admin can do anything
return map[string]filestorage.PathFilter{
ActionFilesRead: allowAllPathFilter,
ActionFilesWrite: allowAllPathFilter,
ActionFilesDelete: allowAllPathFilter,
}
})
return newStandardStorageService(sql, globalRoots, initializeOrgStorages, authService, cfg)
s := newStandardStorageService(sql, globalRoots, initializeOrgStorages, authService, cfg)
s.quotaService = quotaService
s.cfg = settings
return s
}
func createSystemBrandingPathFilter() filestorage.PathFilter {
@@ -208,7 +238,13 @@ func createSystemBrandingPathFilter() filestorage.PathFilter {
nil)
}
func newStandardStorageService(sql *sqlstore.SQLStore, globalRoots []storageRuntime, initializeOrgStorages func(orgId int64) []storageRuntime, authService storageAuthService, cfg *setting.Cfg) *standardStorageService {
func newStandardStorageService(
sql *sqlstore.SQLStore,
globalRoots []storageRuntime,
initializeOrgStorages func(orgId int64) []storageRuntime,
authService storageAuthService,
cfg *setting.Cfg,
) *standardStorageService {
rootsByOrgId := make(map[int64][]storageRuntime)
rootsByOrgId[ac.GlobalOrgID] = globalRoots
@@ -221,9 +257,6 @@ func newStandardStorageService(sql *sqlstore.SQLStore, globalRoots []storageRunt
sql: sql,
tree: res,
authService: authService,
cfg: storageServiceConfig{
allowUnsanitizedSvgUpload: cfg.Storage.AllowUnsanitizedSvgUpload,
},
}
}
@@ -377,3 +410,86 @@ func (s *standardStorageService) Delete(ctx context.Context, user *models.Signed
}
return nil
}
func (s *standardStorageService) write(ctx context.Context, user *models.SignedInUser, req *WriteValueRequest) (*WriteValueResponse, error) {
guardian := s.authService.newGuardian(ctx, user, getFirstSegment(req.Path))
if !guardian.canWrite(req.Path) {
return nil, ErrAccessDenied
}
root, storagePath := s.tree.getRoot(getOrgId(user), req.Path)
if root == nil {
return nil, ErrStorageNotFound
}
if root.Meta().ReadOnly {
return nil, ErrUnsupportedStorage
}
// not svg!
if req.EntityType != EntityTypeDashboard {
return nil, ErrOnlyDashboardSaveSupported
}
// Save pretty JSON
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, req.Body, "", " "); err != nil {
return nil, err
}
req.Body = prettyJSON.Bytes()
// Modify the save request
req.Path = storagePath
req.User = user
return root.Write(ctx, req)
}
type workflowInfo struct {
Type WriteValueWorkflow `json:"value"` // value matches selectable value
Label string `json:"label"`
Description string `json:"description,omitempty"`
}
type optionInfo struct {
Path string `json:"path,omitempty"`
Workflows []workflowInfo `json:"workflows"`
}
func (s *standardStorageService) getWorkflowOptions(ctx context.Context, user *models.SignedInUser, path string) (optionInfo, error) {
options := optionInfo{
Path: path,
Workflows: make([]workflowInfo, 0),
}
scope, _ := splitFirstSegment(path)
root, _ := s.tree.getRoot(user.OrgId, scope)
if root == nil {
return options, fmt.Errorf("can not read")
}
meta := root.Meta()
if meta.Config.Type == rootStorageTypeGit && meta.Config.Git != nil {
cfg := meta.Config.Git
options.Workflows = append(options.Workflows, workflowInfo{
Type: WriteValueWorkflow_PR,
Label: "Create pull request",
Description: "Create a new upstream pull request",
})
if !cfg.RequirePullRequest {
options.Workflows = append(options.Workflows, workflowInfo{
Type: WriteValueWorkflow_Push,
Label: "Push to " + cfg.Branch,
Description: "Push commit to upstrem repository",
})
}
} else if meta.ReadOnly {
// nothing?
} else {
options.Workflows = append(options.Workflows, workflowInfo{
Type: WriteValueWorkflow_Save,
Label: "Save",
Description: "Save directly",
})
}
return options, nil
}
+22 -14
View File
@@ -42,20 +42,24 @@ var (
}
})
publicRoot, _ = filepath.Abs("../../../public")
publicStaticFilesStorage = newDiskStorage(RootStorageConfig{
Prefix: "public",
Name: "Public static files",
Disk: &StorageLocalDiskConfig{
Path: publicRoot,
Roots: []string{
"/testdata/",
"/img/icons/",
"/img/bg/",
"/gazetteer/",
"/maps/",
"/upload/",
},
}}).setReadOnly(true).setBuiltin(true)
publicStaticFilesStorage = newDiskStorage(
RootStorageMeta{
Builtin: true,
ReadOnly: true,
}, RootStorageConfig{
Prefix: "public",
Name: "Public static files",
Disk: &StorageLocalDiskConfig{
Path: publicRoot,
Roots: []string{
"/testdata/",
"/img/icons/",
"/img/bg/",
"/gazetteer/",
"/maps/",
"/upload/",
},
}})
)
func TestListFiles(t *testing.T) {
@@ -96,6 +100,7 @@ func setupUploadStore(t *testing.T, authService storageAuthService) (StorageServ
storageName := "resources"
mockStorage := &filestorage.MockFileStorage{}
sqlStorage := newSQLStorage(
RootStorageMeta{},
storageName, "Testing upload", "dummy descr",
&StorageSQLConfig{},
sqlstore.InitTestDB(t),
@@ -109,6 +114,9 @@ func setupUploadStore(t *testing.T, authService storageAuthService) (StorageServ
store := newStandardStorageService(sqlstore.InitTestDB(t), []storageRuntime{sqlStorage}, func(orgId int64) []storageRuntime {
return make([]storageRuntime, 0)
}, authService, cfg)
store.cfg = &GlobalStorageConfig{
AllowUnsanitizedSvgUpload: true,
}
return store, mockStorage, storageName
}
+17 -9
View File
@@ -12,23 +12,22 @@ import (
const rootStorageTypeDisk = "disk"
type rootStorageDisk struct {
baseStorageRuntime
var _ storageRuntime = &rootStorageDisk{}
type rootStorageDisk struct {
settings *StorageLocalDiskConfig
meta RootStorageMeta
store filestorage.FileStorage
}
func newDiskStorage(scfg RootStorageConfig) *rootStorageDisk {
func newDiskStorage(meta RootStorageMeta, scfg RootStorageConfig) *rootStorageDisk {
cfg := scfg.Disk
if cfg == nil {
cfg = &StorageLocalDiskConfig{}
scfg.Disk = cfg
}
scfg.Type = rootStorageTypeDisk
meta := RootStorageMeta{
Config: scfg,
}
meta.Config = scfg
if scfg.Prefix == "" {
meta.Notice = append(meta.Notice, data.Notice{
Severity: data.NoticeSeverityError,
@@ -42,7 +41,9 @@ func newDiskStorage(scfg RootStorageConfig) *rootStorageDisk {
})
}
s := &rootStorageDisk{}
s := &rootStorageDisk{
settings: cfg,
}
if meta.Notice == nil {
path := fmt.Sprintf("file://%s", cfg.Path)
@@ -63,10 +64,17 @@ func newDiskStorage(scfg RootStorageConfig) *rootStorageDisk {
}
s.meta = meta
s.settings = cfg
return s
}
func (s *rootStorageDisk) Meta() RootStorageMeta {
return s.meta
}
func (s *rootStorageDisk) Store() filestorage.FileStorage {
return s.store
}
func (s *rootStorageDisk) Sync() error {
return nil // already in sync
}
+387
View File
@@ -0,0 +1,387 @@
package store
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/infra/filestorage"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
"gocloud.dev/blob"
)
const rootStorageTypeGit = "git"
var _ storageRuntime = &rootStorageGit{}
type rootStorageGit struct {
settings *StorageGitConfig
repo *git.Repository
root string // repostitory root
github *githubHelper
meta RootStorageMeta
store filestorage.FileStorage
}
func newGitStorage(meta RootStorageMeta, scfg RootStorageConfig, localWorkCache string) *rootStorageGit {
cfg := scfg.Git
if cfg == nil {
cfg = &StorageGitConfig{}
}
scfg.Type = rootStorageTypeGit
scfg.GCS = nil
scfg.SQL = nil
scfg.S3 = nil
scfg.Git = cfg
meta.Config = scfg
if scfg.Prefix == "" {
meta.Notice = append(meta.Notice, data.Notice{
Severity: data.NoticeSeverityError,
Text: "Missing prefix",
})
}
if cfg.Remote == "" {
meta.Notice = append(meta.Notice, data.Notice{
Severity: data.NoticeSeverityError,
Text: "Missing remote path configuration",
})
}
if len(localWorkCache) < 2 {
meta.Notice = append(meta.Notice, data.Notice{
Severity: data.NoticeSeverityError,
Text: "Invalid local root folder",
})
}
s := &rootStorageGit{
settings: cfg,
}
if meta.Notice == nil {
err := os.MkdirAll(localWorkCache, 0750)
if err != nil {
meta.Notice = append(meta.Notice, data.Notice{
Severity: data.NoticeSeverityError,
Text: err.Error(),
})
}
}
if scfg.Disabled {
meta.Notice = append(meta.Notice, data.Notice{
Severity: data.NoticeSeverityWarning,
Text: "folder is disabled (in configuration)",
})
} else if setting.Env == setting.Prod {
meta.Notice = append(meta.Notice, data.Notice{
Severity: data.NoticeSeverityError,
Text: "git is only supported in dev mode (for now)",
})
}
if meta.Notice == nil {
repo, err := git.PlainOpen(localWorkCache)
if errors.Is(err, git.ErrRepositoryNotExists) {
repo, err = git.PlainClone(localWorkCache, false, &git.CloneOptions{
URL: cfg.Remote,
Progress: os.Stdout,
//Depth: 1,
//SingleBranch: true,
})
}
if err != nil {
meta.Notice = append(meta.Notice, data.Notice{
Severity: data.NoticeSeverityError,
Text: err.Error(),
})
}
if err == nil {
p := localWorkCache
if cfg.Root != "" {
p = filepath.Join(p, cfg.Root)
}
path := fmt.Sprintf("file://%s", p)
bucket, err := blob.OpenBucket(context.Background(), path)
if err != nil {
grafanaStorageLogger.Warn("error loading storage", "prefix", scfg.Prefix, "err", err)
meta.Notice = append(meta.Notice, data.Notice{
Severity: data.NoticeSeverityError,
Text: "Failed to initialize storage",
})
} else {
s.store = filestorage.NewCdkBlobStorage(
grafanaStorageLogger,
bucket, "", nil)
meta.Ready = true // exists!
s.root = p
token := cfg.AccessToken
if strings.HasPrefix(token, "$") {
token = os.Getenv(token[1:])
if token == "" {
meta.Notice = append(meta.Notice, data.Notice{
Severity: data.NoticeSeverityError,
Text: "Unable to find token environment variable: " + cfg.AccessToken,
})
}
}
if token != "" {
s.github, err = newGithubHelper(context.Background(), cfg.Remote, token)
if err != nil {
meta.Notice = append(meta.Notice, data.Notice{
Severity: data.NoticeSeverityError,
Text: "error creating github client: " + err.Error(),
})
s.github = nil
} else {
ghrepo, _, err := s.github.getRepo(context.Background())
if err != nil {
meta.Notice = append(meta.Notice, data.Notice{
Severity: data.NoticeSeverityError,
Text: err.Error(),
})
s.github = nil
} else {
grafanaStorageLogger.Info("default branch", "branch", *ghrepo.DefaultBranch)
}
}
}
}
}
s.repo = repo
// Try pulling after init
if s.repo != nil && !scfg.Disabled {
err = s.Sync()
if err != nil {
meta.Notice = append(meta.Notice, data.Notice{
Severity: data.NoticeSeverityError,
Text: "unable to pull: " + err.Error(),
})
} else if cfg.PullInterval != "" {
t, err := time.ParseDuration(cfg.PullInterval)
if err != nil {
meta.Notice = append(meta.Notice, data.Notice{
Severity: data.NoticeSeverityError,
Text: "Invalid pull interval " + cfg.PullInterval,
})
} else {
ticker := time.NewTicker(t)
go func() {
for range ticker.C {
grafanaStorageLogger.Info("try git pull", "branch", s.settings.Remote)
err = s.Sync()
if err != nil {
grafanaStorageLogger.Info("error pulling", "error", err)
}
}
}()
}
}
}
}
s.meta = meta
return s
}
func (s *rootStorageGit) Meta() RootStorageMeta {
return s.meta
}
func (s *rootStorageGit) Store() filestorage.FileStorage {
return s.store
}
func (s *rootStorageGit) Pull() error {
w, err := s.repo.Worktree()
if err != nil {
return err
}
err = w.Pull(&git.PullOptions{
// Depth: 1,
//SingleBranch: true,
})
if err != nil {
return err
}
return nil
}
func (s *rootStorageGit) Write(ctx context.Context, cmd *WriteValueRequest) (*WriteValueResponse, error) {
if s.github == nil {
return nil, fmt.Errorf("github client not initialized")
}
// Write to the correct subfolder
if s.settings.Root != "" {
cmd.Path = s.settings.Root + cmd.Path
}
if cmd.Workflow == WriteValueWorkflow_PR {
prcmd := makePRCommand{
baseBranch: s.settings.Branch,
headBranch: fmt.Sprintf("grafana_ui_%d", time.Now().UnixMilli()),
title: cmd.Title,
body: cmd.Message,
}
res := &WriteValueResponse{
Branch: prcmd.headBranch,
}
ref, _, err := s.github.createRef(ctx, prcmd.baseBranch, prcmd.headBranch)
if err != nil {
res.Code = 500
res.Message = "unable to create branch"
return res, nil
}
err = s.github.pushCommit(ctx, ref, cmd)
if err != nil {
res.Code = 500
res.Message = fmt.Sprintf("error creating commit: %s", err.Error())
return res, nil
}
if prcmd.title == "" {
prcmd.title = "Dashboard save: " + time.Now().String()
}
if prcmd.body == "" {
prcmd.body = "Dashboard save: " + time.Now().String()
}
pr, _, err := s.github.createPR(ctx, prcmd)
if err != nil {
res.Code = 500
res.Message = "error creating PR: " + err.Error()
return res, nil
}
res.Code = 200
res.URL = pr.GetHTMLURL()
res.Pending = true
res.Hash = *ref.Object.SHA
res.Branch = prcmd.headBranch
return res, nil
}
// Push to remote branch (save)
if cmd.Workflow == WriteValueWorkflow_Push || true {
res := &WriteValueResponse{
Branch: s.settings.Branch,
}
ref, _, err := s.github.getRef(ctx, s.settings.Branch)
if err != nil {
res.Code = 500
res.Message = "unable to create branch"
return res, nil
}
err = s.github.pushCommit(ctx, ref, cmd)
if err != nil {
res.Code = 500
res.Message = "error creating commit"
return res, nil
}
ref, _, _ = s.github.getRef(ctx, s.settings.Branch)
if ref != nil {
res.Hash = *ref.Object.SHA
res.URL = ref.GetURL()
}
err = s.Pull()
if err != nil {
res.Message = "error pulling: " + err.Error()
}
res.Code = 200
return res, nil
}
rel := cmd.Path
if s.meta.Config.Git.Root != "" {
rel = filepath.Join(s.meta.Config.Git.Root, cmd.Path)
}
fpath := filepath.Join(s.root, rel)
err := os.WriteFile(fpath, cmd.Body, 0644)
if err != nil {
return nil, err
}
w, err := s.repo.Worktree()
if err != nil {
return nil, err
}
// The file we just wrote
_, err = w.Add(rel)
if err != nil {
return nil, err
}
msg := cmd.Message
if msg == "" {
msg = "changes from grafana ui"
}
user := cmd.User
if user == nil {
user = &models.SignedInUser{}
}
hash, err := w.Commit(msg, &git.CommitOptions{
Author: &object.Signature{
Name: firstRealString(user.Name, user.Login, user.Email, "?"),
Email: firstRealString(user.Email, user.Login, user.Name, "?"),
When: time.Now(),
},
})
if err != nil {
return nil, err
}
grafanaStorageLogger.Info("made commit", "hash", hash)
// err = s.repo.Push(&git.PushOptions{
// InsecureSkipTLS: true,
// })
return &WriteValueResponse{
Hash: hash.String(),
Message: "made commit",
}, nil
}
func (s *rootStorageGit) Sync() error {
grafanaStorageLogger.Info("GIT PULL", "remote", s.settings.Remote)
err := s.Pull()
if err != nil {
if err.Error() == "already up-to-date" {
return nil
}
}
return err
}
func firstRealString(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return "?"
}
+19 -11
View File
@@ -13,10 +13,12 @@ import (
const rootStorageTypeSQL = "sql"
type rootStorageSQL struct {
baseStorageRuntime
var _ storageRuntime = &rootStorageSQL{}
type rootStorageSQL struct {
settings *StorageSQLConfig
meta RootStorageMeta
store filestorage.FileStorage
}
// getDbRootFolder creates a DB path prefix for a given storage name and orgId.
@@ -28,19 +30,17 @@ func getDbStoragePathPrefix(orgId int64, storageName string) string {
return filestorage.Join(fmt.Sprintf("%d", orgId), storageName+filestorage.Delimiter)
}
func newSQLStorage(prefix string, name string, descr string, cfg *StorageSQLConfig, sql *sqlstore.SQLStore, orgId int64) *rootStorageSQL {
func newSQLStorage(meta RootStorageMeta, prefix string, name string, descr string, cfg *StorageSQLConfig, sql *sqlstore.SQLStore, orgId int64) *rootStorageSQL {
if cfg == nil {
cfg = &StorageSQLConfig{}
}
meta := RootStorageMeta{
Config: RootStorageConfig{
Type: rootStorageTypeSQL,
Prefix: prefix,
Name: name,
Description: descr,
SQL: cfg,
},
meta.Config = RootStorageConfig{
Type: rootStorageTypeSQL,
Prefix: prefix,
Name: name,
Description: descr,
SQL: cfg,
}
if prefix == "" {
@@ -78,6 +78,14 @@ func (s *rootStorageSQL) Write(ctx context.Context, cmd *WriteValueRequest) (*Wr
return &WriteValueResponse{Code: 200}, nil
}
func (s *rootStorageSQL) Meta() RootStorageMeta {
return s.meta
}
func (s *rootStorageSQL) Store() filestorage.FileStorage {
return s.store
}
func (s *rootStorageSQL) Sync() error {
return nil // already in sync
}
+13 -16
View File
@@ -2,6 +2,7 @@ package store
import (
"context"
"fmt"
"sync"
"github.com/grafana/grafana-plugin-sdk-go/data"
@@ -77,12 +78,15 @@ func (t *nestedTree) GetFile(ctx context.Context, orgId int64, path string) (*fi
if path == "" {
return nil, nil // not found
}
root, path := t.getRoot(orgId, path)
if root == nil {
return nil, nil // not found (or not ready)
}
return root.Store().Get(ctx, path)
store := root.Store()
if store == nil {
return nil, fmt.Errorf("store not ready")
}
return store.Get(ctx, path)
}
func (t *nestedTree) ListFolder(ctx context.Context, orgId int64, path string, accessFilter filestorage.PathFilter) (*StorageListFrame, error) {
@@ -98,26 +102,17 @@ func (t *nestedTree) ListFolder(ctx context.Context, orgId int64, path string, a
names := data.NewFieldFromFieldType(data.FieldTypeString, count)
title := data.NewFieldFromFieldType(data.FieldTypeString, count)
descr := data.NewFieldFromFieldType(data.FieldTypeString, count)
types := data.NewFieldFromFieldType(data.FieldTypeString, count)
readOnly := data.NewFieldFromFieldType(data.FieldTypeBool, count)
builtIn := data.NewFieldFromFieldType(data.FieldTypeBool, count)
mtype := data.NewFieldFromFieldType(data.FieldTypeString, count)
title.Name = titleListFrameField
names.Name = nameListFrameField
descr.Name = descriptionListFrameField
mtype.Name = mediaTypeListFrameField
types.Name = storageTypeListFrameField
readOnly.Name = readOnlyListFrameField
builtIn.Name = builtInListFrameField
for _, f := range t.rootsByOrgId[ac.GlobalOrgID] {
meta := f.Meta()
names.Set(idx, meta.Config.Prefix)
title.Set(idx, meta.Config.Name)
descr.Set(idx, meta.Config.Description)
mtype.Set(idx, "directory")
types.Set(idx, meta.Config.Type)
readOnly.Set(idx, meta.ReadOnly)
builtIn.Set(idx, meta.Builtin)
idx++
}
if orgId != ac.GlobalOrgID {
@@ -127,14 +122,11 @@ func (t *nestedTree) ListFolder(ctx context.Context, orgId int64, path string, a
title.Set(idx, meta.Config.Name)
descr.Set(idx, meta.Config.Description)
mtype.Set(idx, "directory")
types.Set(idx, meta.Config.Type)
readOnly.Set(idx, meta.ReadOnly)
builtIn.Set(idx, meta.Builtin)
idx++
}
}
frame := data.NewFrame("", names, title, descr, mtype, types, readOnly, builtIn)
frame := data.NewFrame("", names, title, descr, mtype)
frame.SetMeta(&data.FrameMeta{
Type: data.FrameTypeDirectoryListing,
})
@@ -146,7 +138,12 @@ func (t *nestedTree) ListFolder(ctx context.Context, orgId int64, path string, a
return nil, nil // not found (or not ready)
}
listResponse, err := root.Store().List(ctx, path, nil, &filestorage.ListOptions{
store := root.Store()
if store == nil {
return nil, fmt.Errorf("store not ready")
}
listResponse, err := store.List(ctx, path, nil, &filestorage.ListOptions{
Recursive: false,
WithFolders: true,
WithFiles: true,
+15 -43
View File
@@ -9,13 +9,22 @@ import (
"github.com/grafana/grafana/pkg/models"
)
type WriteValueWorkflow = string
var (
WriteValueWorkflow_Save WriteValueWorkflow = "save" // or empty
WriteValueWorkflow_PR WriteValueWorkflow = "pr"
WriteValueWorkflow_Push WriteValueWorkflow = "push"
)
type WriteValueRequest struct {
Path string
User *models.SignedInUser
Body json.RawMessage `json:"body,omitempty"`
Message string `json:"message,omitempty"`
Title string `json:"title,omitempty"` // For PRs
Action string `json:"action,omitempty"` // pr | save
User *models.SignedInUser
Path string // added from URL
EntityType EntityType `json:"kind,omitempty"` // for now only dashboard
Body json.RawMessage `json:"body,omitempty"`
Message string `json:"message,omitempty"`
Title string `json:"title,omitempty"` // For PRs
Workflow WriteValueWorkflow `json:"workflow,omitempty"` // save | pr | push
}
type WriteValueResponse struct {
@@ -48,40 +57,6 @@ type storageRuntime interface {
Write(ctx context.Context, cmd *WriteValueRequest) (*WriteValueResponse, error)
}
type baseStorageRuntime struct {
meta RootStorageMeta
store filestorage.FileStorage
}
func (t *baseStorageRuntime) Meta() RootStorageMeta {
return t.meta
}
func (t *baseStorageRuntime) Store() filestorage.FileStorage {
return t.store
}
func (t *baseStorageRuntime) Sync() error {
return nil
}
func (t *baseStorageRuntime) Write(ctx context.Context, cmd *WriteValueRequest) (*WriteValueResponse, error) {
return &WriteValueResponse{
Code: 500,
Message: "unsupportted operation (base)",
}, nil
}
func (t *baseStorageRuntime) setReadOnly(val bool) *baseStorageRuntime {
t.meta.ReadOnly = val
return t
}
func (t *baseStorageRuntime) setBuiltin(val bool) *baseStorageRuntime {
t.meta.Builtin = val
return t
}
type RootStorageMeta struct {
ReadOnly bool `json:"editable,omitempty"`
Builtin bool `json:"builtin,omitempty"`
@@ -100,9 +75,6 @@ const (
nameListFrameField = "name"
descriptionListFrameField = "description"
mediaTypeListFrameField = "mediaType"
storageTypeListFrameField = "storageType"
readOnlyListFrameField = "readOnly"
builtInListFrameField = "builtIn"
sizeListFrameField = "size"
)