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:
@@ -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{})
|
||||
}
|
||||
@@ -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{}) {}
|
||||
@@ -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"`
|
||||
}
|
||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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...))
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user