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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1391 additions and 275 deletions

View File

@ -5694,12 +5694,9 @@ exports[`better eslint`] = {
"public/app/features/storage/storage.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"],
[0, 0, 0, "Do not use any type assertions.", "4"],
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
[0, 0, 0, "Do not use any type assertions.", "7"]
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
],
"public/app/features/teams/CreateTeam.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
@ -8528,8 +8525,10 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/panel/barchart/BarChartPanel.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"]
],
"public/app/plugins/panel/barchart/TickSpacingEditor.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]

4
go.mod
View File

@ -42,7 +42,7 @@ require (
github.com/go-sql-driver/mysql v1.6.0
github.com/go-stack/stack v1.8.0
github.com/gobwas/glob v0.2.3
github.com/gofrs/uuid v4.0.0+incompatible
github.com/gofrs/uuid v4.0.0+incompatible // indirect
github.com/gogo/protobuf v1.3.2
github.com/golang/mock v1.6.0
github.com/golang/snappy v0.0.4
@ -245,6 +245,7 @@ require (
github.com/blugelabs/bluge_segment_api v0.2.0
github.com/getkin/kin-openapi v0.94.0
github.com/golang-migrate/migrate/v4 v4.7.0
github.com/google/go-github/v45 v45.2.0
github.com/grafana/dskit v0.0.0-20211011144203-3a88ec0b675f
github.com/grafana/thema v0.0.0-20220726124731-b8017e278cc1
go.etcd.io/etcd/api/v3 v3.5.4
@ -258,6 +259,7 @@ require (
cloud.google.com/go v0.100.2 // indirect
github.com/armon/go-metrics v0.3.10 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/gosimple/unidecode v1.0.1 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect

5
go.sum
View File

@ -1213,8 +1213,11 @@ github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-github/v45 v45.2.0 h1:5oRLszbrkvxDDqBCNj2hjDZMKmvexaZ1xw/FCD+K3FI=
github.com/google/go-github/v45 v45.2.0/go.mod h1:FObaZJEDSTa/WGCzZ2Z3eoCDXWJKMenWWTrd8jrta28=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/go-replayers/grpcreplay v1.1.0/go.mod h1:qzAvJ8/wi57zq7gWqaE6AwLM6miiXUQwP1S+I9icmhk=
github.com/google/go-replayers/httpreplay v1.1.1/go.mod h1:gN9GeLIs7l6NUoVaSSnv2RiqK1NiwAmD0MrKeC9IIks=
github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI=

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

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,

View File

@ -200,7 +200,6 @@ var wireBasicSet = wire.NewSet(
search.ProvideService,
searchV2.ProvideService,
store.ProvideService,
store.ProvideHTTPService,
export.ProvideService,
live.ProvideService,
pushhttp.ProvideService,

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",

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)
}

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
// }

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)
}

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 {

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
}

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
}

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
}

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 "?"
}

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
}

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,

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"
)

View File

@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react';
import { useAsync } from 'react-use';
import { config } from '@grafana/runtime';
import { Drawer, Spinner, Tab, TabsBar } from '@grafana/ui';
import { backendSrv } from 'app/core/services/backend_srv';
@ -11,14 +12,16 @@ import { SaveDashboardErrorProxy } from './SaveDashboardErrorProxy';
import { SaveDashboardAsForm } from './forms/SaveDashboardAsForm';
import { SaveDashboardForm } from './forms/SaveDashboardForm';
import { SaveProvisionedDashboardForm } from './forms/SaveProvisionedDashboardForm';
import { SaveToStorageForm } from './forms/SaveToStorageForm';
import { SaveDashboardData, SaveDashboardModalProps, SaveDashboardOptions } from './types';
import { useDashboardSave } from './useDashboardSave';
export const SaveDashboardDrawer = ({ dashboard, onDismiss, onSaveSuccess, isCopy }: SaveDashboardModalProps) => {
const [options, setOptions] = useState<SaveDashboardOptions>({});
const isProvisioned = dashboard.meta.provisioned;
const isNew = dashboard.version === 0;
const isFromStorage = config.featureToggles.dashboardsFromStorage && dashboard.uid.indexOf('/') > 0;
const isProvisioned = dashboard.meta.provisioned && !isFromStorage;
const isNew = dashboard.version === 0 && !isFromStorage;
const previous = useAsync(async () => {
if (isNew) {
@ -78,6 +81,22 @@ export const SaveDashboardDrawer = ({ dashboard, onDismiss, onSaveSuccess, isCop
);
}
if (isFromStorage) {
return (
<SaveToStorageForm
dashboard={dashboard}
saveModel={data}
onCancel={onDismiss}
onSuccess={onSuccess}
onSubmit={onDashboardSave}
options={options}
onOptionsChange={setOptions}
isNew={isNew}
isCopy={isCopy}
/>
);
}
if (isNew || isCopy) {
return (
<SaveDashboardAsForm

View File

@ -10,7 +10,7 @@ interface FormDTO {
message: string;
}
type Props = {
export type SaveProps = {
dashboard: DashboardModel; // original
saveModel: SaveDashboardData; // already cloned
onCancel: () => void;
@ -28,7 +28,7 @@ export const SaveDashboardForm = ({
onCancel,
onSuccess,
onOptionsChange,
}: Props) => {
}: SaveProps) => {
const hasTimeChanged = useMemo(() => dashboard.hasTimeChanged(), [dashboard]);
const hasVariableChanged = useMemo(() => dashboard.hasVariableValuesChanged(), [dashboard]);

View File

@ -0,0 +1,222 @@
import React, { useMemo, useState } from 'react';
import { useAsync } from 'react-use';
import { selectors } from '@grafana/e2e-selectors';
import { locationService } from '@grafana/runtime';
import {
Button,
Checkbox,
Field,
Form,
HorizontalGroup,
Input,
RadioButtonGroup,
Spinner,
Stack,
TextArea,
} from '@grafana/ui';
import { getGrafanaStorage } from 'app/features/storage/storage';
import { ItemOptions, WorkflowID, WriteValueResponse } from 'app/features/storage/types';
import { SaveProps } from './SaveDashboardForm';
interface FormDTO {
title?: string;
message: string;
}
interface Props extends SaveProps {
isNew?: boolean;
isCopy?: boolean;
}
export function SaveToStorageForm(props: Props) {
const { dashboard, saveModel, onSubmit, onCancel, onSuccess, onOptionsChange, isNew, isCopy } = props;
const hasTimeChanged = useMemo(() => dashboard.hasTimeChanged(), [dashboard]);
const hasVariableChanged = useMemo(() => dashboard.hasVariableValuesChanged(), [dashboard]);
const [saving, setSaving] = useState(false);
const [response, setResponse] = useState<WriteValueResponse>();
const [path, setPath] = useState(dashboard.uid);
const [workflow, setWorkflow] = useState(WorkflowID.Save);
const saveText = useMemo(() => {
switch (workflow) {
case WorkflowID.PR:
return 'Create PR';
case WorkflowID.Push:
return 'Push';
}
console.log('???', workflow);
return 'Save';
}, [workflow]);
const item = useAsync(async () => {
const opts = await getGrafanaStorage().getOptions(dashboard.uid);
setWorkflow(opts.workflows[0]?.value ?? WorkflowID.Save);
return opts;
}, [dashboard.uid]);
if (item.error) {
return <div>Error loading workflows</div>;
}
if (item.loading || !item.value) {
return <Spinner />;
}
if (response) {
return (
<div>
{response.url && (
<div>
<h2>View pull request</h2>
<a href={response.url}>{response.url}</a>
</div>
)}
<pre>{JSON.stringify(response)}</pre>
<HorizontalGroup>
<Button variant="secondary" onClick={onCancel}>
Close
</Button>
</HorizontalGroup>
</div>
);
}
let options = props.options;
const workflows = item.value?.workflows ?? [];
const canSave = saveModel.hasChanges || isNew || isCopy;
return (
<Form
onSubmit={async (data: FormDTO) => {
if (!onSubmit) {
return;
}
setSaving(true);
let uid = saveModel.clone.uid;
if (isNew || isCopy) {
uid = path;
if (!uid.endsWith('-dash.json')) {
uid += '-dash.json';
}
}
const rsp = await getGrafanaStorage().write(uid, {
body: saveModel.clone,
kind: 'dashboard',
title: data.title,
message: data.message,
workflow: workflow,
});
console.log('GOT', rsp);
if (rsp.code === 200) {
if (options.saveVariables) {
dashboard.resetOriginalVariables();
}
if (options.saveTimerange) {
dashboard.resetOriginalTime();
}
if (!rsp.pending) {
// should close
onSuccess();
// Need to update the URL
if (isNew || isCopy) {
locationService.push(`/g/${uid}`);
}
}
} else {
setSaving(false);
}
setResponse(rsp);
}}
>
{({ register, errors }) => (
<Stack direction="column" gap={1}>
<Stack direction="column" gap={1}>
{hasTimeChanged && (
<Checkbox
checked={!!options.saveTimerange}
onChange={() =>
onOptionsChange({
...options,
saveTimerange: !options.saveTimerange,
})
}
label="Save current time range as dashboard default"
aria-label={selectors.pages.SaveDashboardModal.saveTimerange}
/>
)}
{hasVariableChanged && (
<Checkbox
checked={!!options.saveVariables}
onChange={() =>
onOptionsChange({
...options,
saveVariables: !options.saveVariables,
})
}
label="Save current variable values as dashboard default"
aria-label={selectors.pages.SaveDashboardModal.saveVariables}
/>
)}
</Stack>
{(isNew || isCopy) && (
<Field label="Path">
<Input
value={path ?? ''}
required
autoFocus
placeholder="Full path (todo, help validate)"
onChange={(v) => setPath(v.currentTarget.value)}
/>
</Field>
)}
{!isJustSave(item.value) && (
<Field label="Workflow">
<RadioButtonGroup value={workflow} options={workflows} onChange={setWorkflow} />
</Field>
)}
{workflow === WorkflowID.PR && (
<Field label="PR Title">
<Input {...register('title')} required placeholder="Enter a PR title" autoFocus />
</Field>
)}
<Field label="Message">
<TextArea {...register('message')} placeholder="Add a note to describe your changes." rows={5} />
</Field>
<HorizontalGroup>
<Button variant="secondary" onClick={onCancel} fill="outline">
Cancel
</Button>
<Button
type="submit"
disabled={!canSave}
icon={saving ? 'fa fa-spinner' : undefined}
aria-label={selectors.pages.SaveDashboardModal.save}
>
{saveText}
</Button>
{!canSave && <div>No changes to save</div>}
</HorizontalGroup>
</Stack>
)}
</Form>
);
}
function isJustSave(opts: ItemOptions): boolean {
if (opts.workflows.length === 1) {
return opts.workflows.find((v) => v.value === WorkflowID.Save) != null;
}
return false;
}

View File

@ -4,7 +4,6 @@ import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { getGrafanaStorage } from 'app/features/storage/storage';
import { DashboardDTO, FolderInfo, PermissionLevelString, ThunkResult } from 'app/types';
import { LibraryElementExport } from '../../dashboard/components/DashExportModal/DashboardExporter';
@ -267,10 +266,6 @@ export function deleteFoldersAndDashboards(folderUids: string[], dashboardUids:
export function saveDashboard(options: SaveDashboardCommand) {
dashboardWatcher.ignoreNextSave();
if (options.dashboard.uid.indexOf('/') > 0) {
return getGrafanaStorage().saveDashboard(options);
}
return getBackendSrv().post('/api/dashboards/db/', {
dashboard: options.dashboard,
message: options.message ?? '',

View File

@ -1,28 +1,33 @@
import { css } from '@emotion/css';
import React, { useMemo, useState } from 'react';
import { useAsync } from 'react-use';
import { DataFrame, DataFrameView, GrafanaTheme2 } from '@grafana/data';
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Button, Card, FilterInput, Icon, IconName, TagList, useStyles2, VerticalGroup } from '@grafana/ui';
import {
Alert,
Button,
Card,
FilterInput,
HorizontalGroup,
Icon,
IconName,
TagList,
useStyles2,
VerticalGroup,
} from '@grafana/ui';
import { StorageView } from './types';
import { getGrafanaStorage } from './storage';
import { StorageInfo, StorageView } from './types';
interface Props {
root: DataFrame;
onPathChange: (p: string, v?: StorageView) => void;
}
interface RootFolder {
name: string;
title: string;
storageType: string;
description: string;
readOnly: boolean;
builtIn: boolean;
}
export function RootView({ root, onPathChange }: Props) {
const styles = useStyles2(getStyles);
const storage = useAsync(getGrafanaStorage().getConfig);
const [searchQuery, setSearchQuery] = useState<string>('');
let base = location.pathname;
if (!base.endsWith('/')) {
@ -30,11 +35,11 @@ export function RootView({ root, onPathChange }: Props) {
}
const roots = useMemo(() => {
const view = new DataFrameView<RootFolder>(root);
const all = view.map((v) => ({ ...v }));
if (searchQuery?.length) {
const all = storage.value;
if (searchQuery?.length && all) {
const lower = searchQuery.toLowerCase();
return all.filter((v) => {
return all.filter((r) => {
const v = r.config;
const isMatch = v.name.toLowerCase().indexOf(lower) >= 0 || v.description.toLowerCase().indexOf(lower) >= 0;
if (isMatch) {
return true;
@ -42,8 +47,8 @@ export function RootView({ root, onPathChange }: Props) {
return false;
});
}
return all;
}, [searchQuery, root]);
return all ?? [];
}, [searchQuery, storage]);
return (
<div>
@ -61,18 +66,30 @@ export function RootView({ root, onPathChange }: Props) {
)}
</div>
<VerticalGroup>
{roots.map((v) => (
<Card key={v.name} href={`admin/storage/${v.name}/`}>
<Card.Heading>{v.title ?? v.name}</Card.Heading>
<Card.Meta className={styles.clickable}>{v.description}</Card.Meta>
<Card.Tags className={styles.clickable}>
<TagList tags={getTags(v)} />
</Card.Tags>
<Card.Figure className={styles.clickable}>
<Icon name={getIconName(v.storageType)} size="xxxl" className={styles.secondaryTextColor} />
</Card.Figure>
</Card>
))}
{roots.map((s) => {
const ok = s.ready;
return (
<Card key={s.config.prefix} href={ok ? `admin/storage/${s.config.prefix}/` : undefined}>
<Card.Heading>{s.config.name}</Card.Heading>
<Card.Meta className={styles.clickable}>
{s.config.description}
{s.config.git?.remote && <a href={s.config.git?.remote}>{s.config.git?.remote}</a>}
</Card.Meta>
{s.notice?.map((notice) => (
<Alert key={notice.text} severity={notice.severity} title={notice.text} />
))}
<Card.Tags className={styles.clickable}>
<HorizontalGroup>
<TagList tags={getTags(s)} />
</HorizontalGroup>
</Card.Tags>
<Card.Figure className={styles.clickable}>
<Icon name={getIconName(s.config.type)} size="xxxl" className={styles.secondaryTextColor} />
</Card.Figure>
</Card>
);
})}
</VerticalGroup>
</div>
);
@ -89,14 +106,19 @@ function getStyles(theme: GrafanaTheme2) {
};
}
function getTags(v: RootFolder) {
function getTags(v: StorageInfo) {
const tags: string[] = [];
if (v.builtIn) {
if (v.builtin) {
tags.push('Builtin');
}
if (v.readOnly) {
if (!v.editable) {
tags.push('Read only');
}
// Error
if (!v.ready) {
tags.push('Not ready');
}
return tags;
}

View File

@ -157,8 +157,7 @@ export default function StoragePage(props: Props) {
const canAddFolder = isFolder && path.startsWith('resources');
const canDelete = path.startsWith('resources/');
const canViewDashboard =
path.startsWith('devenv/') && config.featureToggles.dashboardsFromStorage && (isFolder || path.endsWith('.json'));
const canViewDashboard = config.featureToggles.dashboardsFromStorage && (isFolder || path.endsWith('.json'));
const getErrorMessages = () => {
return (

View File

@ -1,10 +1,9 @@
import { DataFrame, dataFrameFromJSON, DataFrameJSON, getDisplayProcessor } from '@grafana/data';
import { config, getBackendSrv } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { SaveDashboardCommand } from 'app/features/dashboard/components/SaveDashboard/types';
import { DashboardDTO } from 'app/types';
import { UploadReponse } from './types';
import { UploadReponse, StorageInfo, ItemOptions, WriteValueRequest, WriteValueResponse } from './types';
// Likely should be built into the search interface!
export interface GrafanaStorage {
@ -14,12 +13,20 @@ export interface GrafanaStorage {
createFolder: (path: string) => Promise<{ error?: string }>;
delete: (path: { isFolder: boolean; path: string }) => Promise<{ error?: string }>;
/** Admin only */
getConfig: () => Promise<StorageInfo[]>;
/** Called before save */
getOptions: (path: string) => Promise<ItemOptions>;
/**
* Temporary shim that will return a DashboardDTO shape for files in storage
* Longer term, this will call an "Entity API" that is eventually backed by storage
*/
getDashboard: (path: string) => Promise<DashboardDTO>;
saveDashboard: (options: SaveDashboardCommand) => Promise<any>;
/** Saves dashbaords */
write: (path: string, options: WriteValueRequest) => Promise<WriteValueResponse>;
}
class SimpleStorage implements GrafanaStorage {
@ -140,40 +147,16 @@ class SimpleStorage implements GrafanaStorage {
};
}
async saveDashboard(options: SaveDashboardCommand): Promise<any> {
if (!config.featureToggles.dashboardsFromStorage) {
return Promise.reject('Dashboards from storage is not enabled');
}
async write(path: string, options: WriteValueRequest): Promise<WriteValueResponse> {
return backendSrv.post<WriteValueResponse>(`/api/storage/write/${path}`, options);
}
const blob = new Blob([JSON.stringify(options.dashboard)], {
type: 'application/json',
});
async getConfig() {
return getBackendSrv().get<StorageInfo[]>('/api/storage/config');
}
const uid = options.dashboard.uid;
const formData = new FormData();
if (options.message) {
formData.append('message', options.message);
}
formData.append('overwriteExistingFile', options.overwrite === false ? 'false' : 'true');
formData.append('file.path', uid);
formData.append('file', blob);
const res = await fetch('/api/storage/upload', {
method: 'POST',
body: formData,
});
let body = (await res.json()) as UploadReponse;
if (res.status !== 200 && !body?.err) {
console.log('SAVE', options, body);
return Promise.reject({ message: body?.message ?? res.statusText });
}
return {
uid,
url: `/g/${uid}`,
slug: uid,
status: 'success',
};
async getOptions(path: string) {
return getBackendSrv().get<ItemOptions>(`/api/storage/options/${path}`);
}
}

View File

@ -1,3 +1,5 @@
import { QueryResultMetaNotice, SelectableValue } from '@grafana/data';
export enum StorageView {
Data = 'data',
Config = 'config',
@ -15,3 +17,58 @@ export interface UploadReponse {
message: string;
path: string;
}
export interface StorageInfo {
editable?: boolean;
builtin?: boolean;
ready?: boolean;
notice?: QueryResultMetaNotice[];
config: StorageConfig;
}
export interface StorageConfig {
type: string;
prefix: string;
name: string;
description: string;
disk?: {
path: string;
};
git?: {
remote: string;
branch: string;
root: string;
requirePullRequest: boolean;
accessToken: string;
};
sql?: {};
}
export enum WorkflowID {
Save = 'save',
PR = 'pr',
Push = 'push',
}
export interface WriteValueRequest {
kind: string;
body: {}; // json body
message?: string;
title?: string;
workflow: WorkflowID;
}
export interface WriteValueResponse {
code: number;
message?: string;
url?: string;
hash?: string;
branch?: string;
pending?: boolean;
size?: number;
}
export interface ItemOptions {
path: string;
workflows: Array<SelectableValue<WorkflowID>>;
}