mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
CLI: Allow installing custom binary plugins (#17551)
Make sure all data is sent to API to be able to select correct archive version.
This commit is contained in:
parent
64828e017c
commit
8c49d27705
@ -73,7 +73,7 @@ var pluginCommands = []cli.Command{
|
|||||||
}, {
|
}, {
|
||||||
Name: "list-remote",
|
Name: "list-remote",
|
||||||
Usage: "list remote available plugins",
|
Usage: "list remote available plugins",
|
||||||
Action: runPluginCommand(listremoteCommand),
|
Action: runPluginCommand(listRemoteCommand),
|
||||||
}, {
|
}, {
|
||||||
Name: "list-versions",
|
Name: "list-versions",
|
||||||
Usage: "list-versions <plugin id>",
|
Usage: "list-versions <plugin id>",
|
||||||
|
34
pkg/cmd/grafana-cli/commands/commandstest/fake_api_client.go
Normal file
34
pkg/cmd/grafana-cli/commands/commandstest/fake_api_client.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
package commandstest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FakeGrafanaComClient struct {
|
||||||
|
GetPluginFunc func(pluginId, repoUrl string) (models.Plugin, error)
|
||||||
|
DownloadFileFunc func(pluginName, filePath, url string, checksum string) (content []byte, err error)
|
||||||
|
ListAllPluginsFunc func(repoUrl string) (models.PluginRepo, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *FakeGrafanaComClient) GetPlugin(pluginId, repoUrl string) (models.Plugin, error) {
|
||||||
|
if client.GetPluginFunc != nil {
|
||||||
|
return client.GetPluginFunc(pluginId, repoUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.Plugin{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *FakeGrafanaComClient) DownloadFile(pluginName, filePath, url string, checksum string) (content []byte, err error) {
|
||||||
|
if client.DownloadFileFunc != nil {
|
||||||
|
return client.DownloadFileFunc(pluginName, filePath, url, checksum)
|
||||||
|
}
|
||||||
|
|
||||||
|
return make([]byte, 0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *FakeGrafanaComClient) ListAllPlugins(repoUrl string) (models.PluginRepo, error) {
|
||||||
|
if client.ListAllPluginsFunc != nil {
|
||||||
|
return client.ListAllPluginsFunc(repoUrl)
|
||||||
|
}
|
||||||
|
return models.PluginRepo{}, nil
|
||||||
|
}
|
@ -2,6 +2,7 @@ package commandstest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/codegangsta/cli"
|
"github.com/codegangsta/cli"
|
||||||
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FakeFlagger struct {
|
type FakeFlagger struct {
|
||||||
@ -12,6 +13,7 @@ type FakeCommandLine struct {
|
|||||||
LocalFlags, GlobalFlags *FakeFlagger
|
LocalFlags, GlobalFlags *FakeFlagger
|
||||||
HelpShown, VersionShown bool
|
HelpShown, VersionShown bool
|
||||||
CliArgs []string
|
CliArgs []string
|
||||||
|
Client utils.ApiClient
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ff FakeFlagger) String(key string) string {
|
func (ff FakeFlagger) String(key string) string {
|
||||||
@ -105,3 +107,7 @@ func (fcli *FakeCommandLine) PluginDirectory() string {
|
|||||||
func (fcli *FakeCommandLine) PluginURL() string {
|
func (fcli *FakeCommandLine) PluginURL() string {
|
||||||
return fcli.GlobalString("pluginUrl")
|
return fcli.GlobalString("pluginUrl")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fcli *FakeCommandLine) ApiClient() utils.ApiClient {
|
||||||
|
return fcli.Client
|
||||||
|
}
|
||||||
|
@ -6,15 +6,17 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/utils"
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/utils"
|
||||||
|
"github.com/grafana/grafana/pkg/util/errutil"
|
||||||
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
||||||
m "github.com/grafana/grafana/pkg/cmd/grafana-cli/models"
|
m "github.com/grafana/grafana/pkg/cmd/grafana-cli/models"
|
||||||
@ -64,13 +66,23 @@ func installCommand(c utils.CommandLine) error {
|
|||||||
func InstallPlugin(pluginName, version string, c utils.CommandLine) error {
|
func InstallPlugin(pluginName, version string, c utils.CommandLine) error {
|
||||||
pluginFolder := c.PluginDirectory()
|
pluginFolder := c.PluginDirectory()
|
||||||
downloadURL := c.PluginURL()
|
downloadURL := c.PluginURL()
|
||||||
|
isInternal := false
|
||||||
|
|
||||||
|
var checksum string
|
||||||
if downloadURL == "" {
|
if downloadURL == "" {
|
||||||
plugin, err := s.GetPlugin(pluginName, c.RepoDirectory())
|
if strings.HasPrefix(pluginName, "grafana-") {
|
||||||
|
// At this point the plugin download is going through grafana.com API and thus the name is validated.
|
||||||
|
// Checking for grafana prefix is how it is done there so no 3rd party plugin should have that prefix.
|
||||||
|
// You can supply custom plugin name and then set custom download url to 3rd party plugin but then that
|
||||||
|
// is up to the user to know what she is doing.
|
||||||
|
isInternal = true
|
||||||
|
}
|
||||||
|
plugin, err := c.ApiClient().GetPlugin(pluginName, c.RepoDirectory())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
v, err := SelectVersion(plugin, version)
|
v, err := SelectVersion(&plugin, version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -81,7 +93,13 @@ func InstallPlugin(pluginName, version string, c utils.CommandLine) error {
|
|||||||
downloadURL = fmt.Sprintf("%s/%s/versions/%s/download",
|
downloadURL = fmt.Sprintf("%s/%s/versions/%s/download",
|
||||||
c.GlobalString("repo"),
|
c.GlobalString("repo"),
|
||||||
pluginName,
|
pluginName,
|
||||||
version)
|
version,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Plugins which are downloaded just as sourcecode zipball from github do not have checksum
|
||||||
|
if v.Arch != nil {
|
||||||
|
checksum = v.Arch[osAndArchString()].Md5
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("installing %v @ %v\n", pluginName, version)
|
logger.Infof("installing %v @ %v\n", pluginName, version)
|
||||||
@ -89,9 +107,14 @@ func InstallPlugin(pluginName, version string, c utils.CommandLine) error {
|
|||||||
logger.Infof("into: %v\n", pluginFolder)
|
logger.Infof("into: %v\n", pluginFolder)
|
||||||
logger.Info("\n")
|
logger.Info("\n")
|
||||||
|
|
||||||
err := downloadFile(pluginName, pluginFolder, downloadURL)
|
content, err := c.ApiClient().DownloadFile(pluginName, pluginFolder, downloadURL, checksum)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return errutil.Wrap("Failed to download plugin archive", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = extractFiles(content, pluginName, pluginFolder, isInternal)
|
||||||
|
if err != nil {
|
||||||
|
return errutil.Wrap("Failed to extract plugin archive", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("%s Installed %s successfully \n", color.GreenString("✔"), pluginName)
|
logger.Infof("%s Installed %s successfully \n", color.GreenString("✔"), pluginName)
|
||||||
@ -105,18 +128,61 @@ func InstallPlugin(pluginName, version string, c utils.CommandLine) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func SelectVersion(plugin m.Plugin, version string) (m.Version, error) {
|
func osAndArchString() string {
|
||||||
|
osString := strings.ToLower(runtime.GOOS)
|
||||||
|
arch := runtime.GOARCH
|
||||||
|
return osString + "-" + arch
|
||||||
|
}
|
||||||
|
|
||||||
|
func supportsCurrentArch(version *m.Version) bool {
|
||||||
|
if version.Arch == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for arch := range version.Arch {
|
||||||
|
if arch == osAndArchString() || arch == "any" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestSupportedVersion(plugin *m.Plugin) *m.Version {
|
||||||
|
for _, ver := range plugin.Versions {
|
||||||
|
if supportsCurrentArch(&ver) {
|
||||||
|
return &ver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectVersion returns latest version if none is specified or the specified version. If the version string is not
|
||||||
|
// matched to existing version it errors out. It also errors out if version that is matched is not available for current
|
||||||
|
// os and platform.
|
||||||
|
func SelectVersion(plugin *m.Plugin, version string) (*m.Version, error) {
|
||||||
|
var ver *m.Version
|
||||||
if version == "" {
|
if version == "" {
|
||||||
return plugin.Versions[0], nil
|
ver = &plugin.Versions[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, v := range plugin.Versions {
|
for _, v := range plugin.Versions {
|
||||||
if v.Version == version {
|
if v.Version == version {
|
||||||
return v, nil
|
ver = &v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.Version{}, errors.New("Could not find the version you're looking for")
|
if ver == nil {
|
||||||
|
return nil, xerrors.New("Could not find the version you're looking for")
|
||||||
|
}
|
||||||
|
|
||||||
|
latestForArch := latestSupportedVersion(plugin)
|
||||||
|
if latestForArch == nil {
|
||||||
|
return nil, xerrors.New("Plugin is not supported on your architecture and os.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if latestForArch.Version == ver.Version {
|
||||||
|
return ver, nil
|
||||||
|
}
|
||||||
|
return nil, xerrors.Errorf("Version you want is not supported on your architecture and os. Latest suitable version is %v", latestForArch.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
func RemoveGitBuildFromName(pluginName, filename string) string {
|
func RemoveGitBuildFromName(pluginName, filename string) string {
|
||||||
@ -124,57 +190,19 @@ func RemoveGitBuildFromName(pluginName, filename string) string {
|
|||||||
return r.ReplaceAllString(filename, pluginName+"/")
|
return r.ReplaceAllString(filename, pluginName+"/")
|
||||||
}
|
}
|
||||||
|
|
||||||
var retryCount = 0
|
|
||||||
var permissionsDeniedMessage = "Could not create %s. Permission denied. Make sure you have write access to plugindir"
|
var permissionsDeniedMessage = "Could not create %s. Permission denied. Make sure you have write access to plugindir"
|
||||||
|
|
||||||
func downloadFile(pluginName, filePath, url string) (err error) {
|
func extractFiles(body []byte, pluginName string, filePath string, allowSymlinks bool) error {
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
retryCount++
|
|
||||||
if retryCount < 3 {
|
|
||||||
fmt.Println("Failed downloading. Will retry once.")
|
|
||||||
err = downloadFile(pluginName, filePath, url)
|
|
||||||
} else {
|
|
||||||
failure := fmt.Sprintf("%v", r)
|
|
||||||
if failure == "runtime error: makeslice: len out of range" {
|
|
||||||
err = fmt.Errorf("Corrupt http response from source. Please try again")
|
|
||||||
} else {
|
|
||||||
panic(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
var bytes []byte
|
|
||||||
|
|
||||||
if _, err := os.Stat(url); err == nil {
|
|
||||||
bytes, err = ioutil.ReadFile(url)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
resp, err := http.Get(url) // #nosec
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
bytes, err = ioutil.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return extractFiles(bytes, pluginName, filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractFiles(body []byte, pluginName string, filePath string) error {
|
|
||||||
r, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
r, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, zf := range r.File {
|
for _, zf := range r.File {
|
||||||
newFile := path.Join(filePath, RemoveGitBuildFromName(pluginName, zf.Name))
|
newFileName := RemoveGitBuildFromName(pluginName, zf.Name)
|
||||||
|
if !isPathSafe(newFileName, path.Join(filePath, pluginName)) {
|
||||||
|
return xerrors.Errorf("filepath: %v tries to write outside of plugin directory: %v. This can be a security risk.", zf.Name, path.Join(filePath, pluginName))
|
||||||
|
}
|
||||||
|
newFile := path.Join(filePath, newFileName)
|
||||||
|
|
||||||
if zf.FileInfo().IsDir() {
|
if zf.FileInfo().IsDir() {
|
||||||
err := os.Mkdir(newFile, 0755)
|
err := os.Mkdir(newFile, 0755)
|
||||||
@ -182,25 +210,24 @@ func extractFiles(body []byte, pluginName string, filePath string) error {
|
|||||||
return fmt.Errorf(permissionsDeniedMessage, newFile)
|
return fmt.Errorf(permissionsDeniedMessage, newFile)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fileMode := zf.Mode()
|
if isSymlink(zf) {
|
||||||
|
if !allowSymlinks {
|
||||||
|
logger.Errorf("%v: plugin archive contains symlink which is not allowed. Skipping \n", zf.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err = extractSymlink(zf, newFile)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to extract symlink: %v \n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
if strings.HasSuffix(newFile, "_linux_amd64") || strings.HasSuffix(newFile, "_darwin_amd64") {
|
err = extractFile(zf, newFile)
|
||||||
fileMode = os.FileMode(0755)
|
if err != nil {
|
||||||
|
logger.Errorf("Failed to extract file: %v \n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dst, err := os.OpenFile(newFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileMode)
|
|
||||||
if permissionsError(err) {
|
|
||||||
return fmt.Errorf(permissionsDeniedMessage, newFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
src, err := zf.Open()
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("Failed to extract file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
io.Copy(dst, src)
|
|
||||||
dst.Close()
|
|
||||||
src.Close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -210,3 +237,63 @@ func extractFiles(body []byte, pluginName string, filePath string) error {
|
|||||||
func permissionsError(err error) bool {
|
func permissionsError(err error) bool {
|
||||||
return err != nil && strings.Contains(err.Error(), "permission denied")
|
return err != nil && strings.Contains(err.Error(), "permission denied")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isSymlink(file *zip.File) bool {
|
||||||
|
return file.Mode()&os.ModeSymlink == os.ModeSymlink
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSymlink(file *zip.File, filePath string) error {
|
||||||
|
// symlink target is the contents of the file
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return errutil.Wrap("Failed to extract file", err)
|
||||||
|
}
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
_, err = io.Copy(buf, src)
|
||||||
|
if err != nil {
|
||||||
|
return errutil.Wrap("Failed to copy symlink contents", err)
|
||||||
|
}
|
||||||
|
err = os.Symlink(strings.TrimSpace(buf.String()), filePath)
|
||||||
|
if err != nil {
|
||||||
|
return errutil.Wrapf(err, "failed to make symbolic link for %v", filePath)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractFile(file *zip.File, filePath string) (err error) {
|
||||||
|
fileMode := file.Mode()
|
||||||
|
// This is entry point for backend plugins so we want to make them executable
|
||||||
|
if strings.HasSuffix(filePath, "_linux_amd64") || strings.HasSuffix(filePath, "_darwin_amd64") {
|
||||||
|
fileMode = os.FileMode(0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileMode)
|
||||||
|
if err != nil {
|
||||||
|
if permissionsError(err) {
|
||||||
|
return xerrors.Errorf(permissionsDeniedMessage, filePath)
|
||||||
|
}
|
||||||
|
return errutil.Wrap("Failed to open file", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
err = dst.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return errutil.Wrap("Failed to extract file", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
err = src.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
_, err = io.Copy(dst, src)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPathSafe checks if the filePath does not resolve outside of destination. This is used to prevent
|
||||||
|
// https://snyk.io/research/zip-slip-vulnerability
|
||||||
|
// Based on https://github.com/mholt/archiver/pull/65/files#diff-635e4219ee55ef011b2b32bba065606bR109
|
||||||
|
func isPathSafe(filePath string, destination string) bool {
|
||||||
|
destpath := filepath.Join(destination, filePath)
|
||||||
|
return strings.HasPrefix(destpath, destination)
|
||||||
|
}
|
||||||
|
@ -1,11 +1,17 @@
|
|||||||
package commands
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/commands/commandstest"
|
||||||
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/models"
|
||||||
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/utils"
|
||||||
. "github.com/smartystreets/goconvey/convey"
|
. "github.com/smartystreets/goconvey/convey"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFoldernameReplacement(t *testing.T) {
|
func TestFoldernameReplacement(t *testing.T) {
|
||||||
@ -41,40 +47,145 @@ func TestFoldernameReplacement(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractFiles(t *testing.T) {
|
func TestExtractFiles(t *testing.T) {
|
||||||
Convey("Should preserve file permissions for plugin backend binaries for linux and darwin", t, func() {
|
t.Run("Should preserve file permissions for plugin backend binaries for linux and darwin", func(t *testing.T) {
|
||||||
err := os.RemoveAll("testdata/fake-plugins-dir")
|
pluginDir, del := setupFakePluginsDir(t)
|
||||||
So(err, ShouldBeNil)
|
defer del()
|
||||||
|
|
||||||
err = os.MkdirAll("testdata/fake-plugins-dir", 0774)
|
|
||||||
So(err, ShouldBeNil)
|
|
||||||
|
|
||||||
body, err := ioutil.ReadFile("testdata/grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip")
|
body, err := ioutil.ReadFile("testdata/grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip")
|
||||||
So(err, ShouldBeNil)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
err = extractFiles(body, "grafana-simple-json-datasource", "testdata/fake-plugins-dir")
|
err = extractFiles(body, "grafana-simple-json-datasource", pluginDir, false)
|
||||||
So(err, ShouldBeNil)
|
assert.Nil(t, err)
|
||||||
|
|
||||||
//File in zip has permissions 777
|
//File in zip has permissions 755
|
||||||
fileInfo, err := os.Stat("testdata/fake-plugins-dir/grafana-simple-json-datasource/simple-plugin_darwin_amd64")
|
fileInfo, err := os.Stat(pluginDir + "/grafana-simple-json-datasource/simple-plugin_darwin_amd64")
|
||||||
So(err, ShouldBeNil)
|
assert.Nil(t, err)
|
||||||
So(fileInfo.Mode().String(), ShouldEqual, "-rwxr-xr-x")
|
assert.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String())
|
||||||
|
|
||||||
//File in zip has permission 664
|
|
||||||
fileInfo, err = os.Stat("testdata/fake-plugins-dir/grafana-simple-json-datasource/simple-plugin_linux_amd64")
|
|
||||||
So(err, ShouldBeNil)
|
|
||||||
So(fileInfo.Mode().String(), ShouldEqual, "-rwxr-xr-x")
|
|
||||||
|
|
||||||
//File in zip has permission 644
|
|
||||||
fileInfo, err = os.Stat("testdata/fake-plugins-dir/grafana-simple-json-datasource/simple-plugin_windows_amd64.exe")
|
|
||||||
So(err, ShouldBeNil)
|
|
||||||
So(fileInfo.Mode().String(), ShouldEqual, "-rw-r--r--")
|
|
||||||
|
|
||||||
//File in zip has permission 755
|
//File in zip has permission 755
|
||||||
fileInfo, err = os.Stat("testdata/fake-plugins-dir/grafana-simple-json-datasource/non-plugin-binary")
|
fileInfo, err = os.Stat(pluginDir + "/grafana-simple-json-datasource/simple-plugin_linux_amd64")
|
||||||
So(err, ShouldBeNil)
|
assert.Nil(t, err)
|
||||||
So(fileInfo.Mode().String(), ShouldEqual, "-rwxr-xr-x")
|
assert.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String())
|
||||||
|
|
||||||
err = os.RemoveAll("testdata/fake-plugins-dir")
|
//File in zip has permission 644
|
||||||
So(err, ShouldBeNil)
|
fileInfo, err = os.Stat(pluginDir + "/grafana-simple-json-datasource/simple-plugin_windows_amd64.exe")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "-rw-r--r--", fileInfo.Mode().String())
|
||||||
|
|
||||||
|
//File in zip has permission 755
|
||||||
|
fileInfo, err = os.Stat(pluginDir + "/grafana-simple-json-datasource/non-plugin-binary")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Should ignore symlinks if not allowed", func(t *testing.T) {
|
||||||
|
pluginDir, del := setupFakePluginsDir(t)
|
||||||
|
defer del()
|
||||||
|
|
||||||
|
body, err := ioutil.ReadFile("testdata/plugin-with-symlink.zip")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
err = extractFiles(body, "plugin-with-symlink", pluginDir, false)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
_, err = os.Stat(pluginDir + "/plugin-with-symlink/text.txt")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
_, err = os.Stat(pluginDir + "/plugin-with-symlink/symlink_to_txt")
|
||||||
|
assert.NotNil(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Should extract symlinks if allowed", func(t *testing.T) {
|
||||||
|
pluginDir, del := setupFakePluginsDir(t)
|
||||||
|
defer del()
|
||||||
|
|
||||||
|
body, err := ioutil.ReadFile("testdata/plugin-with-symlink.zip")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
err = extractFiles(body, "plugin-with-symlink", pluginDir, true)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
_, err = os.Stat(pluginDir + "/plugin-with-symlink/symlink_to_txt")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
fmt.Println(err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInstallPluginCommand(t *testing.T) {
|
||||||
|
pluginDir, del := setupFakePluginsDir(t)
|
||||||
|
defer del()
|
||||||
|
cmd := setupPluginInstallCmd(t, pluginDir)
|
||||||
|
err := InstallPlugin("test-plugin-panel", "", cmd)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsPathSafe(t *testing.T) {
|
||||||
|
t.Run("Should be true on nested destinations", func(t *testing.T) {
|
||||||
|
assert.True(t, isPathSafe("dest", "/test/path"))
|
||||||
|
assert.True(t, isPathSafe("dest/one", "/test/path"))
|
||||||
|
assert.True(t, isPathSafe("../path/dest/one", "/test/path"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Should be false on destinations outside of path", func(t *testing.T) {
|
||||||
|
assert.False(t, isPathSafe("../dest", "/test/path"))
|
||||||
|
assert.False(t, isPathSafe("../../", "/test/path"))
|
||||||
|
assert.False(t, isPathSafe("../../test", "/test/path"))
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupPluginInstallCmd(t *testing.T, pluginDir string) utils.CommandLine {
|
||||||
|
cmd := &commandstest.FakeCommandLine{
|
||||||
|
GlobalFlags: &commandstest.FakeFlagger{Data: map[string]interface{}{
|
||||||
|
"pluginsDir": pluginDir,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &commandstest.FakeGrafanaComClient{}
|
||||||
|
|
||||||
|
client.GetPluginFunc = func(pluginId, repoUrl string) (models.Plugin, error) {
|
||||||
|
assert.Equal(t, "test-plugin-panel", pluginId)
|
||||||
|
plugin := models.Plugin{
|
||||||
|
Id: "test-plugin-panel",
|
||||||
|
Category: "",
|
||||||
|
Versions: []models.Version{
|
||||||
|
{
|
||||||
|
Commit: "commit",
|
||||||
|
Url: "url",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Arch: map[string]models.ArchMeta{
|
||||||
|
fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH): {
|
||||||
|
Md5: "test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return plugin, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
client.DownloadFileFunc = func(pluginName, filePath, url string, checksum string) (content []byte, err error) {
|
||||||
|
assert.Equal(t, "test-plugin-panel", pluginName)
|
||||||
|
assert.Equal(t, "/test-plugin-panel/versions/1.0.0/download", url)
|
||||||
|
assert.Equal(t, "test", checksum)
|
||||||
|
body, err := ioutil.ReadFile("testdata/grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip")
|
||||||
|
assert.Nil(t, err)
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Client = client
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupFakePluginsDir(t *testing.T) (string, func()) {
|
||||||
|
dirname := "testdata/fake-plugins-dir"
|
||||||
|
err := os.RemoveAll(dirname)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
err = os.MkdirAll(dirname, 0774)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
return dirname, func() {
|
||||||
|
err = os.RemoveAll(dirname)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -2,24 +2,26 @@ package commands
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
||||||
s "github.com/grafana/grafana/pkg/cmd/grafana-cli/services"
|
|
||||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/utils"
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func listremoteCommand(c utils.CommandLine) error {
|
// listRemoteCommand prints out all plugins in the remote repo with latest version supported on current platform.
|
||||||
plugin, err := s.ListAllPlugins(c.RepoDirectory())
|
// If there are no supported versions for plugin it is skipped.
|
||||||
|
func listRemoteCommand(c utils.CommandLine) error {
|
||||||
|
plugin, err := c.ApiClient().ListAllPlugins(c.RepoDirectory())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, i := range plugin.Plugins {
|
for _, plugin := range plugin.Plugins {
|
||||||
pluginVersion := ""
|
if len(plugin.Versions) > 0 {
|
||||||
if len(i.Versions) > 0 {
|
ver := latestSupportedVersion(&plugin)
|
||||||
pluginVersion = i.Versions[0].Version
|
if ver != nil {
|
||||||
|
logger.Infof("id: %v version: %s\n", plugin.Id, ver.Version)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("id: %v version: %s\n", i.Id, pluginVersion)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -4,7 +4,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
||||||
s "github.com/grafana/grafana/pkg/cmd/grafana-cli/services"
|
|
||||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/utils"
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -24,7 +23,7 @@ func listversionsCommand(c utils.CommandLine) error {
|
|||||||
|
|
||||||
pluginToList := c.Args().First()
|
pluginToList := c.Args().First()
|
||||||
|
|
||||||
plugin, err := s.GetPlugin(pluginToList, c.GlobalString("repo"))
|
plugin, err := c.ApiClient().GetPlugin(pluginToList, c.GlobalString("repo"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
BIN
pkg/cmd/grafana-cli/commands/testdata/plugin-with-symlink.zip
vendored
Normal file
BIN
pkg/cmd/grafana-cli/commands/testdata/plugin-with-symlink.zip
vendored
Normal file
Binary file not shown.
@ -8,24 +8,18 @@ import (
|
|||||||
"github.com/hashicorp/go-version"
|
"github.com/hashicorp/go-version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ShouldUpgrade(installed string, remote m.Plugin) bool {
|
func shouldUpgrade(installed string, remote *m.Plugin) bool {
|
||||||
installedVersion, err1 := version.NewVersion(installed)
|
installedVersion, err := version.NewVersion(installed)
|
||||||
|
if err != nil {
|
||||||
if err1 != nil {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, v := range remote.Versions {
|
latest := latestSupportedVersion(remote)
|
||||||
remoteVersion, err2 := version.NewVersion(v.Version)
|
latestVersion, err := version.NewVersion(latest.Version)
|
||||||
|
if err != nil {
|
||||||
if err2 == nil {
|
return false
|
||||||
if installedVersion.LessThan(remoteVersion) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return installedVersion.LessThan(latestVersion)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func upgradeAllCommand(c utils.CommandLine) error {
|
func upgradeAllCommand(c utils.CommandLine) error {
|
||||||
@ -33,7 +27,7 @@ func upgradeAllCommand(c utils.CommandLine) error {
|
|||||||
|
|
||||||
localPlugins := s.GetLocalPlugins(pluginsDir)
|
localPlugins := s.GetLocalPlugins(pluginsDir)
|
||||||
|
|
||||||
remotePlugins, err := s.ListAllPlugins(c.GlobalString("repo"))
|
remotePlugins, err := c.ApiClient().ListAllPlugins(c.GlobalString("repo"))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -44,7 +38,7 @@ func upgradeAllCommand(c utils.CommandLine) error {
|
|||||||
for _, localPlugin := range localPlugins {
|
for _, localPlugin := range localPlugins {
|
||||||
for _, remotePlugin := range remotePlugins.Plugins {
|
for _, remotePlugin := range remotePlugins.Plugins {
|
||||||
if localPlugin.Id == remotePlugin.Id {
|
if localPlugin.Id == remotePlugin.Id {
|
||||||
if ShouldUpgrade(localPlugin.Info.Version, remotePlugin) {
|
if shouldUpgrade(localPlugin.Info.Version, &remotePlugin) {
|
||||||
pluginsToUpgrade = append(pluginsToUpgrade, localPlugin)
|
pluginsToUpgrade = append(pluginsToUpgrade, localPlugin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,46 +1,47 @@
|
|||||||
package commands
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
m "github.com/grafana/grafana/pkg/cmd/grafana-cli/models"
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/models"
|
||||||
. "github.com/smartystreets/goconvey/convey"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestVersionComparsion(t *testing.T) {
|
func TestVersionComparsion(t *testing.T) {
|
||||||
Convey("Validate that version is outdated", t, func() {
|
t.Run("Validate that version is outdated", func(t *testing.T) {
|
||||||
versions := []m.Version{
|
versions := []models.Version{
|
||||||
{Version: "1.1.1"},
|
{Version: "1.1.1"},
|
||||||
{Version: "2.0.0"},
|
{Version: "2.0.0"},
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldUpgrade := map[string]m.Plugin{
|
upgradeablePlugins := map[string]models.Plugin{
|
||||||
"0.0.0": {Versions: versions},
|
"0.0.0": {Versions: versions},
|
||||||
"1.0.0": {Versions: versions},
|
"1.0.0": {Versions: versions},
|
||||||
}
|
}
|
||||||
|
|
||||||
Convey("should return error", func() {
|
for k, v := range upgradeablePlugins {
|
||||||
for k, v := range shouldUpgrade {
|
t.Run(fmt.Sprintf("for %s should be true", k), func(t *testing.T) {
|
||||||
So(ShouldUpgrade(k, v), ShouldBeTrue)
|
assert.True(t, shouldUpgrade(k, &v))
|
||||||
}
|
})
|
||||||
})
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
Convey("Validate that version is ok", t, func() {
|
t.Run("Validate that version is ok", func(t *testing.T) {
|
||||||
versions := []m.Version{
|
versions := []models.Version{
|
||||||
{Version: "1.1.1"},
|
{Version: "1.1.1"},
|
||||||
{Version: "2.0.0"},
|
{Version: "2.0.0"},
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldNotUpgrade := map[string]m.Plugin{
|
shouldNotUpgrade := map[string]models.Plugin{
|
||||||
"2.0.0": {Versions: versions},
|
"2.0.0": {Versions: versions},
|
||||||
"6.0.0": {Versions: versions},
|
"6.0.0": {Versions: versions},
|
||||||
}
|
}
|
||||||
|
|
||||||
Convey("should return error", func() {
|
for k, v := range shouldNotUpgrade {
|
||||||
for k, v := range shouldNotUpgrade {
|
t.Run(fmt.Sprintf("for %s should be false", k), func(t *testing.T) {
|
||||||
So(ShouldUpgrade(k, v), ShouldBeFalse)
|
assert.False(t, shouldUpgrade(k, &v))
|
||||||
}
|
})
|
||||||
})
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -17,13 +17,13 @@ func upgradeCommand(c utils.CommandLine) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
v, err2 := s.GetPlugin(pluginName, c.RepoDirectory())
|
plugin, err2 := c.ApiClient().GetPlugin(pluginName, c.RepoDirectory())
|
||||||
|
|
||||||
if err2 != nil {
|
if err2 != nil {
|
||||||
return err2
|
return err2
|
||||||
}
|
}
|
||||||
|
|
||||||
if ShouldUpgrade(localPlugin.Info.Version, v) {
|
if shouldUpgrade(localPlugin.Info.Version, &plugin) {
|
||||||
s.RemoveInstalledPlugin(pluginsDir, pluginName)
|
s.RemoveInstalledPlugin(pluginsDir, pluginName)
|
||||||
return InstallPlugin(pluginName, "", c)
|
return InstallPlugin(pluginName, "", c)
|
||||||
}
|
}
|
||||||
|
@ -33,6 +33,12 @@ type Version struct {
|
|||||||
Commit string `json:"commit"`
|
Commit string `json:"commit"`
|
||||||
Url string `json:"url"`
|
Url string `json:"url"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
|
// os-arch to md5 checksum to check when downloading the file
|
||||||
|
Arch map[string]ArchMeta `json:"arch"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArchMeta struct {
|
||||||
|
Md5 string `json:"md5"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PluginRepo struct {
|
type PluginRepo struct {
|
||||||
|
160
pkg/cmd/grafana-cli/services/api_client.go
Normal file
160
pkg/cmd/grafana-cli/services/api_client.go
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
||||||
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/models"
|
||||||
|
"github.com/grafana/grafana/pkg/util/errutil"
|
||||||
|
"golang.org/x/xerrors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GrafanaComClient struct {
|
||||||
|
retryCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *GrafanaComClient) GetPlugin(pluginId, repoUrl string) (models.Plugin, error) {
|
||||||
|
logger.Debugf("getting plugin metadata from: %v pluginId: %v \n", repoUrl, pluginId)
|
||||||
|
body, err := sendRequest(HttpClient, repoUrl, "repo", pluginId)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == ErrNotFoundError {
|
||||||
|
return models.Plugin{}, errutil.Wrap("Failed to find requested plugin, check if the plugin_id is correct", err)
|
||||||
|
}
|
||||||
|
return models.Plugin{}, errutil.Wrap("Failed to send request", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data models.Plugin
|
||||||
|
err = json.Unmarshal(body, &data)
|
||||||
|
if err != nil {
|
||||||
|
logger.Info("Failed to unmarshal plugin repo response error:", err)
|
||||||
|
return models.Plugin{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *GrafanaComClient) DownloadFile(pluginName, filePath, url string, checksum string) (content []byte, err error) {
|
||||||
|
// Try handling url like local file path first
|
||||||
|
if _, err := os.Stat(url); err == nil {
|
||||||
|
bytes, err := ioutil.ReadFile(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errutil.Wrap("Failed to read file", err)
|
||||||
|
}
|
||||||
|
return bytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
client.retryCount = 0
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
client.retryCount++
|
||||||
|
if client.retryCount < 3 {
|
||||||
|
logger.Info("Failed downloading. Will retry once.")
|
||||||
|
content, err = client.DownloadFile(pluginName, filePath, url, checksum)
|
||||||
|
} else {
|
||||||
|
client.retryCount = 0
|
||||||
|
failure := fmt.Sprintf("%v", r)
|
||||||
|
if failure == "runtime error: makeslice: len out of range" {
|
||||||
|
err = xerrors.New("Corrupt http response from source. Please try again")
|
||||||
|
} else {
|
||||||
|
panic(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// TODO: this would be better if it was streamed file by file instead of buffered.
|
||||||
|
// Using no timeout here as some plugins can be bigger and smaller timeout would prevent to download a plugin on
|
||||||
|
// slow network. As this is CLI operation hanging is not a big of an issue as user can just abort.
|
||||||
|
body, err := sendRequest(HttpClientNoTimeout, url)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errutil.Wrap("Failed to send request", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(checksum) > 0 && checksum != fmt.Sprintf("%x", md5.Sum(body)) {
|
||||||
|
return nil, xerrors.New("Expected MD5 checksum does not match the downloaded archive. Please contact security@grafana.com.")
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *GrafanaComClient) ListAllPlugins(repoUrl string) (models.PluginRepo, error) {
|
||||||
|
body, err := sendRequest(HttpClient, repoUrl, "repo")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.Info("Failed to send request", "error", err)
|
||||||
|
return models.PluginRepo{}, errutil.Wrap("Failed to send request", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data models.PluginRepo
|
||||||
|
err = json.Unmarshal(body, &data)
|
||||||
|
if err != nil {
|
||||||
|
logger.Info("Failed to unmarshal plugin repo response error:", err)
|
||||||
|
return models.PluginRepo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendRequest(client http.Client, repoUrl string, subPaths ...string) ([]byte, error) {
|
||||||
|
u, _ := url.Parse(repoUrl)
|
||||||
|
for _, v := range subPaths {
|
||||||
|
u.Path = path.Join(u.Path, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||||
|
|
||||||
|
req.Header.Set("grafana-version", grafanaVersion)
|
||||||
|
req.Header.Set("grafana-os", runtime.GOOS)
|
||||||
|
req.Header.Set("grafana-arch", runtime.GOARCH)
|
||||||
|
req.Header.Set("User-Agent", "grafana "+grafanaVersion)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return []byte{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return []byte{}, err
|
||||||
|
}
|
||||||
|
return handleResponse(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleResponse(res *http.Response) ([]byte, error) {
|
||||||
|
if res.StatusCode == 404 {
|
||||||
|
return []byte{}, ErrNotFoundError
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.StatusCode/100 != 2 && res.StatusCode/100 != 4 {
|
||||||
|
return []byte{}, fmt.Errorf("Api returned invalid status: %s", res.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ioutil.ReadAll(res.Body)
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
if res.StatusCode/100 == 4 {
|
||||||
|
if len(body) == 0 {
|
||||||
|
return []byte{}, &BadRequestError{Status: res.Status}
|
||||||
|
}
|
||||||
|
var message string
|
||||||
|
var jsonBody map[string]string
|
||||||
|
err = json.Unmarshal(body, &jsonBody)
|
||||||
|
if err != nil || len(jsonBody["message"]) == 0 {
|
||||||
|
message = string(body)
|
||||||
|
} else {
|
||||||
|
message = jsonBody["message"]
|
||||||
|
}
|
||||||
|
return []byte{}, &BadRequestError{Status: res.Status, Message: message}
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, err
|
||||||
|
}
|
67
pkg/cmd/grafana-cli/services/api_client_test.go
Normal file
67
pkg/cmd/grafana-cli/services/api_client_test.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleResponse(t *testing.T) {
|
||||||
|
t.Run("Returns body if status == 200", func(t *testing.T) {
|
||||||
|
body, err := handleResponse(makeResponse(200, "test"))
|
||||||
|
assert.Nil(t, err)
|
||||||
|
assert.Equal(t, "test", string(body))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Returns ErrorNotFound if status == 404", func(t *testing.T) {
|
||||||
|
_, err := handleResponse(makeResponse(404, ""))
|
||||||
|
assert.Equal(t, ErrNotFoundError, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Returns message from body if status == 400", func(t *testing.T) {
|
||||||
|
_, err := handleResponse(makeResponse(400, "{ \"message\": \"error_message\" }"))
|
||||||
|
assert.NotNil(t, err)
|
||||||
|
assert.Equal(t, "error_message", asBadRequestError(t, err).Message)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Returns body if status == 400 and no message key", func(t *testing.T) {
|
||||||
|
_, err := handleResponse(makeResponse(400, "{ \"test\": \"test_message\"}"))
|
||||||
|
assert.NotNil(t, err)
|
||||||
|
assert.Equal(t, "{ \"test\": \"test_message\"}", asBadRequestError(t, err).Message)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Returns Bad request error if status == 400 and no body", func(t *testing.T) {
|
||||||
|
_, err := handleResponse(makeResponse(400, ""))
|
||||||
|
assert.NotNil(t, err)
|
||||||
|
_ = asBadRequestError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Returns error with invalid status if status == 500", func(t *testing.T) {
|
||||||
|
_, err := handleResponse(makeResponse(500, ""))
|
||||||
|
assert.NotNil(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid status")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeResponse(status int, body string) *http.Response {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: status,
|
||||||
|
Body: makeBody(body),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeBody(body string) io.ReadCloser {
|
||||||
|
return ioutil.NopCloser(bytes.NewReader([]byte(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func asBadRequestError(t *testing.T, err error) *BadRequestError {
|
||||||
|
if badRequestError, ok := err.(*BadRequestError); ok {
|
||||||
|
return badRequestError
|
||||||
|
}
|
||||||
|
assert.FailNow(t, "Error was not of type BadRequestError")
|
||||||
|
return nil
|
||||||
|
}
|
@ -5,12 +5,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"path"
|
"path"
|
||||||
"runtime"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
||||||
@ -18,15 +15,33 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
IoHelper m.IoUtil = IoUtilImp{}
|
IoHelper m.IoUtil = IoUtilImp{}
|
||||||
HttpClient http.Client
|
HttpClient http.Client
|
||||||
grafanaVersion string
|
HttpClientNoTimeout http.Client
|
||||||
ErrNotFoundError = errors.New("404 not found error")
|
grafanaVersion string
|
||||||
|
ErrNotFoundError = errors.New("404 not found error")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type BadRequestError struct {
|
||||||
|
Message string
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *BadRequestError) Error() string {
|
||||||
|
if len(e.Message) > 0 {
|
||||||
|
return fmt.Sprintf("%s: %s", e.Status, e.Message)
|
||||||
|
}
|
||||||
|
return e.Status
|
||||||
|
}
|
||||||
|
|
||||||
func Init(version string, skipTLSVerify bool) {
|
func Init(version string, skipTLSVerify bool) {
|
||||||
grafanaVersion = version
|
grafanaVersion = version
|
||||||
|
|
||||||
|
HttpClient = makeHttpClient(skipTLSVerify, 10*time.Second)
|
||||||
|
HttpClientNoTimeout = makeHttpClient(skipTLSVerify, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeHttpClient(skipTLSVerify bool, timeout time.Duration) http.Client {
|
||||||
tr := &http.Transport{
|
tr := &http.Transport{
|
||||||
Proxy: http.ProxyFromEnvironment,
|
Proxy: http.ProxyFromEnvironment,
|
||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
@ -42,30 +57,12 @@ func Init(version string, skipTLSVerify bool) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpClient = http.Client{
|
return http.Client{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: timeout,
|
||||||
Transport: tr,
|
Transport: tr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListAllPlugins(repoUrl string) (m.PluginRepo, error) {
|
|
||||||
body, err := sendRequest(repoUrl, "repo")
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.Info("Failed to send request", "error", err)
|
|
||||||
return m.PluginRepo{}, fmt.Errorf("Failed to send request. error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var data m.PluginRepo
|
|
||||||
err = json.Unmarshal(body, &data)
|
|
||||||
if err != nil {
|
|
||||||
logger.Info("Failed to unmarshal plugin repo response error:", err)
|
|
||||||
return m.PluginRepo{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ReadPlugin(pluginDir, pluginName string) (m.InstalledPlugin, error) {
|
func ReadPlugin(pluginDir, pluginName string) (m.InstalledPlugin, error) {
|
||||||
distPluginDataPath := path.Join(pluginDir, pluginName, "dist", "plugin.json")
|
distPluginDataPath := path.Join(pluginDir, pluginName, "dist", "plugin.json")
|
||||||
|
|
||||||
@ -120,60 +117,3 @@ func RemoveInstalledPlugin(pluginPath, pluginName string) error {
|
|||||||
|
|
||||||
return IoHelper.RemoveAll(pluginDir)
|
return IoHelper.RemoveAll(pluginDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetPlugin(pluginId, repoUrl string) (m.Plugin, error) {
|
|
||||||
logger.Debugf("getting plugin metadata from: %v pluginId: %v \n", repoUrl, pluginId)
|
|
||||||
body, err := sendRequest(repoUrl, "repo", pluginId)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.Info("Failed to send request: ", err)
|
|
||||||
if err == ErrNotFoundError {
|
|
||||||
return m.Plugin{}, fmt.Errorf("Failed to find requested plugin, check if the plugin_id is correct. error: %v", err)
|
|
||||||
}
|
|
||||||
return m.Plugin{}, fmt.Errorf("Failed to send request. error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var data m.Plugin
|
|
||||||
err = json.Unmarshal(body, &data)
|
|
||||||
if err != nil {
|
|
||||||
logger.Info("Failed to unmarshal plugin repo response error:", err)
|
|
||||||
return m.Plugin{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendRequest(repoUrl string, subPaths ...string) ([]byte, error) {
|
|
||||||
u, _ := url.Parse(repoUrl)
|
|
||||||
for _, v := range subPaths {
|
|
||||||
u.Path = path.Join(u.Path, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
|
||||||
|
|
||||||
req.Header.Set("grafana-version", grafanaVersion)
|
|
||||||
req.Header.Set("grafana-os", runtime.GOOS)
|
|
||||||
req.Header.Set("grafana-arch", runtime.GOARCH)
|
|
||||||
req.Header.Set("User-Agent", "grafana "+grafanaVersion)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return []byte{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := HttpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return []byte{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if res.StatusCode == 404 {
|
|
||||||
return []byte{}, ErrNotFoundError
|
|
||||||
}
|
|
||||||
if res.StatusCode/100 != 2 {
|
|
||||||
return []byte{}, fmt.Errorf("Api returned invalid status: %s", res.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := ioutil.ReadAll(res.Body)
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
return body, err
|
|
||||||
}
|
|
||||||
|
@ -2,6 +2,8 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/codegangsta/cli"
|
"github.com/codegangsta/cli"
|
||||||
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/models"
|
||||||
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CommandLine interface {
|
type CommandLine interface {
|
||||||
@ -20,6 +22,13 @@ type CommandLine interface {
|
|||||||
PluginDirectory() string
|
PluginDirectory() string
|
||||||
RepoDirectory() string
|
RepoDirectory() string
|
||||||
PluginURL() string
|
PluginURL() string
|
||||||
|
ApiClient() ApiClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiClient interface {
|
||||||
|
GetPlugin(pluginId, repoUrl string) (models.Plugin, error)
|
||||||
|
DownloadFile(pluginName, filePath, url string, checksum string) (content []byte, err error)
|
||||||
|
ListAllPlugins(repoUrl string) (models.PluginRepo, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContextCommandLine struct {
|
type ContextCommandLine struct {
|
||||||
@ -57,3 +66,7 @@ func (c *ContextCommandLine) PluginURL() string {
|
|||||||
func (c *ContextCommandLine) OptionsString() string {
|
func (c *ContextCommandLine) OptionsString() string {
|
||||||
return c.GlobalString("configOverrides")
|
return c.GlobalString("configOverrides")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *ContextCommandLine) ApiClient() ApiClient {
|
||||||
|
return &services.GrafanaComClient{}
|
||||||
|
}
|
||||||
|
@ -6,37 +6,52 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
||||||
|
"golang.org/x/xerrors"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetGrafanaPluginDir(currentOS string) string {
|
func GetGrafanaPluginDir(currentOS string) string {
|
||||||
if isDevEnvironment() {
|
if rootPath, ok := tryGetRootForDevEnvironment(); ok {
|
||||||
return "../data/plugins"
|
return filepath.Join(rootPath, "data/plugins")
|
||||||
}
|
}
|
||||||
|
|
||||||
return returnOsDefault(currentOS)
|
return returnOsDefault(currentOS)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isDevEnvironment() bool {
|
// getGrafanaRoot tries to get root of directory when developing grafana ie repo root. It is not perfect it just
|
||||||
// if ../conf/defaults.ini exists, grafana is not installed as package
|
// checks what is the binary path and tries to guess based on that but if it is not running in dev env you get a bogus
|
||||||
// that its in development environment.
|
// path back.
|
||||||
|
func getGrafanaRoot() (string, error) {
|
||||||
ex, err := os.Executable()
|
ex, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Could not get executable path. Assuming non dev environment.")
|
return "", xerrors.New("Failed to get executable path")
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
exPath := filepath.Dir(ex)
|
exPath := filepath.Dir(ex)
|
||||||
_, last := path.Split(exPath)
|
_, last := path.Split(exPath)
|
||||||
if last == "bin" {
|
if last == "bin" {
|
||||||
// In dev env the executable for current platform is created in 'bin/' dir
|
// In dev env the executable for current platform is created in 'bin/' dir
|
||||||
defaultsPath := filepath.Join(exPath, "../conf/defaults.ini")
|
return filepath.Join(exPath, ".."), nil
|
||||||
_, err = os.Stat(defaultsPath)
|
|
||||||
return err == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// But at the same time there are per platform directories that contain the binaries and can also be used.
|
// But at the same time there are per platform directories that contain the binaries and can also be used.
|
||||||
defaultsPath := filepath.Join(exPath, "../../conf/defaults.ini")
|
return filepath.Join(exPath, "../.."), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryGetRootForDevEnvironment returns root path if we are in dev environment. It checks if conf/defaults.ini exists
|
||||||
|
// which should only exist in dev. Second param is false if we are not in dev or if it wasn't possible to determine it.
|
||||||
|
func tryGetRootForDevEnvironment() (string, bool) {
|
||||||
|
rootPath, err := getGrafanaRoot()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Could not get executable path. Assuming non dev environment.", err)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultsPath := filepath.Join(rootPath, "conf/defaults.ini")
|
||||||
|
|
||||||
_, err = os.Stat(defaultsPath)
|
_, err = os.Stat(defaultsPath)
|
||||||
return err == nil
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return rootPath, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func returnOsDefault(currentOs string) string {
|
func returnOsDefault(currentOs string) string {
|
||||||
|
@ -164,11 +164,15 @@ func scan(pluginDir string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (scanner *PluginScanner) walker(currentPath string, f os.FileInfo, err error) error {
|
func (scanner *PluginScanner) walker(currentPath string, f os.FileInfo, err error) error {
|
||||||
|
// We scan all the subfolders for plugin.json (with some exceptions) so that we also load embedded plugins, for
|
||||||
|
// example https://github.com/raintank/worldping-app/tree/master/dist/grafana-worldmap-panel worldmap panel plugin
|
||||||
|
// is embedded in worldping app.
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if f.Name() == "node_modules" {
|
if f.Name() == "node_modules" || f.Name() == "Chromium.app" {
|
||||||
return util.ErrWalkSkipDir
|
return util.ErrWalkSkipDir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -67,6 +67,7 @@ func (rs *RenderingService) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if plugins.Renderer == nil {
|
if plugins.Renderer == nil {
|
||||||
|
rs.log.Info("Backend rendering via phantomJS")
|
||||||
rs.renderAction = rs.renderViaPhantomJS
|
rs.renderAction = rs.renderViaPhantomJS
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
return nil
|
return nil
|
||||||
|
Loading…
Reference in New Issue
Block a user