Plugins: Refactor plugin download/installation (#43046)

* installer -> repo

* add semver format checking

* add plugin callbacks in test

* remove newline

* post install only scans new directories

* remove unused stuff

* everything in own package

* add missing cli params

* make grafana version part of the API

* resolve conflicts

* tidy up logger

* fix cli and tidy log statements

* rename log package

* update struct name

* fix linter issue

* fs -> filestore

* reorder imports

* alias import

* fix test

* fix test

* inline var

* revert jsonc file

* make repo dep of manager

* actually inject the thing

* accept all args for compatability checks

* accept compat from store

* pass os + arch vals

* don't inject fs

* tidy up

* tidy up

* merge with main and tidy fs storage

* fix test

* fix packages

* fix comment + field name

* update fs naming

* fixed wire

* remove unused func

* fix mocks

* fix storage test

* renaming

* fix log line

* fix test

* re-order field

* tidying

* add test for update with same version

* fix wire for CLI

* remove use of ioutil

* don't pass field

* small tidy

* ignore code scanning warn

* fix testdata link

* update lgtm code
This commit is contained in:
Will Browne
2022-08-23 11:50:50 +02:00
committed by GitHub
parent cc78486535
commit 26dfdd5af3
36 changed files with 1399 additions and 998 deletions
-31
View File
@@ -1,31 +0,0 @@
package installer
import (
"context"
"github.com/grafana/grafana/pkg/plugins"
)
// Service is responsible for managing plugins (add / remove) on the file system.
type Service interface {
// Install downloads the requested plugin in the provided file system location.
Install(ctx context.Context, pluginID, version, pluginsDir, pluginZipURL, pluginRepoURL string) error
// Uninstall removes the requested plugin from the provided file system location.
Uninstall(ctx context.Context, pluginDir string) error
// GetUpdateInfo provides update information for the requested plugin.
GetUpdateInfo(ctx context.Context, pluginID, version, pluginRepoURL string) (plugins.UpdateInfo, error)
}
type Logger interface {
Successf(format string, args ...interface{})
Failuref(format string, args ...interface{})
Info(args ...interface{})
Infof(format string, args ...interface{})
Debug(args ...interface{})
Debugf(format string, args ...interface{})
Warn(args ...interface{})
Warnf(format string, args ...interface{})
Error(args ...interface{})
Errorf(format string, args ...interface{})
}
-703
View File
@@ -1,703 +0,0 @@
package installer
import (
"archive/zip"
"bufio"
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
"github.com/grafana/grafana/pkg/plugins"
)
type Installer struct {
retryCount int
httpClient http.Client
httpClientNoTimeout http.Client
grafanaVersion string
log Logger
}
const (
permissionsDeniedMessage = "could not create %q, permission denied, make sure you have write access to plugin dir"
)
var (
reGitBuild = regexp.MustCompile("^[a-zA-Z0-9_.-]*/")
)
type Response4xxError struct {
Message string
StatusCode int
SystemInfo string
}
func (e Response4xxError) Error() string {
if len(e.Message) > 0 {
if len(e.SystemInfo) > 0 {
return fmt.Sprintf("%s (%s)", e.Message, e.SystemInfo)
}
return fmt.Sprintf("%d: %s", e.StatusCode, e.Message)
}
return fmt.Sprintf("%d", e.StatusCode)
}
type ErrVersionUnsupported struct {
PluginID string
RequestedVersion string
SystemInfo string
}
func (e ErrVersionUnsupported) Error() string {
return fmt.Sprintf("%s v%s is not supported on your system (%s)", e.PluginID, e.RequestedVersion, e.SystemInfo)
}
type ErrVersionNotFound struct {
PluginID string
RequestedVersion string
SystemInfo string
}
func (e ErrVersionNotFound) Error() string {
return fmt.Sprintf("%s v%s either does not exist or is not supported on your system (%s)", e.PluginID, e.RequestedVersion, e.SystemInfo)
}
func New(skipTLSVerify bool, grafanaVersion string, logger Logger) Service {
return &Installer{
httpClient: makeHttpClient(skipTLSVerify, 10*time.Second),
httpClientNoTimeout: makeHttpClient(skipTLSVerify, 0),
log: logger,
grafanaVersion: grafanaVersion,
}
}
// Install downloads the plugin code as a zip file from specified URL
// and then extracts the zip into the provided plugins directory.
func (i *Installer) Install(ctx context.Context, pluginID, version, pluginsDir, pluginZipURL, pluginRepoURL string) error {
var checksum string
if pluginZipURL == "" {
plugin, err := i.getPluginMetadataFromPluginRepo(pluginID, pluginRepoURL)
if err != nil {
return err
}
v, err := i.selectVersion(&plugin, version)
if err != nil {
return err
}
if version == "" {
version = v.Version
}
pluginZipURL = fmt.Sprintf("%s/%s/versions/%s/download",
pluginRepoURL,
pluginID,
version,
)
// Plugins which are downloaded just as sourcecode zipball from github do not have checksum
if v.Arch != nil {
archMeta, exists := v.Arch[osAndArchString()]
if !exists {
archMeta = v.Arch["any"]
}
checksum = archMeta.SHA256
}
}
i.log.Debugf("Installing plugin\nfrom: %s\ninto: %s", pluginZipURL, pluginsDir)
// Create temp file for downloading zip file
tmpFile, err := os.CreateTemp("", "*.zip")
if err != nil {
return fmt.Errorf("%v: %w", "failed to create temporary file", err)
}
defer func() {
if err := os.Remove(tmpFile.Name()); err != nil {
i.log.Warn("Failed to remove temporary file", "file", tmpFile.Name(), "err", err)
}
}()
err = i.DownloadFile(pluginID, tmpFile, pluginZipURL, checksum)
if err != nil {
if err := tmpFile.Close(); err != nil {
i.log.Warn("Failed to close file", "err", err)
}
return fmt.Errorf("%v: %w", "failed to download plugin archive", err)
}
err = tmpFile.Close()
if err != nil {
return fmt.Errorf("%v: %w", "failed to close tmp file", err)
}
err = i.extractFiles(tmpFile.Name(), pluginID, pluginsDir)
if err != nil {
return fmt.Errorf("%v: %w", "failed to extract plugin archive", err)
}
res, _ := toPluginDTO(pluginsDir, pluginID)
i.log.Successf("Downloaded %s v%s zip successfully", res.ID, res.Info.Version)
// download dependency plugins
for _, dep := range res.Dependencies.Plugins {
i.log.Infof("Fetching %s dependencies...", res.ID)
if err := i.Install(ctx, dep.ID, normalizeVersion(dep.Version), pluginsDir, "", pluginRepoURL); err != nil {
return fmt.Errorf("failed to install plugin %s: %w", dep.ID, err)
}
}
return err
}
// Uninstall removes the specified plugin from the provided plugin directory.
func (i *Installer) Uninstall(ctx context.Context, pluginDir string) error {
// verify it's a plugin directory
if _, err := os.Stat(filepath.Join(pluginDir, "plugin.json")); err != nil {
if os.IsNotExist(err) {
if _, err := os.Stat(filepath.Join(pluginDir, "dist", "plugin.json")); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("tried to remove %s, but it doesn't seem to be a plugin", pluginDir)
}
}
}
}
i.log.Infof("Uninstalling plugin %v", pluginDir)
return os.RemoveAll(pluginDir)
}
func (i *Installer) DownloadFile(pluginID string, tmpFile *os.File, url string, checksum string) (err error) {
// Try handling URL as a local file path first
if _, err := os.Stat(url); err == nil {
// We can ignore this gosec G304 warning since `url` stems from command line flag "pluginUrl". If the
// user shouldn't be able to read the file, it should be handled through filesystem permissions.
// nolint:gosec
f, err := os.Open(url)
if err != nil {
return fmt.Errorf("%v: %w", "Failed to read plugin archive", err)
}
defer func() {
if err := f.Close(); err != nil {
i.log.Warn("Failed to close file", "err", err)
}
}()
_, err = io.Copy(tmpFile, f)
if err != nil {
return fmt.Errorf("%v: %w", "Failed to copy plugin archive", err)
}
return nil
}
i.retryCount = 0
defer func() {
if r := recover(); r != nil {
i.retryCount++
if i.retryCount < 3 {
i.log.Debug("Failed downloading. Will retry once.")
err = tmpFile.Truncate(0)
if err != nil {
return
}
_, err = tmpFile.Seek(0, 0)
if err != nil {
return
}
err = i.DownloadFile(pluginID, tmpFile, url, checksum)
} else {
i.retryCount = 0
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)
}
}
}
}()
// 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.
bodyReader, err := i.sendRequestWithoutTimeout(url)
if err != nil {
return err
}
defer func() {
if err := bodyReader.Close(); err != nil {
i.log.Warn("Failed to close body", "err", err)
}
}()
w := bufio.NewWriter(tmpFile)
h := sha256.New()
if _, err = io.Copy(w, io.TeeReader(bodyReader, h)); err != nil {
return fmt.Errorf("%v: %w", "failed to compute SHA256 checksum", err)
}
if err := w.Flush(); err != nil {
return fmt.Errorf("failed to write to %q: %w", tmpFile.Name(), err)
}
if len(checksum) > 0 && checksum != fmt.Sprintf("%x", h.Sum(nil)) {
return fmt.Errorf("expected SHA256 checksum does not match the downloaded archive - please contact security@grafana.com")
}
return nil
}
func (i *Installer) getPluginMetadataFromPluginRepo(pluginID, pluginRepoURL string) (Plugin, error) {
i.log.Debugf("Fetching metadata for plugin \"%s\" from repo %s", pluginID, pluginRepoURL)
body, err := i.sendRequestGetBytes(pluginRepoURL, "repo", pluginID)
if err != nil {
return Plugin{}, err
}
var data Plugin
err = json.Unmarshal(body, &data)
if err != nil {
i.log.Error("Failed to unmarshal plugin repo response error", err)
return Plugin{}, err
}
return data, nil
}
func (i *Installer) sendRequestGetBytes(URL string, subPaths ...string) ([]byte, error) {
bodyReader, err := i.sendRequest(URL, subPaths...)
if err != nil {
return []byte{}, err
}
defer func() {
if err := bodyReader.Close(); err != nil {
i.log.Warn("Failed to close stream", "err", err)
}
}()
return io.ReadAll(bodyReader)
}
func (i *Installer) sendRequest(URL string, subPaths ...string) (io.ReadCloser, error) {
req, err := i.createRequest(URL, subPaths...)
if err != nil {
return nil, err
}
res, err := i.httpClient.Do(req)
if err != nil {
return nil, err
}
return i.handleResponse(res)
}
func (i *Installer) sendRequestWithoutTimeout(URL string, subPaths ...string) (io.ReadCloser, error) {
req, err := i.createRequest(URL, subPaths...)
if err != nil {
return nil, err
}
res, err := i.httpClientNoTimeout.Do(req)
if err != nil {
return nil, err
}
return i.handleResponse(res)
}
func (i *Installer) createRequest(URL string, subPaths ...string) (*http.Request, error) {
u, err := url.Parse(URL)
if err != nil {
return nil, err
}
for _, v := range subPaths {
u.Path = path.Join(u.Path, v)
}
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("grafana-version", i.grafanaVersion)
req.Header.Set("grafana-os", runtime.GOOS)
req.Header.Set("grafana-arch", runtime.GOARCH)
req.Header.Set("User-Agent", "grafana "+i.grafanaVersion)
return req, err
}
func (i *Installer) handleResponse(res *http.Response) (io.ReadCloser, error) {
if res.StatusCode/100 == 4 {
body, err := io.ReadAll(res.Body)
defer func() {
if err := res.Body.Close(); err != nil {
i.log.Warn("Failed to close response body", "err", err)
}
}()
if err != nil || len(body) == 0 {
return nil, Response4xxError{StatusCode: res.StatusCode}
}
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 nil, Response4xxError{StatusCode: res.StatusCode, Message: message, SystemInfo: i.fullSystemInfoString()}
}
if res.StatusCode/100 != 2 {
return nil, fmt.Errorf("API returned invalid status: %s", res.Status)
}
return res.Body, nil
}
func makeHttpClient(skipTLSVerify bool, timeout time.Duration) http.Client {
tr := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: skipTLSVerify,
},
}
return http.Client{
Timeout: timeout,
Transport: tr,
}
}
func normalizeVersion(version string) string {
normalized := strings.ReplaceAll(version, " ", "")
if strings.HasPrefix(normalized, "^") || strings.HasPrefix(normalized, "v") {
return normalized[1:]
}
return normalized
}
func (i *Installer) GetUpdateInfo(ctx context.Context, pluginID, version, pluginRepoURL string) (plugins.UpdateInfo, error) {
plugin, err := i.getPluginMetadataFromPluginRepo(pluginID, pluginRepoURL)
if err != nil {
return plugins.UpdateInfo{}, err
}
v, err := i.selectVersion(&plugin, version)
if err != nil {
return plugins.UpdateInfo{}, err
}
return plugins.UpdateInfo{
PluginZipURL: fmt.Sprintf("%s/%s/versions/%s/download", pluginRepoURL, pluginID, v.Version),
}, nil
}
// selectVersion selects the most appropriate plugin version
// returns the specified version if supported.
// returns latest version if no specific version is specified.
// returns error if the supplied version does not exist.
// returns error if supplied version exists but is not supported.
// NOTE: It expects plugin.Versions to be sorted so the newest version is first.
func (i *Installer) selectVersion(plugin *Plugin, version string) (*Version, error) {
var ver Version
latestForArch := latestSupportedVersion(plugin)
if latestForArch == nil {
return nil, ErrVersionUnsupported{
PluginID: plugin.ID,
RequestedVersion: version,
SystemInfo: i.fullSystemInfoString(),
}
}
if version == "" {
return latestForArch, nil
}
for _, v := range plugin.Versions {
if v.Version == version {
ver = v
break
}
}
if len(ver.Version) == 0 {
i.log.Debugf("Requested plugin version %s v%s not found but potential fallback version '%s' was found",
plugin.ID, version, latestForArch.Version)
return nil, ErrVersionNotFound{
PluginID: plugin.ID,
RequestedVersion: version,
SystemInfo: i.fullSystemInfoString(),
}
}
if !supportsCurrentArch(&ver) {
i.log.Debugf("Requested plugin version %s v%s not found but potential fallback version '%s' was found",
plugin.ID, version, latestForArch.Version)
return nil, ErrVersionUnsupported{
PluginID: plugin.ID,
RequestedVersion: version,
SystemInfo: i.fullSystemInfoString(),
}
}
return &ver, nil
}
func (i *Installer) fullSystemInfoString() string {
return fmt.Sprintf("Grafana v%s %s", i.grafanaVersion, osAndArchString())
}
func osAndArchString() string {
osString := strings.ToLower(runtime.GOOS)
arch := runtime.GOARCH
return osString + "-" + arch
}
func supportsCurrentArch(version *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 *Plugin) *Version {
for _, v := range plugin.Versions {
ver := v
if supportsCurrentArch(&ver) {
return &ver
}
}
return nil
}
func (i *Installer) extractFiles(archiveFile string, pluginID string, dest string) error {
var err error
dest, err = filepath.Abs(dest)
if err != nil {
return err
}
i.log.Debug(fmt.Sprintf("Extracting archive %q to %q...", archiveFile, dest))
existingInstallDir := filepath.Join(dest, pluginID)
if _, err := os.Stat(existingInstallDir); !os.IsNotExist(err) {
i.log.Debugf("Removing existing installation of plugin %s", existingInstallDir)
err = os.RemoveAll(existingInstallDir)
if err != nil {
return err
}
}
r, err := zip.OpenReader(archiveFile)
if err != nil {
return err
}
defer func() {
if err := r.Close(); err != nil {
i.log.Warn("failed to close zip file", "err", err)
}
}()
for _, zf := range r.File {
// We can ignore gosec G305 here since we check for the ZipSlip vulnerability below
// nolint:gosec
fullPath := filepath.Join(dest, zf.Name)
// Check for ZipSlip. More Info: http://bit.ly/2MsjAWE
if filepath.IsAbs(zf.Name) ||
!strings.HasPrefix(fullPath, filepath.Clean(dest)+string(os.PathSeparator)) ||
strings.HasPrefix(zf.Name, ".."+string(os.PathSeparator)) {
return fmt.Errorf(
"archive member %q tries to write outside of plugin directory: %q, this can be a security risk",
zf.Name, dest)
}
dstPath := filepath.Clean(filepath.Join(dest, removeGitBuildFromName(zf.Name, pluginID)))
if zf.FileInfo().IsDir() {
// We can ignore gosec G304 here since it makes sense to give all users read access
// nolint:gosec
if err := os.MkdirAll(dstPath, 0755); err != nil {
if os.IsPermission(err) {
return fmt.Errorf(permissionsDeniedMessage, dstPath)
}
return err
}
continue
}
// Create needed directories to extract file
// We can ignore gosec G304 here since it makes sense to give all users read access
// nolint:gosec
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
return fmt.Errorf("%v: %w", "failed to create directory to extract plugin files", err)
}
if isSymlink(zf) {
if err := extractSymlink(existingInstallDir, zf, dstPath); err != nil {
i.log.Warn("failed to extract symlink", "err", err)
continue
}
continue
}
if err := extractFile(zf, dstPath); err != nil {
return fmt.Errorf("%v: %w", "failed to extract file", err)
}
}
return nil
}
func isSymlink(file *zip.File) bool {
return file.Mode()&os.ModeSymlink == os.ModeSymlink
}
func extractSymlink(basePath string, file *zip.File, filePath string) error {
// symlink target is the contents of the file
src, err := file.Open()
if err != nil {
return fmt.Errorf("%v: %w", "failed to extract file", err)
}
buf := new(bytes.Buffer)
if _, err := io.Copy(buf, src); err != nil {
return fmt.Errorf("%v: %w", "failed to copy symlink contents", err)
}
symlinkPath := strings.TrimSpace(buf.String())
if !isSymlinkRelativeTo(basePath, symlinkPath, filePath) {
return fmt.Errorf("symlink %q pointing outside plugin directory is not allowed", filePath)
}
if err := os.Symlink(symlinkPath, filePath); err != nil {
return fmt.Errorf("failed to make symbolic link for %v: %w", filePath, err)
}
return nil
}
// isSymlinkRelativeTo checks whether symlinkDestPath is relative to basePath.
// symlinkOrigPath is the path to file holding the symbolic link.
func isSymlinkRelativeTo(basePath string, symlinkDestPath string, symlinkOrigPath string) bool {
if filepath.IsAbs(symlinkDestPath) {
return false
} else {
fileDir := filepath.Dir(symlinkOrigPath)
cleanPath := filepath.Clean(filepath.Join(fileDir, "/", symlinkDestPath))
p, err := filepath.Rel(basePath, cleanPath)
if err != nil {
return false
}
if p == ".." || strings.HasPrefix(p, ".."+string(filepath.Separator)) {
return false
}
}
return true
}
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)
}
// We can ignore the gosec G304 warning on this one, since the variable part of the file path stems
// from command line flag "pluginsDir", and the only possible damage would be writing to the wrong directory.
// If the user shouldn't be writing to this directory, they shouldn't have the permission in the file system.
// nolint:gosec
dst, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileMode)
if err != nil {
if os.IsPermission(err) {
return fmt.Errorf(permissionsDeniedMessage, filePath)
}
unwrappedError := errors.Unwrap(err)
if unwrappedError != nil && strings.EqualFold(unwrappedError.Error(), "text file busy") {
return fmt.Errorf("file %q is in use - please stop Grafana, install the plugin and restart Grafana", filePath)
}
return fmt.Errorf("%v: %w", "failed to open file", err)
}
defer func() {
err = dst.Close()
}()
src, err := file.Open()
if err != nil {
return fmt.Errorf("%v: %w", "failed to extract file", err)
}
defer func() {
err = src.Close()
}()
_, err = io.Copy(dst, src)
return err
}
func removeGitBuildFromName(filename, pluginID string) string {
return reGitBuild.ReplaceAllString(filename, pluginID+"/")
}
func toPluginDTO(pluginDir, pluginID string) (InstalledPlugin, error) {
distPluginDataPath := filepath.Join(pluginDir, pluginID, "dist", "plugin.json")
// It's safe to ignore gosec warning G304 since the file path suffix is hardcoded
// nolint:gosec
data, err := os.ReadFile(distPluginDataPath)
if err != nil {
pluginDataPath := filepath.Join(pluginDir, pluginID, "plugin.json")
// It's safe to ignore gosec warning G304 since the file path suffix is hardcoded
// nolint:gosec
data, err = os.ReadFile(pluginDataPath)
if err != nil {
return InstalledPlugin{}, errors.New("Could not find dist/plugin.json or plugin.json on " + pluginID + " in " + pluginDir)
}
}
res := InstalledPlugin{}
if err := json.Unmarshal(data, &res); err != nil {
return res, err
}
if res.Info.Version == "" {
res.Info.Version = "0.0.0"
}
if res.ID == "" {
return InstalledPlugin{}, errors.New("could not find plugin " + pluginID + " in " + pluginDir)
}
return res, nil
}
@@ -1,390 +0,0 @@
package installer
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/require"
)
func TestInstall(t *testing.T) {
testDir := "./testdata/tmpInstallPluginDir"
err := os.Mkdir(testDir, os.ModePerm)
require.NoError(t, err)
t.Cleanup(func() {
err = os.RemoveAll(testDir)
require.NoError(t, err)
})
pluginID := "test-app"
i := &Installer{log: &fakeLogger{}}
err = i.Install(context.Background(), pluginID, "", testDir, "./testdata/plugin-with-symlinks.zip", "")
require.NoError(t, err)
// verify extracted contents
files, err := os.ReadDir(filepath.Join(testDir, pluginID))
require.NoError(t, err)
file2, err := files[2].Info()
require.NoError(t, err)
file4, err := files[4].Info()
require.NoError(t, err)
require.Len(t, files, 6)
require.Equal(t, files[0].Name(), "MANIFEST.txt")
require.Equal(t, files[1].Name(), "dashboards")
require.Equal(t, files[2].Name(), "extra")
require.Equal(t, os.ModeSymlink, file2.Mode()&os.ModeSymlink)
require.Equal(t, files[3].Name(), "plugin.json")
require.Equal(t, files[4].Name(), "symlink_to_txt")
require.Equal(t, os.ModeSymlink, file4.Mode()&os.ModeSymlink)
require.Equal(t, files[5].Name(), "text.txt")
}
func TestUninstall(t *testing.T) {
i := &Installer{log: &fakeLogger{}}
pluginDir := t.TempDir()
pluginJSON := filepath.Join(pluginDir, "plugin.json")
_, err := os.Create(pluginJSON)
require.NoError(t, err)
err = i.Uninstall(context.Background(), pluginDir)
require.NoError(t, err)
_, err = os.Stat(pluginDir)
require.True(t, os.IsNotExist(err))
t.Run("Uninstall will search in nested dir folder for plugin.json", func(t *testing.T) {
pluginDistDir := filepath.Join(t.TempDir(), "dist")
err = os.Mkdir(pluginDistDir, os.ModePerm)
require.NoError(t, err)
pluginJSON = filepath.Join(pluginDistDir, "plugin.json")
_, err = os.Create(pluginJSON)
require.NoError(t, err)
pluginDir = filepath.Dir(pluginDistDir)
err = i.Uninstall(context.Background(), pluginDir)
require.NoError(t, err)
_, err = os.Stat(pluginDir)
require.True(t, os.IsNotExist(err))
})
t.Run("Uninstall will not delete folder if cannot recognize plugin structure", func(t *testing.T) {
pluginDir = t.TempDir()
err = i.Uninstall(context.Background(), pluginDir)
require.EqualError(t, err, fmt.Sprintf("tried to remove %s, but it doesn't seem to be a plugin", pluginDir))
_, err = os.Stat(pluginDir)
require.False(t, os.IsNotExist(err))
})
}
func TestExtractFiles(t *testing.T) {
i := &Installer{log: &fakeLogger{}}
pluginsDir := setupFakePluginsDir(t)
t.Run("Should preserve file permissions for plugin backend binaries for linux and darwin", func(t *testing.T) {
skipWindows(t)
archive := filepath.Join("testdata", "grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip")
err := i.extractFiles(archive, "grafana-simple-json-datasource", pluginsDir)
require.NoError(t, err)
// File in zip has permissions 755
fileInfo, err := os.Stat(filepath.Join(pluginsDir, "grafana-simple-json-datasource", "simple-plugin_darwin_amd64"))
require.NoError(t, err)
require.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String())
// File in zip has permission 755
fileInfo, err = os.Stat(pluginsDir + "/grafana-simple-json-datasource/simple-plugin_linux_amd64")
require.NoError(t, err)
require.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String())
// File in zip has permission 644
fileInfo, err = os.Stat(pluginsDir + "/grafana-simple-json-datasource/simple-plugin_windows_amd64.exe")
require.NoError(t, err)
require.Equal(t, "-rw-r--r--", fileInfo.Mode().String())
// File in zip has permission 755
fileInfo, err = os.Stat(pluginsDir + "/grafana-simple-json-datasource/non-plugin-binary")
require.NoError(t, err)
require.Equal(t, "-rwxr-xr-x", fileInfo.Mode().String())
})
t.Run("Should extract file with relative symlink", func(t *testing.T) {
skipWindows(t)
err := i.extractFiles("testdata/plugin-with-symlink.zip", "plugin-with-symlink", pluginsDir)
require.NoError(t, err)
_, err = os.Stat(pluginsDir + "/plugin-with-symlink/symlink_to_txt")
require.NoError(t, err)
target, err := filepath.EvalSymlinks(pluginsDir + "/plugin-with-symlink/symlink_to_txt")
require.NoError(t, err)
require.Equal(t, pluginsDir+"/plugin-with-symlink/text.txt", target)
})
t.Run("Should extract directory with relative symlink", func(t *testing.T) {
skipWindows(t)
err := i.extractFiles("testdata/plugin-with-symlink-dir.zip", "plugin-with-symlink-dir", pluginsDir)
require.NoError(t, err)
_, err = os.Stat(pluginsDir + "/plugin-with-symlink-dir/symlink_to_dir")
require.NoError(t, err)
target, err := filepath.EvalSymlinks(pluginsDir + "/plugin-with-symlink-dir/symlink_to_dir")
require.NoError(t, err)
require.Equal(t, pluginsDir+"/plugin-with-symlink-dir/dir", target)
})
t.Run("Should not extract file with absolute symlink", func(t *testing.T) {
skipWindows(t)
err := i.extractFiles("testdata/plugin-with-absolute-symlink.zip", "plugin-with-absolute-symlink", pluginsDir)
require.NoError(t, err)
_, err = os.Stat(pluginsDir + "/plugin-with-absolute-symlink/test.txt")
require.True(t, os.IsNotExist(err))
})
t.Run("Should not extract directory with absolute symlink", func(t *testing.T) {
skipWindows(t)
err := i.extractFiles("testdata/plugin-with-absolute-symlink-dir.zip", "plugin-with-absolute-symlink-dir", pluginsDir)
require.NoError(t, err)
_, err = os.Stat(pluginsDir + "/plugin-with-absolute-symlink-dir/target")
require.True(t, os.IsNotExist(err))
})
t.Run("Should detect if archive members point outside of the destination directory", func(t *testing.T) {
err := i.extractFiles("testdata/plugin-with-parent-member.zip", "plugin-with-parent-member", pluginsDir)
require.EqualError(t, err, fmt.Sprintf(
`archive member "../member.txt" tries to write outside of plugin directory: %q, this can be a security risk`,
pluginsDir,
))
})
t.Run("Should detect if archive members are absolute", func(t *testing.T) {
err := i.extractFiles("testdata/plugin-with-absolute-member.zip", "plugin-with-absolute-member", pluginsDir)
require.EqualError(t, err, fmt.Sprintf(
`archive member "/member.txt" tries to write outside of plugin directory: %q, this can be a security risk`,
pluginsDir,
))
})
}
func TestSelectVersion(t *testing.T) {
i := &Installer{log: &fakeLogger{}}
t.Run("Should return error when requested version does not exist", func(t *testing.T) {
_, err := i.selectVersion(createPlugin(versionArg{version: "version"}), "1.1.1")
require.Error(t, err)
})
t.Run("Should return error when no version supports current arch", func(t *testing.T) {
_, err := i.selectVersion(createPlugin(versionArg{version: "version", arch: []string{"non-existent"}}), "")
require.Error(t, err)
})
t.Run("Should return error when requested version does not support current arch", func(t *testing.T) {
_, err := i.selectVersion(createPlugin(
versionArg{version: "2.0.0"},
versionArg{version: "1.1.1", arch: []string{"non-existent"}},
), "1.1.1")
require.Error(t, err)
})
t.Run("Should return latest available for arch when no version specified", func(t *testing.T) {
ver, err := i.selectVersion(createPlugin(
versionArg{version: "2.0.0", arch: []string{"non-existent"}},
versionArg{version: "1.0.0"},
), "")
require.NoError(t, err)
require.Equal(t, "1.0.0", ver.Version)
})
t.Run("Should return latest version when no version specified", func(t *testing.T) {
ver, err := i.selectVersion(createPlugin(versionArg{version: "2.0.0"}, versionArg{version: "1.0.0"}), "")
require.NoError(t, err)
require.Equal(t, "2.0.0", ver.Version)
})
t.Run("Should return requested version", func(t *testing.T) {
ver, err := i.selectVersion(createPlugin(versionArg{version: "2.0.0"}, versionArg{version: "1.0.0"}), "1.0.0")
require.NoError(t, err)
require.Equal(t, "1.0.0", ver.Version)
})
}
func TestRemoveGitBuildFromName(t *testing.T) {
// The root directory should get renamed to the plugin name
paths := map[string]string{
"datasource-plugin-kairosdb-cc4a3965ef5d3eb1ae0ee4f93e9e78ec7db69e64/": "datasource-kairosdb/",
"datasource-plugin-kairosdb-cc4a3965ef5d3eb1ae0ee4f93e9e78ec7db69e64/README.md": "datasource-kairosdb/README.md",
"datasource-plugin-kairosdb-cc4a3965ef5d3eb1ae0ee4f93e9e78ec7db69e64/partials/": "datasource-kairosdb/partials/",
"datasource-plugin-kairosdb-cc4a3965ef5d3eb1ae0ee4f93e9e78ec7db69e64/partials/config.html": "datasource-kairosdb/partials/config.html",
}
for p, exp := range paths {
name := removeGitBuildFromName(p, "datasource-kairosdb")
require.Equal(t, exp, name)
}
}
func TestIsSymlinkRelativeTo(t *testing.T) {
tcs := []struct {
desc string
basePath string
symlinkDestPath string
symlinkOrigPath string
expected bool
}{
{
desc: "Symbolic link pointing to relative file within basePath should return true",
basePath: "/dir",
symlinkDestPath: "test.txt",
symlinkOrigPath: "/dir/sub-dir/test1.txt",
expected: true,
},
{
desc: "Symbolic link pointing to relative file within basePath should return true",
basePath: "/dir",
symlinkDestPath: "test.txt",
symlinkOrigPath: "/dir/test1.txt",
expected: true,
},
{
desc: "Symbolic link pointing to relative file within basePath should return true",
basePath: "/dir",
symlinkDestPath: "../etc/test.txt",
symlinkOrigPath: "/dir/sub-dir/test1.txt",
expected: true,
},
{
desc: "Symbolic link pointing to absolute directory outside basePath should return false",
basePath: "/dir",
symlinkDestPath: "/etc/test.txt",
symlinkOrigPath: "/dir/sub-dir/test1.txt",
expected: false,
},
{
desc: "Symbolic link pointing to relative file outside basePath should return false",
basePath: "/dir",
symlinkDestPath: "../../etc/test.txt",
symlinkOrigPath: "/dir/sub-dir/test1.txt",
expected: false,
},
{
desc: "Symbolic link pointing to relative file outside basePath should return false",
basePath: "/dir",
symlinkDestPath: "../../",
symlinkOrigPath: "/dir/sub-sir/symlink.txt",
expected: false,
},
{
desc: "Symbolic link pointing to relative file outside basePath should return false",
basePath: "/dir",
symlinkDestPath: "../..",
symlinkOrigPath: "/dir/sub-sir/symlink.txt",
expected: false,
},
{
desc: "Symbolic link pointing to relative file outside basePath should return false",
basePath: "/dir",
symlinkDestPath: "../../",
symlinkOrigPath: "/dir/sub-sir/",
expected: false,
},
{
desc: "Symbolic link pointing to relative file outside basePath should return false",
basePath: "/dir",
symlinkDestPath: "../..",
symlinkOrigPath: "/dir/sub-sir/",
expected: false,
},
}
for _, tc := range tcs {
t.Run(tc.desc, func(t *testing.T) {
actual := isSymlinkRelativeTo(tc.basePath, tc.symlinkDestPath, tc.symlinkOrigPath)
require.Equal(t, tc.expected, actual)
})
}
}
func setupFakePluginsDir(t *testing.T) string {
dir := "testdata/fake-plugins-dir"
err := os.RemoveAll(dir)
require.NoError(t, err)
err = os.MkdirAll(dir, 0750)
require.NoError(t, err)
t.Cleanup(func() {
err = os.RemoveAll(dir)
require.NoError(t, err)
})
dir, err = filepath.Abs(dir)
require.NoError(t, err)
return dir
}
func skipWindows(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Skipping test on Windows")
}
}
type versionArg struct {
version string
arch []string
}
func createPlugin(versions ...versionArg) *Plugin {
p := &Plugin{
Versions: []Version{},
}
for _, version := range versions {
ver := Version{
Version: version.version,
Commit: fmt.Sprintf("commit_%s", version.version),
URL: fmt.Sprintf("url_%s", version.version),
}
if version.arch != nil {
ver.Arch = map[string]ArchMeta{}
for _, arch := range version.arch {
ver.Arch[arch] = ArchMeta{
SHA256: fmt.Sprintf("sha256_%s", arch),
}
}
}
p.Versions = append(p.Versions, ver)
}
return p
}
type fakeLogger struct{}
func (f *fakeLogger) Successf(_ string, _ ...interface{}) {}
func (f *fakeLogger) Failuref(_ string, _ ...interface{}) {}
func (f *fakeLogger) Info(_ ...interface{}) {}
func (f *fakeLogger) Infof(_ string, _ ...interface{}) {}
func (f *fakeLogger) Debug(_ ...interface{}) {}
func (f *fakeLogger) Debugf(_ string, _ ...interface{}) {}
func (f *fakeLogger) Warn(_ ...interface{}) {}
func (f *fakeLogger) Warnf(_ string, _ ...interface{}) {}
func (f *fakeLogger) Error(_ ...interface{}) {}
func (f *fakeLogger) Errorf(_ string, _ ...interface{}) {}
-48
View File
@@ -1,48 +0,0 @@
package installer
type InstalledPlugin struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Info PluginInfo `json:"info"`
Dependencies Dependencies `json:"dependencies"`
}
type Dependencies struct {
GrafanaVersion string `json:"grafanaVersion"`
Plugins []PluginDependency `json:"plugins"`
}
type PluginDependency struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Version string `json:"version"`
}
type PluginInfo struct {
Version string `json:"version"`
Updated string `json:"updated"`
}
type Plugin struct {
ID string `json:"id"`
Category string `json:"category"`
Versions []Version `json:"versions"`
}
type Version struct {
Commit string `json:"commit"`
URL string `json:"url"`
Version string `json:"version"`
Arch map[string]ArchMeta `json:"arch"`
}
type ArchMeta struct {
SHA256 string `json:"sha256"`
}
type PluginRepo struct {
Plugins []Plugin `json:"plugins"`
Version string `json:"version"`
}
Binary file not shown.
Binary file not shown.
-64
View File
@@ -1,64 +0,0 @@
package manager
import (
"fmt"
"github.com/grafana/grafana/pkg/infra/log"
)
type InfraLogWrapper struct {
l log.Logger
debugMode bool
}
func newInstallerLogger(name string, debugMode bool) (l *InfraLogWrapper) {
return &InfraLogWrapper{
debugMode: debugMode,
l: log.New(name),
}
}
func (l *InfraLogWrapper) Successf(format string, args ...interface{}) {
l.l.Info(fmt.Sprintf(format, args...))
}
func (l *InfraLogWrapper) Failuref(format string, args ...interface{}) {
l.l.Error(fmt.Sprintf(format, args...))
}
func (l *InfraLogWrapper) Info(args ...interface{}) {
l.l.Info(fmt.Sprint(args...))
}
func (l *InfraLogWrapper) Infof(format string, args ...interface{}) {
l.l.Info(fmt.Sprintf(format, args...))
}
func (l *InfraLogWrapper) Debug(args ...interface{}) {
if l.debugMode {
l.l.Debug(fmt.Sprint(args...))
}
}
func (l *InfraLogWrapper) Debugf(format string, args ...interface{}) {
if l.debugMode {
l.l.Debug(fmt.Sprintf(format, args...))
}
}
func (l *InfraLogWrapper) Warn(args ...interface{}) {
l.l.Warn(fmt.Sprint(args...))
}
func (l *InfraLogWrapper) Warnf(format string, args ...interface{}) {
l.l.Warn(fmt.Sprintf(format, args...))
}
func (l *InfraLogWrapper) Error(args ...interface{}) {
l.l.Error(fmt.Sprint(args...))
}
func (l *InfraLogWrapper) Errorf(format string, args ...interface{}) {
l.l.Error(fmt.Sprintf(format, args...))
}
+24 -21
View File
@@ -10,16 +10,15 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
"github.com/grafana/grafana/pkg/plugins/manager/installer"
"github.com/grafana/grafana/pkg/plugins/logger"
"github.com/grafana/grafana/pkg/plugins/manager/loader"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/repo"
"github.com/grafana/grafana/pkg/plugins/storage"
"github.com/grafana/grafana/pkg/setting"
)
const (
grafanaComURL = "https://grafana.com/api/plugins"
)
var _ plugins.Manager = (*PluginManager)(nil)
var _ plugins.Client = (*PluginManager)(nil)
var _ plugins.Store = (*PluginManager)(nil)
var _ plugins.StaticRouteResolver = (*PluginManager)(nil)
@@ -27,13 +26,14 @@ var _ plugins.RendererManager = (*PluginManager)(nil)
var _ plugins.SecretsPluginManager = (*PluginManager)(nil)
type PluginManager struct {
cfg *plugins.Cfg
pluginRegistry registry.Service
pluginInstaller installer.Service
pluginLoader loader.Service
pluginsMu sync.RWMutex
pluginSources []PluginSource
log log.Logger
cfg *plugins.Cfg
pluginRegistry registry.Service
pluginLoader loader.Service
pluginRepo repo.Service
pluginSources []PluginSource
pluginStorage storage.Manager
pluginsMu sync.RWMutex
log log.Logger
}
type PluginSource struct {
@@ -41,26 +41,29 @@ type PluginSource struct {
Paths []string
}
func ProvideService(grafanaCfg *setting.Cfg, pluginRegistry registry.Service, pluginLoader loader.Service) (*PluginManager, error) {
func ProvideService(grafanaCfg *setting.Cfg, pluginRegistry registry.Service, pluginLoader loader.Service,
pluginRepo repo.Service) (*PluginManager, error) {
pm := New(plugins.FromGrafanaCfg(grafanaCfg), pluginRegistry, []PluginSource{
{Class: plugins.Core, Paths: corePluginPaths(grafanaCfg)},
{Class: plugins.Bundled, Paths: []string{grafanaCfg.BundledPluginsPath}},
{Class: plugins.External, Paths: append([]string{grafanaCfg.PluginsPath}, pluginSettingPaths(grafanaCfg)...)},
}, pluginLoader)
}, pluginLoader, pluginRepo, storage.FileSystem(logger.NewLogger("plugin.fs"), grafanaCfg.PluginsPath))
if err := pm.Init(); err != nil {
return nil, err
}
return pm, nil
}
func New(cfg *plugins.Cfg, pluginRegistry registry.Service, pluginSources []PluginSource, pluginLoader loader.Service) *PluginManager {
func New(cfg *plugins.Cfg, pluginRegistry registry.Service, pluginSources []PluginSource, pluginLoader loader.Service,
pluginRepo repo.Service, pluginFs storage.Manager) *PluginManager {
return &PluginManager{
cfg: cfg,
pluginLoader: pluginLoader,
pluginSources: pluginSources,
pluginRegistry: pluginRegistry,
log: log.New("plugin.manager"),
pluginInstaller: installer.New(false, cfg.BuildVersion, newInstallerLogger("plugin.installer", true)),
cfg: cfg,
pluginLoader: pluginLoader,
pluginSources: pluginSources,
pluginRegistry: pluginRegistry,
pluginRepo: pluginRepo,
pluginStorage: pluginFs,
log: log.New("plugin.manager"),
}
}
@@ -93,7 +93,7 @@ func TestPluginManager_int_init(t *testing.T) {
pmCfg := plugins.FromGrafanaCfg(cfg)
pm, err := ProvideService(cfg, registry.NewInMemory(), loader.New(pmCfg, license, signature.NewUnsignedAuthorizer(pmCfg),
provider.ProvideService(coreRegistry)))
provider.ProvideService(coreRegistry)), nil)
require.NoError(t, err)
ctx := context.Background()
+166 -65
View File
@@ -1,8 +1,11 @@
package manager
import (
"archive/zip"
"context"
"net/http"
"os"
"path/filepath"
"sync"
"testing"
"time"
@@ -16,6 +19,8 @@ import (
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/repo"
"github.com/grafana/grafana/pkg/plugins/storage"
)
const (
@@ -29,7 +34,7 @@ func TestPluginManager_Init(t *testing.T) {
{Class: plugins.Bundled, Paths: []string{"path1"}},
{Class: plugins.Core, Paths: []string{"path2"}},
{Class: plugins.External, Paths: []string{"path3"}},
}, loader)
}, loader, &fakePluginRepo{}, &fakeFsManager{})
err := pm.Init()
require.NoError(t, err)
@@ -39,7 +44,9 @@ func TestPluginManager_Init(t *testing.T) {
func TestPluginManager_loadPlugins(t *testing.T) {
t.Run("Managed backend plugin", func(t *testing.T) {
p, pc := createPlugin(t, testPluginID, "", plugins.External, true, true)
p, pc := createPlugin(t, testPluginID, plugins.External, true, func(p *plugins.Plugin) {
p.Backend = true
})
loader := &fakeLoader{
mockedLoadedPlugins: []*plugins.Plugin{p},
@@ -65,7 +72,9 @@ func TestPluginManager_loadPlugins(t *testing.T) {
})
t.Run("Unmanaged backend plugin", func(t *testing.T) {
p, pc := createPlugin(t, testPluginID, "", plugins.External, false, true)
p, pc := createPlugin(t, testPluginID, plugins.External, false, func(p *plugins.Plugin) {
p.Backend = true
})
loader := &fakeLoader{
mockedLoadedPlugins: []*plugins.Plugin{p},
@@ -91,7 +100,9 @@ func TestPluginManager_loadPlugins(t *testing.T) {
})
t.Run("Managed non-backend plugin", func(t *testing.T) {
p, pc := createPlugin(t, testPluginID, "", plugins.External, false, true)
p, pc := createPlugin(t, testPluginID, plugins.External, false, func(p *plugins.Plugin) {
p.Backend = true
})
loader := &fakeLoader{
mockedLoadedPlugins: []*plugins.Plugin{p},
@@ -117,7 +128,7 @@ func TestPluginManager_loadPlugins(t *testing.T) {
})
t.Run("Unmanaged non-backend plugin", func(t *testing.T) {
p, pc := createPlugin(t, testPluginID, "", plugins.External, false, false)
p, pc := createPlugin(t, testPluginID, plugins.External, false)
loader := &fakeLoader{
mockedLoadedPlugins: []*plugins.Plugin{p},
@@ -144,24 +155,36 @@ func TestPluginManager_loadPlugins(t *testing.T) {
}
func TestPluginManager_Installer(t *testing.T) {
t.Run("Install", func(t *testing.T) {
p, pc := createPlugin(t, testPluginID, "1.0.0", plugins.External, true, true)
t.Run("Add new plugin", func(t *testing.T) {
testDir, err := os.CreateTemp(os.TempDir(), "plugin-manager-test-*")
require.NoError(t, err)
t.Cleanup(func() {
err := os.RemoveAll(testDir.Name())
assert.NoError(t, err)
})
p, pc := createPlugin(t, testPluginID, plugins.External, true, func(p *plugins.Plugin) {
p.PluginDir = filepath.Join(testDir.Name(), p.ID)
p.Backend = true
})
l := &fakeLoader{
mockedLoadedPlugins: []*plugins.Plugin{p},
}
fsm := &fakeFsManager{}
i := &fakePluginInstaller{}
repository := &fakePluginRepo{}
pm := createManager(t, func(pm *PluginManager) {
pm.pluginInstaller = i
pm.cfg.PluginsPath = testDir.Name()
pm.pluginLoader = l
pm.pluginStorage = fsm
pm.pluginRepo = repository
})
err := pm.Add(context.Background(), testPluginID, "1.0.0")
err = pm.Add(context.Background(), testPluginID, "1.0.0", plugins.CompatOpts{})
require.NoError(t, err)
assert.Equal(t, 1, i.installCount)
assert.Equal(t, 0, i.uninstallCount)
assert.Equal(t, 1, repository.downloadCount)
verifyNoPluginErrors(t, pm)
@@ -169,6 +192,10 @@ func TestPluginManager_Installer(t *testing.T) {
assert.Equal(t, p.ID, pm.Routes()[0].PluginID)
assert.Equal(t, p.PluginDir, pm.Routes()[0].Directory)
assert.Equal(t, 1, repository.downloadCount)
assert.Equal(t, 0, fsm.removed)
assert.Equal(t, 1, fsm.added)
assert.Equal(t, 1, pc.startCount)
assert.Equal(t, 0, pc.stopCount)
assert.False(t, pc.exited)
@@ -180,27 +207,63 @@ func TestPluginManager_Installer(t *testing.T) {
assert.Len(t, pm.Plugins(context.Background()), 1)
t.Run("Won't install if already installed", func(t *testing.T) {
err := pm.Add(context.Background(), testPluginID, "1.0.0")
require.Equal(t, plugins.DuplicateError{
err := pm.Add(context.Background(), testPluginID, "1.0.0", plugins.CompatOpts{})
assert.Equal(t, plugins.DuplicateError{
PluginID: p.ID,
ExistingPluginDir: p.PluginDir,
}, err)
})
t.Run("Update", func(t *testing.T) {
p, pc := createPlugin(t, testPluginID, "1.2.0", plugins.External, true, true)
t.Run("Update option is the same as installed version", func(t *testing.T) {
repository.downloadOptionsHandler = func(_ context.Context, _, _ string, _ repo.CompatOpts) (*repo.PluginDownloadOptions, error) {
return &repo.PluginDownloadOptions{
Version: p.Info.Version,
}, nil
}
err = pm.Add(context.Background(), p.ID, "", plugins.CompatOpts{})
require.ErrorIs(t, err, plugins.DuplicateError{
PluginID: p.ID,
ExistingPluginDir: p.PluginDir,
})
assert.Equal(t, 1, repository.downloadCount)
assert.Equal(t, 0, fsm.removed)
assert.Equal(t, 1, fsm.added)
assert.Equal(t, 1, pc.startCount)
assert.Equal(t, 0, pc.stopCount)
assert.False(t, pc.exited)
assert.False(t, pc.decommissioned)
testPlugin, exists = pm.Plugin(context.Background(), p.ID)
assert.True(t, exists)
assert.Equal(t, p.ToDTO(), testPlugin)
assert.Len(t, pm.Plugins(context.Background()), 1)
})
t.Run("Update existing plugin", func(t *testing.T) {
p, pc := createPlugin(t, testPluginID, plugins.External, true, func(p *plugins.Plugin) {
p.Backend = true
p.PluginDir = filepath.Join(testDir.Name(), p.ID)
})
l := &fakeLoader{
mockedLoadedPlugins: []*plugins.Plugin{p},
}
pm.pluginLoader = l
err = pm.Add(context.Background(), testPluginID, "1.2.0")
repository.downloadOptionsHandler = func(_ context.Context, _, _ string, _ repo.CompatOpts) (*repo.PluginDownloadOptions, error) {
return &repo.PluginDownloadOptions{
Version: "1.2.0",
}, nil
}
err = pm.Add(context.Background(), testPluginID, "1.2.0", plugins.CompatOpts{})
assert.NoError(t, err)
assert.Equal(t, 2, i.installCount)
assert.Equal(t, 1, i.uninstallCount)
assert.Equal(t, 2, repository.downloadCount)
assert.Equal(t, 1, fsm.removed)
assert.Equal(t, 2, fsm.added)
assert.Equal(t, 1, pc.startCount)
assert.Equal(t, 0, pc.stopCount)
assert.False(t, pc.exited)
@@ -212,12 +275,11 @@ func TestPluginManager_Installer(t *testing.T) {
assert.Len(t, pm.Plugins(context.Background()), 1)
})
t.Run("Uninstall", func(t *testing.T) {
t.Run("Uninstall existing plugin", func(t *testing.T) {
err := pm.Remove(context.Background(), p.ID)
require.NoError(t, err)
assert.Equal(t, 2, i.installCount)
assert.Equal(t, 2, i.uninstallCount)
assert.Equal(t, 2, repository.downloadCount)
p, exists := pm.Plugin(context.Background(), p.ID)
assert.False(t, exists)
@@ -232,7 +294,9 @@ func TestPluginManager_Installer(t *testing.T) {
})
t.Run("Can't update core plugin", func(t *testing.T) {
p, pc := createPlugin(t, testPluginID, "", plugins.Core, true, true)
p, pc := createPlugin(t, testPluginID, plugins.Core, true, func(p *plugins.Plugin) {
p.Backend = true
})
loader := &fakeLoader{
mockedLoadedPlugins: []*plugins.Plugin{p},
@@ -256,7 +320,7 @@ func TestPluginManager_Installer(t *testing.T) {
verifyNoPluginErrors(t, pm)
err = pm.Add(context.Background(), testPluginID, "")
err = pm.Add(context.Background(), testPluginID, "1.0.0", plugins.CompatOpts{})
assert.Equal(t, plugins.ErrInstallCorePlugin, err)
t.Run("Can't uninstall core plugin", func(t *testing.T) {
@@ -266,7 +330,9 @@ func TestPluginManager_Installer(t *testing.T) {
})
t.Run("Can't update bundled plugin", func(t *testing.T) {
p, pc := createPlugin(t, testPluginID, "", plugins.Bundled, true, true)
p, pc := createPlugin(t, testPluginID, plugins.Bundled, true, func(p *plugins.Plugin) {
p.Backend = true
})
loader := &fakeLoader{
mockedLoadedPlugins: []*plugins.Plugin{p},
@@ -290,7 +356,7 @@ func TestPluginManager_Installer(t *testing.T) {
verifyNoPluginErrors(t, pm)
err = pm.Add(context.Background(), testPluginID, "")
err = pm.Add(context.Background(), testPluginID, "1.0.0", plugins.CompatOpts{})
assert.Equal(t, plugins.ErrInstallCorePlugin, err)
t.Run("Can't uninstall bundled plugin", func(t *testing.T) {
@@ -302,20 +368,20 @@ func TestPluginManager_Installer(t *testing.T) {
func TestPluginManager_registeredPlugins(t *testing.T) {
t.Run("Decommissioned plugins are included in registeredPlugins", func(t *testing.T) {
decommissionedPlugin, _ := createPlugin(t, testPluginID, "", plugins.Core, false, true,
func(plugin *plugins.Plugin) {
err := plugin.Decommission()
require.NoError(t, err)
},
)
require.True(t, decommissionedPlugin.IsDecommissioned())
decommissionedPlugin, _ := createPlugin(t, testPluginID, plugins.External, true, func(p *plugins.Plugin) {
p.Backend = true
err := p.Decommission()
require.NoError(t, err)
})
pm := New(&plugins.Cfg{}, &fakePluginRegistry{
store: map[string]*plugins.Plugin{
testPluginID: decommissionedPlugin,
"test-app": {},
},
}, []PluginSource{}, &fakeLoader{})
}, []PluginSource{}, &fakeLoader{}, &fakePluginRepo{}, &fakeFsManager{})
require.True(t, decommissionedPlugin.IsDecommissioned())
rps := pm.registeredPlugins(context.Background())
require.Equal(t, 2, len(rps))
@@ -520,29 +586,17 @@ func TestPluginManager_lifecycle_unmanaged(t *testing.T) {
})
}
func createManager(t *testing.T, cbs ...func(*PluginManager)) *PluginManager {
t.Helper()
pm := New(&plugins.Cfg{}, newFakePluginRegistry(), nil, &fakeLoader{})
for _, cb := range cbs {
cb(pm)
}
return pm
}
func createPlugin(t *testing.T, pluginID, version string, class plugins.Class, managed, backend bool, cbs ...func(*plugins.Plugin)) (*plugins.Plugin, *fakePluginClient) {
func createPlugin(t *testing.T, pluginID string, class plugins.Class, managed bool,
cbs ...func(*plugins.Plugin)) (*plugins.Plugin, *fakePluginClient) {
t.Helper()
p := &plugins.Plugin{
Class: class,
JSONData: plugins.JSONData{
ID: pluginID,
Type: plugins.DataSource,
Backend: backend,
ID: pluginID,
Type: plugins.DataSource,
Info: plugins.Info{
Version: version,
Version: "1.0.0",
},
},
}
@@ -566,6 +620,22 @@ func createPlugin(t *testing.T, pluginID, version string, class plugins.Class, m
return p, pc
}
func createManager(t *testing.T, cbs ...func(*PluginManager)) *PluginManager {
t.Helper()
cfg := &plugins.Cfg{
DevMode: false,
}
pm := New(cfg, newFakePluginRegistry(), nil, &fakeLoader{}, &fakePluginRepo{}, &fakeFsManager{})
for _, cb := range cbs {
cb(pm)
}
return pm
}
type managerScenarioCtx struct {
manager *PluginManager
plugin *plugins.Plugin
@@ -583,12 +653,16 @@ func newScenario(t *testing.T, managed bool, fn func(t *testing.T, ctx *managerS
ManagedIdentityClientId: "client-id",
}
manager := New(cfg, registry.NewInMemory(), nil, &fakeLoader{})
loader := &fakeLoader{}
manager := New(cfg, registry.NewInMemory(), nil, loader, &fakePluginRepo{}, &fakeFsManager{})
manager.pluginLoader = loader
ctx := &managerScenarioCtx{
manager: manager,
}
ctx.plugin, ctx.pluginClient = createPlugin(t, testPluginID, "", plugins.External, managed, true)
ctx.plugin, ctx.pluginClient = createPlugin(t, testPluginID, plugins.External, managed, func(p *plugins.Plugin) {
p.Backend = true
})
fn(t, ctx)
}
@@ -599,23 +673,33 @@ func verifyNoPluginErrors(t *testing.T, pm *PluginManager) {
}
}
type fakePluginInstaller struct {
installCount int
uninstallCount int
type fakePluginRepo struct {
repo.Service
downloadOptionsHandler func(_ context.Context, _, _ string, _ repo.CompatOpts) (*repo.PluginDownloadOptions, error)
downloadOptionsCount int
downloadCount int
}
func (f *fakePluginInstaller) Install(_ context.Context, _, _, _, _, _ string) error {
f.installCount++
return nil
func (pr *fakePluginRepo) GetPluginArchive(_ context.Context, _, _ string, _ repo.CompatOpts) (*repo.PluginArchive, error) {
pr.downloadCount++
return &repo.PluginArchive{}, nil
}
func (f *fakePluginInstaller) Uninstall(_ context.Context, _ string) error {
f.uninstallCount++
return nil
// DownloadWithURL downloads the requested plugin from the specified URL.
func (pr *fakePluginRepo) GetPluginArchiveByURL(_ context.Context, _ string, _ repo.CompatOpts) (*repo.PluginArchive, error) {
pr.downloadCount++
return &repo.PluginArchive{}, nil
}
func (f *fakePluginInstaller) GetUpdateInfo(_ context.Context, _, _, _ string) (plugins.UpdateInfo, error) {
return plugins.UpdateInfo{}, nil
// GetDownloadOptions provides information for downloading the requested plugin.
func (pr *fakePluginRepo) GetPluginDownloadOptions(ctx context.Context, pluginID, version string, opts repo.CompatOpts) (*repo.PluginDownloadOptions, error) {
pr.downloadOptionsCount++
if pr.downloadOptionsHandler != nil {
return pr.downloadOptionsHandler(ctx, pluginID, version, opts)
}
return &repo.PluginDownloadOptions{}, nil
}
type fakeLoader struct {
@@ -790,3 +874,20 @@ func (f *fakePluginRegistry) Remove(_ context.Context, id string) error {
delete(f.store, id)
return nil
}
type fakeFsManager struct {
storage.Manager
added int
removed int
}
func (fsm *fakeFsManager) Add(_ context.Context, _ string, _ *zip.ReadCloser) (*storage.ExtractedPluginArchive, error) {
fsm.added++
return &storage.ExtractedPluginArchive{}, nil
}
func (fsm *fakeFsManager) Remove(_ context.Context, _ string) error {
fsm.removed++
return nil
}
+57 -16
View File
@@ -2,10 +2,10 @@ package manager
import (
"context"
"path/filepath"
"strings"
"fmt"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/repo"
)
func (m *PluginManager) Plugin(ctx context.Context, pluginID string) (plugins.PluginDTO, bool) {
@@ -68,9 +68,10 @@ func (m *PluginManager) registeredPlugins(ctx context.Context) map[string]struct
return pluginsByID
}
func (m *PluginManager) Add(ctx context.Context, pluginID, version string) error {
var pluginZipURL string
func (m *PluginManager) Add(ctx context.Context, pluginID, version string, opts plugins.CompatOpts) error {
compatOpts := repo.NewCompatOpts(opts.GrafanaVersion, opts.OS, opts.Arch)
var pluginArchive *repo.PluginArchive
if plugin, exists := m.plugin(ctx, pluginID); exists {
if !plugin.IsExternalPlugin() {
return plugins.ErrInstallCorePlugin
@@ -83,28 +84,74 @@ func (m *PluginManager) Add(ctx context.Context, pluginID, version string) error
}
}
// get plugin update information to confirm if upgrading is possible
updateInfo, err := m.pluginInstaller.GetUpdateInfo(ctx, pluginID, version, grafanaComURL)
// get plugin update information to confirm if target update is possible
dlOpts, err := m.pluginRepo.GetPluginDownloadOptions(ctx, pluginID, version, compatOpts)
if err != nil {
return err
}
pluginZipURL = updateInfo.PluginZipURL
// if existing plugin version is the same as the target update version
if dlOpts.Version == plugin.Info.Version {
return plugins.DuplicateError{
PluginID: plugin.ID,
ExistingPluginDir: plugin.PluginDir,
}
}
if dlOpts.PluginZipURL == "" && dlOpts.Version == "" {
return fmt.Errorf("could not determine update options for %s", pluginID)
}
// remove existing installation of plugin
err = m.Remove(ctx, plugin.ID)
if err != nil {
return err
}
if dlOpts.PluginZipURL != "" {
pluginArchive, err = m.pluginRepo.GetPluginArchiveByURL(ctx, dlOpts.PluginZipURL, compatOpts)
if err != nil {
return err
}
} else {
pluginArchive, err = m.pluginRepo.GetPluginArchive(ctx, pluginID, dlOpts.Version, compatOpts)
if err != nil {
return err
}
}
} else {
var err error
pluginArchive, err = m.pluginRepo.GetPluginArchive(ctx, pluginID, version, compatOpts)
if err != nil {
return err
}
}
err := m.pluginInstaller.Install(ctx, pluginID, version, m.cfg.PluginsPath, pluginZipURL, grafanaComURL)
extractedArchive, err := m.pluginStorage.Add(ctx, pluginID, pluginArchive.File)
if err != nil {
return err
}
err = m.loadPlugins(context.Background(), plugins.External, m.cfg.PluginsPath)
// download dependency plugins
pathsToScan := []string{extractedArchive.Path}
for _, dep := range extractedArchive.Dependencies {
m.log.Info("Fetching %s dependencies...", dep.ID)
d, err := m.pluginRepo.GetPluginArchive(ctx, dep.ID, dep.Version, compatOpts)
if err != nil {
return fmt.Errorf("%v: %w", fmt.Sprintf("failed to download plugin %s from repository", dep.ID), err)
}
depArchive, err := m.pluginStorage.Add(ctx, dep.ID, d.File)
if err != nil {
return err
}
pathsToScan = append(pathsToScan, depArchive.Path)
}
err = m.loadPlugins(context.Background(), plugins.External, pathsToScan...)
if err != nil {
m.log.Error("Could not load plugins", "paths", pathsToScan, "err", err)
return err
}
@@ -121,15 +168,9 @@ func (m *PluginManager) Remove(ctx context.Context, pluginID string) error {
return plugins.ErrUninstallCorePlugin
}
// extra security check to ensure we only remove plugins that are located in the configured plugins directory
path, err := filepath.Rel(m.cfg.PluginsPath, plugin.PluginDir)
if err != nil || strings.HasPrefix(path, ".."+string(filepath.Separator)) {
return plugins.ErrUninstallOutsideOfPluginDir
}
if err := m.unregisterAndStop(ctx, plugin); err != nil {
return err
}
return m.pluginInstaller.Uninstall(ctx, plugin.PluginDir)
return m.pluginStorage.Remove(ctx, plugin.ID)
}