mirror of https://github.com/grafana/grafana
CLI: Allow installing custom binary plugins (#17551)
Make sure all data is sent to API to be able to select correct archive version.pull/18290/head
parent
64828e017c
commit
8c49d27705
@ -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 |
||||
} |
||||
Binary file not shown.
@ -1,46 +1,47 @@ |
||||
package commands |
||||
|
||||
import ( |
||||
"fmt" |
||||
"testing" |
||||
|
||||
m "github.com/grafana/grafana/pkg/cmd/grafana-cli/models" |
||||
. "github.com/smartystreets/goconvey/convey" |
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/models" |
||||
"github.com/stretchr/testify/assert" |
||||
) |
||||
|
||||
func TestVersionComparsion(t *testing.T) { |
||||
Convey("Validate that version is outdated", t, func() { |
||||
versions := []m.Version{ |
||||
t.Run("Validate that version is outdated", func(t *testing.T) { |
||||
versions := []models.Version{ |
||||
{Version: "1.1.1"}, |
||||
{Version: "2.0.0"}, |
||||
} |
||||
|
||||
shouldUpgrade := map[string]m.Plugin{ |
||||
upgradeablePlugins := map[string]models.Plugin{ |
||||
"0.0.0": {Versions: versions}, |
||||
"1.0.0": {Versions: versions}, |
||||
} |
||||
|
||||
Convey("should return error", func() { |
||||
for k, v := range shouldUpgrade { |
||||
So(ShouldUpgrade(k, v), ShouldBeTrue) |
||||
} |
||||
}) |
||||
for k, v := range upgradeablePlugins { |
||||
t.Run(fmt.Sprintf("for %s should be true", k), func(t *testing.T) { |
||||
assert.True(t, shouldUpgrade(k, &v)) |
||||
}) |
||||
} |
||||
}) |
||||
|
||||
Convey("Validate that version is ok", t, func() { |
||||
versions := []m.Version{ |
||||
t.Run("Validate that version is ok", func(t *testing.T) { |
||||
versions := []models.Version{ |
||||
{Version: "1.1.1"}, |
||||
{Version: "2.0.0"}, |
||||
} |
||||
|
||||
shouldNotUpgrade := map[string]m.Plugin{ |
||||
shouldNotUpgrade := map[string]models.Plugin{ |
||||
"2.0.0": {Versions: versions}, |
||||
"6.0.0": {Versions: versions}, |
||||
} |
||||
|
||||
Convey("should return error", func() { |
||||
for k, v := range shouldNotUpgrade { |
||||
So(ShouldUpgrade(k, v), ShouldBeFalse) |
||||
} |
||||
}) |
||||
for k, v := range shouldNotUpgrade { |
||||
t.Run(fmt.Sprintf("for %s should be false", k), func(t *testing.T) { |
||||
assert.False(t, shouldUpgrade(k, &v)) |
||||
}) |
||||
} |
||||
}) |
||||
} |
||||
|
||||
@ -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 |
||||
} |
||||
@ -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 |
||||
} |
||||
Loading…
Reference in new issue