mirror of
https://github.com/grafana/grafana.git
synced 2025-02-09 23:16:16 -06:00
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:
parent
cc78486535
commit
26dfdd5af3
@ -15,7 +15,7 @@ type fakePlugin struct {
|
||||
version string
|
||||
}
|
||||
|
||||
func (pm *fakePluginManager) Add(_ context.Context, pluginID, version string) error {
|
||||
func (pm *fakePluginManager) Add(_ context.Context, pluginID, version string, _ plugins.CompatOpts) error {
|
||||
pm.plugins[pluginID] = fakePlugin{
|
||||
pluginID: pluginID,
|
||||
version: version,
|
||||
|
@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@ -23,7 +24,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"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/repo"
|
||||
"github.com/grafana/grafana/pkg/plugins/storage"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsettings"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
@ -368,21 +370,25 @@ func (hs *HTTPServer) InstallPlugin(c *models.ReqContext) response.Response {
|
||||
}
|
||||
pluginID := web.Params(c.Req)[":pluginId"]
|
||||
|
||||
err := hs.pluginManager.Add(c.Req.Context(), pluginID, dto.Version)
|
||||
err := hs.pluginManager.Add(c.Req.Context(), pluginID, dto.Version, plugins.CompatOpts{
|
||||
GrafanaVersion: hs.Cfg.BuildVersion,
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
})
|
||||
if err != nil {
|
||||
var dupeErr plugins.DuplicateError
|
||||
if errors.As(err, &dupeErr) {
|
||||
return response.Error(http.StatusConflict, "Plugin already installed", err)
|
||||
}
|
||||
var versionUnsupportedErr installer.ErrVersionUnsupported
|
||||
var versionUnsupportedErr repo.ErrVersionUnsupported
|
||||
if errors.As(err, &versionUnsupportedErr) {
|
||||
return response.Error(http.StatusConflict, "Plugin version not supported", err)
|
||||
}
|
||||
var versionNotFoundErr installer.ErrVersionNotFound
|
||||
var versionNotFoundErr repo.ErrVersionNotFound
|
||||
if errors.As(err, &versionNotFoundErr) {
|
||||
return response.Error(http.StatusNotFound, "Plugin version not found", err)
|
||||
}
|
||||
var clientError installer.Response4xxError
|
||||
var clientError repo.Response4xxError
|
||||
if errors.As(err, &clientError) {
|
||||
return response.Error(clientError.StatusCode, clientError.Message, err)
|
||||
}
|
||||
@ -407,7 +413,7 @@ func (hs *HTTPServer) UninstallPlugin(c *models.ReqContext) response.Response {
|
||||
if errors.Is(err, plugins.ErrUninstallCorePlugin) {
|
||||
return response.Error(http.StatusForbidden, "Cannot uninstall a Core plugin", err)
|
||||
}
|
||||
if errors.Is(err, plugins.ErrUninstallOutsideOfPluginDir) {
|
||||
if errors.Is(err, storage.ErrUninstallOutsideOfPluginDir) {
|
||||
return response.Error(http.StatusForbidden, "Cannot uninstall a plugin outside of the plugins directory", err)
|
||||
}
|
||||
|
||||
|
@ -11,7 +11,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/models"
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/services"
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/utils"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/installer"
|
||||
"github.com/grafana/grafana/pkg/plugins/repo"
|
||||
"github.com/grafana/grafana/pkg/plugins/storage"
|
||||
)
|
||||
|
||||
func validateInput(c utils.CommandLine, pluginFolder string) error {
|
||||
@ -48,16 +49,49 @@ func (cmd Command) installCommand(c utils.CommandLine) error {
|
||||
|
||||
pluginID := c.Args().First()
|
||||
version := c.Args().Get(1)
|
||||
return InstallPlugin(pluginID, version, c)
|
||||
return installPlugin(context.Background(), pluginID, version, c)
|
||||
}
|
||||
|
||||
// InstallPlugin downloads the plugin code as a zip file from the Grafana.com API
|
||||
// and then extracts the zip into the plugins directory.
|
||||
func InstallPlugin(pluginID, version string, c utils.CommandLine) error {
|
||||
// installPlugin downloads the plugin code as a zip file from the Grafana.com API
|
||||
// and then extracts the zip into the plugin's directory.
|
||||
func installPlugin(ctx context.Context, pluginID, version string, c utils.CommandLine) error {
|
||||
skipTLSVerify := c.Bool("insecure")
|
||||
repository := repo.New(skipTLSVerify, c.PluginRepoURL(), services.Logger)
|
||||
|
||||
i := installer.New(skipTLSVerify, services.GrafanaVersion, services.Logger)
|
||||
return i.Install(context.Background(), pluginID, version, c.PluginDirectory(), c.PluginURL(), c.PluginRepoURL())
|
||||
compatOpts := repo.NewCompatOpts(services.GrafanaVersion, runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
var archive *repo.PluginArchive
|
||||
var err error
|
||||
pluginZipURL := c.PluginURL()
|
||||
if pluginZipURL != "" {
|
||||
if archive, err = repository.GetPluginArchiveByURL(ctx, pluginZipURL, compatOpts); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if archive, err = repository.GetPluginArchive(ctx, pluginID, version, compatOpts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
pluginFs := storage.FileSystem(services.Logger, c.PluginDirectory())
|
||||
extractedArchive, err := pluginFs.Add(ctx, pluginID, archive.File)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, dep := range extractedArchive.Dependencies {
|
||||
services.Logger.Infof("Fetching %s dependency...", dep.ID)
|
||||
d, err := repository.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)
|
||||
}
|
||||
|
||||
_, err = pluginFs.Add(ctx, dep.ID, d.File)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func osAndArchString() string {
|
||||
|
@ -1,6 +1,8 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/models"
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/services"
|
||||
@ -54,7 +56,7 @@ func (cmd Command) upgradeAllCommand(c utils.CommandLine) error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = InstallPlugin(p.ID, "", c)
|
||||
err = installPlugin(context.Background(), p.ID, "", c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/fatih/color"
|
||||
@ -29,7 +30,7 @@ func (cmd Command) upgradeCommand(c utils.CommandLine) error {
|
||||
return fmt.Errorf("failed to remove plugin '%s': %w", pluginName, err)
|
||||
}
|
||||
|
||||
return InstallPlugin(pluginName, "", c)
|
||||
return installPlugin(context.Background(), pluginName, "", c)
|
||||
}
|
||||
|
||||
logger.Infof("%s %s is up to date \n", color.GreenString("✔"), pluginName)
|
||||
|
@ -8,12 +8,12 @@ import (
|
||||
)
|
||||
|
||||
type CLILogger struct {
|
||||
DebugMode bool
|
||||
debugMode bool
|
||||
}
|
||||
|
||||
func New(debugMode bool) *CLILogger {
|
||||
return &CLILogger{
|
||||
DebugMode: debugMode,
|
||||
debugMode: debugMode,
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,13 +36,13 @@ func (l *CLILogger) Infof(format string, args ...interface{}) {
|
||||
|
||||
func (l *CLILogger) Debug(args ...interface{}) {
|
||||
args = append(args, "\n\n")
|
||||
if l.DebugMode {
|
||||
if l.debugMode {
|
||||
fmt.Print(color.HiBlueString(fmt.Sprint(args...)))
|
||||
}
|
||||
}
|
||||
|
||||
func (l *CLILogger) Debugf(format string, args ...interface{}) {
|
||||
if l.DebugMode {
|
||||
if l.debugMode {
|
||||
fmt.Print(color.HiBlueString(fmt.Sprintf(addNewlines(format), args...)))
|
||||
}
|
||||
}
|
||||
|
@ -37,6 +37,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/loader"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
||||
"github.com/grafana/grafana/pkg/plugins/plugincontext"
|
||||
"github.com/grafana/grafana/pkg/plugins/repo"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
@ -171,6 +172,8 @@ var wireSet = wire.NewSet(
|
||||
uss.ProvideService,
|
||||
registry.ProvideService,
|
||||
wire.Bind(new(registry.Service), new(*registry.InMemory)),
|
||||
repo.ProvideService,
|
||||
wire.Bind(new(repo.Service), new(*repo.Manager)),
|
||||
manager.ProvideService,
|
||||
wire.Bind(new(plugins.Manager), new(*manager.PluginManager)),
|
||||
wire.Bind(new(plugins.Client), new(*manager.PluginManager)),
|
||||
|
@ -19,11 +19,17 @@ type Store interface {
|
||||
|
||||
type Manager interface {
|
||||
// Add adds a plugin to the store.
|
||||
Add(ctx context.Context, pluginID, version string) error
|
||||
Add(ctx context.Context, pluginID, version string, opts CompatOpts) error
|
||||
// Remove removes a plugin from the store.
|
||||
Remove(ctx context.Context, pluginID string) error
|
||||
}
|
||||
|
||||
type CompatOpts struct {
|
||||
GrafanaVersion string
|
||||
OS string
|
||||
Arch string
|
||||
}
|
||||
|
||||
type UpdateInfo struct {
|
||||
PluginZipURL string
|
||||
}
|
||||
|
17
pkg/plugins/logger/ifaces.go
Normal file
17
pkg/plugins/logger/ifaces.go
Normal file
@ -0,0 +1,17 @@
|
||||
package logger
|
||||
|
||||
// Logger is used primarily to facilitate logging/user feedback for both
|
||||
// the grafana-cli and the grafana backend when managing plugin installs
|
||||
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,4 +1,4 @@
|
||||
package manager
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@ -7,58 +7,51 @@ import (
|
||||
)
|
||||
|
||||
type InfraLogWrapper struct {
|
||||
l log.Logger
|
||||
|
||||
debugMode bool
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
func newInstallerLogger(name string, debugMode bool) (l *InfraLogWrapper) {
|
||||
func NewLogger(name string) (l *InfraLogWrapper) {
|
||||
return &InfraLogWrapper{
|
||||
debugMode: debugMode,
|
||||
l: log.New(name),
|
||||
log: log.New(name),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *InfraLogWrapper) Successf(format string, args ...interface{}) {
|
||||
l.l.Info(fmt.Sprintf(format, args...))
|
||||
l.log.Info(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (l *InfraLogWrapper) Failuref(format string, args ...interface{}) {
|
||||
l.l.Error(fmt.Sprintf(format, args...))
|
||||
l.log.Error(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (l *InfraLogWrapper) Info(args ...interface{}) {
|
||||
l.l.Info(fmt.Sprint(args...))
|
||||
l.log.Info(fmt.Sprint(args...))
|
||||
}
|
||||
|
||||
func (l *InfraLogWrapper) Infof(format string, args ...interface{}) {
|
||||
l.l.Info(fmt.Sprintf(format, args...))
|
||||
l.log.Info(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (l *InfraLogWrapper) Debug(args ...interface{}) {
|
||||
if l.debugMode {
|
||||
l.l.Debug(fmt.Sprint(args...))
|
||||
}
|
||||
l.log.Debug(fmt.Sprint(args...))
|
||||
}
|
||||
|
||||
func (l *InfraLogWrapper) Debugf(format string, args ...interface{}) {
|
||||
if l.debugMode {
|
||||
l.l.Debug(fmt.Sprintf(format, args...))
|
||||
}
|
||||
l.log.Debug(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (l *InfraLogWrapper) Warn(args ...interface{}) {
|
||||
l.l.Warn(fmt.Sprint(args...))
|
||||
l.log.Warn(fmt.Sprint(args...))
|
||||
}
|
||||
|
||||
func (l *InfraLogWrapper) Warnf(format string, args ...interface{}) {
|
||||
l.l.Warn(fmt.Sprintf(format, args...))
|
||||
l.log.Warn(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (l *InfraLogWrapper) Error(args ...interface{}) {
|
||||
l.l.Error(fmt.Sprint(args...))
|
||||
l.log.Error(fmt.Sprint(args...))
|
||||
}
|
||||
|
||||
func (l *InfraLogWrapper) Errorf(format string, args ...interface{}) {
|
||||
l.l.Error(fmt.Sprintf(format, args...))
|
||||
l.log.Error(fmt.Sprintf(format, args...))
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -12,10 +12,9 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInstallCorePlugin = errors.New("cannot install a Core plugin")
|
||||
ErrUninstallCorePlugin = errors.New("cannot uninstall a Core plugin")
|
||||
ErrUninstallOutsideOfPluginDir = errors.New("cannot uninstall a plugin outside")
|
||||
ErrPluginNotInstalled = errors.New("plugin is not installed")
|
||||
ErrInstallCorePlugin = errors.New("cannot install a Core plugin")
|
||||
ErrUninstallCorePlugin = errors.New("cannot uninstall a Core plugin")
|
||||
ErrPluginNotInstalled = errors.New("plugin is not installed")
|
||||
)
|
||||
|
||||
type NotFoundError struct {
|
||||
|
@ -222,7 +222,7 @@ func TestParseTreeZips(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
staticRootPath, err := filepath.Abs("../manager/installer/testdata")
|
||||
staticRootPath, err := filepath.Abs("../storage/testdata")
|
||||
require.NoError(t, err)
|
||||
ents, err := os.ReadDir(staticRootPath)
|
||||
require.NoError(t, err)
|
||||
|
249
pkg/plugins/repo/client.go
Normal file
249
pkg/plugins/repo/client.go
Normal file
@ -0,0 +1,249 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins/logger"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
httpClient http.Client
|
||||
httpClientNoTimeout http.Client
|
||||
retryCount int
|
||||
|
||||
log logger.Logger
|
||||
}
|
||||
|
||||
func newClient(skipTLSVerify bool, logger logger.Logger) *Client {
|
||||
return &Client{
|
||||
httpClient: makeHttpClient(skipTLSVerify, 10*time.Second),
|
||||
httpClientNoTimeout: makeHttpClient(skipTLSVerify, 0),
|
||||
log: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) download(_ context.Context, pluginZipURL, checksum string, compatOpts CompatOpts) (*PluginArchive, error) {
|
||||
// Create temp file for downloading zip file
|
||||
tmpFile, err := os.CreateTemp("", "*.zip")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%v: %w", "failed to create temporary file", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Remove(tmpFile.Name()); err != nil {
|
||||
c.log.Warn("Failed to remove temporary file", "file", tmpFile.Name(), "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
c.log.Debugf("Installing plugin from %s", pluginZipURL)
|
||||
|
||||
err = c.downloadFile(tmpFile, pluginZipURL, checksum, compatOpts)
|
||||
if err != nil {
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
c.log.Warn("Failed to close file", "err", err)
|
||||
}
|
||||
return nil, fmt.Errorf("%v: %w", "failed to download plugin archive", err)
|
||||
}
|
||||
|
||||
rc, err := zip.OpenReader(tmpFile.Name())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PluginArchive{
|
||||
File: rc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) downloadFile(tmpFile *os.File, pluginURL, checksum string, compatOpts CompatOpts) (err error) {
|
||||
// Try handling URL as a local file path first
|
||||
if _, err := os.Stat(pluginURL); err == nil {
|
||||
// TODO re-verify
|
||||
// We can ignore this gosec G304 warning since `pluginURL` 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(pluginURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v: %w", "Failed to read plugin archive", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
c.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
|
||||
}
|
||||
|
||||
c.retryCount = 0
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
c.retryCount++
|
||||
if c.retryCount < 3 {
|
||||
c.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 = c.downloadFile(tmpFile, pluginURL, checksum, compatOpts)
|
||||
} else {
|
||||
c.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
u, err := url.Parse(pluginURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 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 := c.sendReqNoTimeout(u, compatOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := bodyReader.Close(); err != nil {
|
||||
c.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 (c *Client) sendReq(url *url.URL, compatOpts CompatOpts) ([]byte, error) {
|
||||
req, err := c.createReq(url, compatOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyReader, err := c.handleResp(res, compatOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if err := bodyReader.Close(); err != nil {
|
||||
c.log.Warn("Failed to close stream", "err", err)
|
||||
}
|
||||
}()
|
||||
return io.ReadAll(bodyReader)
|
||||
}
|
||||
|
||||
func (c *Client) sendReqNoTimeout(url *url.URL, compatOpts CompatOpts) (io.ReadCloser, error) {
|
||||
req, err := c.createReq(url, compatOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := c.httpClientNoTimeout.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.handleResp(res, compatOpts)
|
||||
}
|
||||
|
||||
func (c *Client) createReq(url *url.URL, compatOpts CompatOpts) (*http.Request, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("grafana-version", compatOpts.GrafanaVersion)
|
||||
req.Header.Set("grafana-os", compatOpts.OS)
|
||||
req.Header.Set("grafana-arch", compatOpts.Arch)
|
||||
req.Header.Set("User-Agent", "grafana "+compatOpts.GrafanaVersion)
|
||||
|
||||
return req, err
|
||||
}
|
||||
|
||||
func (c *Client) handleResp(res *http.Response, compatOpts CompatOpts) (io.ReadCloser, error) {
|
||||
if res.StatusCode/100 == 4 {
|
||||
body, err := io.ReadAll(res.Body)
|
||||
defer func() {
|
||||
if err := res.Body.Close(); err != nil {
|
||||
c.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: compatOpts.String()}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
39
pkg/plugins/repo/ifaces.go
Normal file
39
pkg/plugins/repo/ifaces.go
Normal file
@ -0,0 +1,39 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Service is responsible for retrieving plugin information from a repository.
|
||||
type Service interface {
|
||||
// GetPluginArchive fetches the requested plugin archive.
|
||||
GetPluginArchive(ctx context.Context, pluginID, version string, opts CompatOpts) (*PluginArchive, error)
|
||||
// GetPluginArchiveByURL fetches the requested plugin from the specified URL.
|
||||
GetPluginArchiveByURL(ctx context.Context, archiveURL string, opts CompatOpts) (*PluginArchive, error)
|
||||
// GetPluginDownloadOptions fetches information for downloading the requested plugin.
|
||||
GetPluginDownloadOptions(ctx context.Context, pluginID, version string, opts CompatOpts) (*PluginDownloadOptions, error)
|
||||
}
|
||||
|
||||
type CompatOpts struct {
|
||||
GrafanaVersion string
|
||||
OS string
|
||||
Arch string
|
||||
}
|
||||
|
||||
func NewCompatOpts(grafanaVersion, os, arch string) CompatOpts {
|
||||
return CompatOpts{
|
||||
GrafanaVersion: grafanaVersion,
|
||||
OS: os,
|
||||
Arch: arch,
|
||||
}
|
||||
}
|
||||
|
||||
func (co CompatOpts) OSAndArch() string {
|
||||
return fmt.Sprintf("%s-%s", strings.ToLower(co.OS), co.Arch)
|
||||
}
|
||||
|
||||
func (co CompatOpts) String() string {
|
||||
return fmt.Sprintf("Grafana v%s %s", co.GrafanaVersion, co.OSAndArch())
|
||||
}
|
74
pkg/plugins/repo/models.go
Normal file
74
pkg/plugins/repo/models.go
Normal file
@ -0,0 +1,74 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type PluginArchive struct {
|
||||
File *zip.ReadCloser
|
||||
}
|
||||
|
||||
type PluginDownloadOptions struct {
|
||||
PluginZipURL string
|
||||
Version string
|
||||
Checksum string
|
||||
}
|
||||
|
||||
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:"repoURL"`
|
||||
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"`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
183
pkg/plugins/repo/service.go
Normal file
183
pkg/plugins/repo/service.go
Normal file
@ -0,0 +1,183 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins/logger"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
client *Client
|
||||
baseURL string
|
||||
|
||||
log logger.Logger
|
||||
}
|
||||
|
||||
func ProvideService() *Manager {
|
||||
defaultBaseURL := "https://grafana.com/api/plugins"
|
||||
return New(false, defaultBaseURL, logger.NewLogger("plugin.repository"))
|
||||
}
|
||||
|
||||
func New(skipTLSVerify bool, baseURL string, logger logger.Logger) *Manager {
|
||||
return &Manager{
|
||||
client: newClient(skipTLSVerify, logger),
|
||||
baseURL: baseURL,
|
||||
log: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetPluginArchive fetches the requested plugin archive
|
||||
func (m *Manager) GetPluginArchive(ctx context.Context, pluginID, version string, compatOpts CompatOpts) (*PluginArchive, error) {
|
||||
dlOpts, err := m.GetPluginDownloadOptions(ctx, pluginID, version, compatOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m.client.download(ctx, dlOpts.PluginZipURL, dlOpts.Checksum, compatOpts)
|
||||
}
|
||||
|
||||
// GetPluginArchiveByURL fetches the requested plugin archive from the provided `pluginZipURL`
|
||||
func (m *Manager) GetPluginArchiveByURL(ctx context.Context, pluginZipURL string, compatOpts CompatOpts) (*PluginArchive, error) {
|
||||
return m.client.download(ctx, pluginZipURL, "", compatOpts)
|
||||
}
|
||||
|
||||
// GetPluginDownloadOptions returns the options for downloading the requested plugin (with optional `version`)
|
||||
func (m *Manager) GetPluginDownloadOptions(_ context.Context, pluginID, version string, compatOpts CompatOpts) (*PluginDownloadOptions, error) {
|
||||
plugin, err := m.pluginMetadata(pluginID, compatOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v, err := m.selectVersion(&plugin, version, compatOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Plugins which are downloaded just as sourcecode zipball from GitHub do not have checksum
|
||||
var checksum string
|
||||
if v.Arch != nil {
|
||||
archMeta, exists := v.Arch[compatOpts.OSAndArch()]
|
||||
if !exists {
|
||||
archMeta = v.Arch["any"]
|
||||
}
|
||||
checksum = archMeta.SHA256
|
||||
}
|
||||
|
||||
return &PluginDownloadOptions{
|
||||
Version: v.Version,
|
||||
Checksum: checksum,
|
||||
PluginZipURL: fmt.Sprintf("%s/%s/versions/%s/download", m.baseURL, pluginID, v.Version),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) pluginMetadata(pluginID string, compatOpts CompatOpts) (Plugin, error) {
|
||||
m.log.Debugf("Fetching metadata for plugin \"%s\" from repo %s", pluginID, m.baseURL)
|
||||
|
||||
u, err := url.Parse(m.baseURL)
|
||||
if err != nil {
|
||||
return Plugin{}, err
|
||||
}
|
||||
u.Path = path.Join(u.Path, "repo", pluginID)
|
||||
|
||||
body, err := m.client.sendReq(u, compatOpts)
|
||||
if err != nil {
|
||||
return Plugin{}, err
|
||||
}
|
||||
|
||||
var data Plugin
|
||||
err = json.Unmarshal(body, &data)
|
||||
if err != nil {
|
||||
m.log.Error("Failed to unmarshal plugin repo response error", err)
|
||||
return Plugin{}, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// selectVersion selects the most appropriate plugin version
|
||||
// returns the specified version if supported.
|
||||
// returns the 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 (m *Manager) selectVersion(plugin *Plugin, version string, compatOpts CompatOpts) (*Version, error) {
|
||||
version = normalizeVersion(version)
|
||||
|
||||
var ver Version
|
||||
latestForArch := latestSupportedVersion(plugin, compatOpts)
|
||||
if latestForArch == nil {
|
||||
return nil, ErrVersionUnsupported{
|
||||
PluginID: plugin.ID,
|
||||
RequestedVersion: version,
|
||||
SystemInfo: compatOpts.String(),
|
||||
}
|
||||
}
|
||||
|
||||
if version == "" {
|
||||
return latestForArch, nil
|
||||
}
|
||||
for _, v := range plugin.Versions {
|
||||
if v.Version == version {
|
||||
ver = v
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(ver.Version) == 0 {
|
||||
m.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: compatOpts.String(),
|
||||
}
|
||||
}
|
||||
|
||||
if !supportsCurrentArch(&ver, compatOpts) {
|
||||
m.log.Debugf("Requested plugin version %s v%s is not supported on your system but potential fallback version '%s' was found",
|
||||
plugin.ID, version, latestForArch.Version)
|
||||
return nil, ErrVersionUnsupported{
|
||||
PluginID: plugin.ID,
|
||||
RequestedVersion: version,
|
||||
SystemInfo: compatOpts.String(),
|
||||
}
|
||||
}
|
||||
|
||||
return &ver, nil
|
||||
}
|
||||
|
||||
func supportsCurrentArch(version *Version, compatOpts CompatOpts) bool {
|
||||
if version.Arch == nil {
|
||||
return true
|
||||
}
|
||||
for arch := range version.Arch {
|
||||
if arch == compatOpts.OSAndArch() || arch == "any" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func latestSupportedVersion(plugin *Plugin, compatOpts CompatOpts) *Version {
|
||||
for _, v := range plugin.Versions {
|
||||
ver := v
|
||||
if supportsCurrentArch(&ver, compatOpts) {
|
||||
return &ver
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeVersion(version string) string {
|
||||
normalized := strings.ReplaceAll(version, " ", "")
|
||||
if strings.HasPrefix(normalized, "^") || strings.HasPrefix(normalized, "v") {
|
||||
return normalized[1:]
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
94
pkg/plugins/repo/service_test.go
Normal file
94
pkg/plugins/repo/service_test.go
Normal file
@ -0,0 +1,94 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSelectVersion(t *testing.T) {
|
||||
i := &Manager{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", CompatOpts{})
|
||||
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"}}), "", CompatOpts{})
|
||||
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", CompatOpts{})
|
||||
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"},
|
||||
), "", CompatOpts{})
|
||||
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"}), "", CompatOpts{})
|
||||
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", CompatOpts{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "1.0.0", ver.Version)
|
||||
})
|
||||
}
|
||||
|
||||
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{}) {}
|
288
pkg/plugins/storage/fs.go
Normal file
288
pkg/plugins/storage/fs.go
Normal file
@ -0,0 +1,288 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins/logger"
|
||||
)
|
||||
|
||||
var reGitBuild = regexp.MustCompile("^[a-zA-Z0-9_.-]*/")
|
||||
|
||||
var (
|
||||
ErrUninstallOutsideOfPluginDir = errors.New("cannot uninstall a plugin outside of the plugins directory")
|
||||
ErrUninstallInvalidPluginDir = errors.New("cannot recognize as plugin folder")
|
||||
)
|
||||
|
||||
type FS struct {
|
||||
store map[string]string
|
||||
mu sync.RWMutex
|
||||
pluginsDir string
|
||||
log logger.Logger
|
||||
}
|
||||
|
||||
func FileSystem(logger logger.Logger, pluginsDir string) *FS {
|
||||
return &FS{
|
||||
store: make(map[string]string),
|
||||
pluginsDir: pluginsDir,
|
||||
log: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (fs *FS) Add(ctx context.Context, pluginID string, pluginArchive *zip.ReadCloser) (
|
||||
*ExtractedPluginArchive, error) {
|
||||
pluginDir, err := fs.extractFiles(ctx, pluginArchive, pluginID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%v: %w", "failed to extract plugin archive", err)
|
||||
}
|
||||
|
||||
res, err := toPluginDTO(pluginID, pluginDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%v: %w", "failed to convert to plugin DTO", err)
|
||||
}
|
||||
|
||||
fs.log.Successf("Downloaded and extracted %s v%s zip successfully to %s", res.ID, res.Info.Version, pluginDir)
|
||||
|
||||
var deps []*Dependency
|
||||
for _, plugin := range res.Dependencies.Plugins {
|
||||
deps = append(deps, &Dependency{
|
||||
ID: plugin.ID,
|
||||
Version: plugin.Version,
|
||||
})
|
||||
}
|
||||
|
||||
fs.mu.Lock()
|
||||
fs.store[pluginID] = pluginDir
|
||||
fs.mu.Unlock()
|
||||
|
||||
return &ExtractedPluginArchive{
|
||||
ID: res.ID,
|
||||
Version: res.Info.Version,
|
||||
Dependencies: deps,
|
||||
Path: pluginDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (fs *FS) Remove(_ context.Context, pluginID string) error {
|
||||
fs.mu.RLock()
|
||||
pluginDir, exists := fs.store[pluginID]
|
||||
fs.mu.RUnlock()
|
||||
if !exists {
|
||||
return fmt.Errorf("%s does not exist", pluginID)
|
||||
}
|
||||
|
||||
// extra security check to ensure we only remove plugins that are located in the configured plugins directory
|
||||
path, err := filepath.Rel(fs.pluginsDir, pluginDir)
|
||||
if err != nil || strings.HasPrefix(path, ".."+string(filepath.Separator)) {
|
||||
return ErrUninstallOutsideOfPluginDir
|
||||
}
|
||||
|
||||
if _, err = os.Stat(filepath.Join(pluginDir, "plugin.json")); os.IsNotExist(err) {
|
||||
if _, err = os.Stat(filepath.Join(pluginDir, "dist/plugin.json")); os.IsNotExist(err) {
|
||||
return ErrUninstallInvalidPluginDir
|
||||
}
|
||||
}
|
||||
|
||||
fs.log.Infof("Uninstalling plugin %v", pluginDir)
|
||||
return os.RemoveAll(pluginDir)
|
||||
}
|
||||
|
||||
func (fs *FS) extractFiles(_ context.Context, pluginArchive *zip.ReadCloser, pluginID string) (string, error) {
|
||||
installDir := filepath.Join(fs.pluginsDir, pluginID)
|
||||
if _, err := os.Stat(installDir); !os.IsNotExist(err) {
|
||||
fs.log.Debugf("Removing existing installation of plugin %s", installDir)
|
||||
err = os.RemoveAll(installDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := pluginArchive.Close(); err != nil {
|
||||
fs.log.Warn("failed to close zip file", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
for _, zf := range pluginArchive.File {
|
||||
// We can ignore gosec G305 here since we check for the ZipSlip vulnerability below
|
||||
// nolint:gosec
|
||||
fullPath := filepath.Join(fs.pluginsDir, zf.Name)
|
||||
|
||||
// Check for ZipSlip. More Info: http://bit.ly/2MsjAWE
|
||||
if filepath.IsAbs(zf.Name) ||
|
||||
!strings.HasPrefix(fullPath, filepath.Clean(fs.pluginsDir)+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, fs.pluginsDir)
|
||||
}
|
||||
|
||||
dstPath := filepath.Clean(filepath.Join(fs.pluginsDir, removeGitBuildFromName(zf.Name, pluginID))) // lgtm[go/zipslip]
|
||||
|
||||
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 "", ErrPermissionDenied{Path: 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(installDir, zf, dstPath); err != nil {
|
||||
fs.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 installDir, 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
|
||||
}
|
||||
fileDir := filepath.Dir(symlinkOrigPath)
|
||||
cleanPath := filepath.Clean(filepath.Join(fileDir, "/", symlinkDestPath))
|
||||
p, err := filepath.Rel(basePath, cleanPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.HasPrefix(filepath.Clean(p), "..") {
|
||||
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("could not create %q, permission denied, make sure you have write access to plugin dir", 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(pluginID, pluginDir string) (InstalledPlugin, error) {
|
||||
distPluginDataPath := filepath.Join(pluginDir, "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, "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{}, fmt.Errorf("could not find dist/plugin.json or plugin.json for %s in %s", pluginID, pluginDir)
|
||||
}
|
||||
}
|
||||
|
||||
res := InstalledPlugin{}
|
||||
if err = json.Unmarshal(data, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
|
||||
if res.ID == "" {
|
||||
return InstalledPlugin{}, fmt.Errorf("could not find valid plugin %s in %s", pluginID, pluginDir)
|
||||
}
|
||||
|
||||
if res.Info.Version == "" {
|
||||
res.Info.Version = "0.0.0"
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
package installer
|
||||
package storage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
@ -11,9 +12,9 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInstall(t *testing.T) {
|
||||
func TestAdd(t *testing.T) {
|
||||
testDir := "./testdata/tmpInstallPluginDir"
|
||||
err := os.Mkdir(testDir, os.ModePerm)
|
||||
err := os.MkdirAll(testDir, os.ModePerm)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
@ -23,8 +24,9 @@ func TestInstall(t *testing.T) {
|
||||
|
||||
pluginID := "test-app"
|
||||
|
||||
i := &Installer{log: &fakeLogger{}}
|
||||
err = i.Install(context.Background(), pluginID, "", testDir, "./testdata/plugin-with-symlinks.zip", "")
|
||||
fs := FileSystem(&fakeLogger{}, testDir)
|
||||
archive, err := fs.Add(context.Background(), pluginID, zipFile(t, "./testdata/plugin-with-symlinks.zip"))
|
||||
require.NotNil(t, archive)
|
||||
require.NoError(t, err)
|
||||
|
||||
// verify extracted contents
|
||||
@ -46,15 +48,22 @@ func TestInstall(t *testing.T) {
|
||||
require.Equal(t, files[5].Name(), "text.txt")
|
||||
}
|
||||
|
||||
func TestUninstall(t *testing.T) {
|
||||
i := &Installer{log: &fakeLogger{}}
|
||||
|
||||
func TestRemove(t *testing.T) {
|
||||
pluginDir := t.TempDir()
|
||||
pluginJSON := filepath.Join(pluginDir, "plugin.json")
|
||||
_, err := os.Create(pluginJSON)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = i.Uninstall(context.Background(), pluginDir)
|
||||
pluginID := "test-datasource"
|
||||
i := &FS{
|
||||
pluginsDir: filepath.Dir(pluginDir),
|
||||
store: map[string]string{
|
||||
pluginID: pluginDir,
|
||||
},
|
||||
log: &fakeLogger{},
|
||||
}
|
||||
|
||||
err = i.Remove(context.Background(), pluginID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = os.Stat(pluginDir)
|
||||
@ -70,7 +79,15 @@ func TestUninstall(t *testing.T) {
|
||||
|
||||
pluginDir = filepath.Dir(pluginDistDir)
|
||||
|
||||
err = i.Uninstall(context.Background(), pluginDir)
|
||||
i = &FS{
|
||||
pluginsDir: filepath.Dir(pluginDir),
|
||||
store: map[string]string{
|
||||
pluginID: pluginDir,
|
||||
},
|
||||
log: &fakeLogger{},
|
||||
}
|
||||
|
||||
err = i.Remove(context.Background(), pluginID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = os.Stat(pluginDir)
|
||||
@ -79,8 +96,33 @@ func TestUninstall(t *testing.T) {
|
||||
|
||||
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))
|
||||
i = &FS{
|
||||
pluginsDir: filepath.Dir(pluginDir),
|
||||
store: map[string]string{
|
||||
pluginID: pluginDir,
|
||||
},
|
||||
log: &fakeLogger{},
|
||||
}
|
||||
|
||||
err = i.Remove(context.Background(), pluginID)
|
||||
require.EqualError(t, err, "cannot recognize as plugin folder")
|
||||
|
||||
_, err = os.Stat(pluginDir)
|
||||
require.False(t, os.IsNotExist(err))
|
||||
})
|
||||
|
||||
t.Run("Uninstall will not delete folder if plugin's directory is not a subdirectory of specified plugins directory", func(t *testing.T) {
|
||||
pluginDir = t.TempDir()
|
||||
i = &FS{
|
||||
pluginsDir: "/some/other/path",
|
||||
store: map[string]string{
|
||||
pluginID: pluginDir,
|
||||
},
|
||||
log: &fakeLogger{},
|
||||
}
|
||||
|
||||
err = i.Remove(context.Background(), pluginID)
|
||||
require.EqualError(t, err, "cannot uninstall a plugin outside of the plugins directory")
|
||||
|
||||
_, err = os.Stat(pluginDir)
|
||||
require.False(t, os.IsNotExist(err))
|
||||
@ -88,14 +130,16 @@ func TestUninstall(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtractFiles(t *testing.T) {
|
||||
i := &Installer{log: &fakeLogger{}}
|
||||
pluginsDir := setupFakePluginsDir(t)
|
||||
|
||||
i := &FS{log: &fakeLogger{}, pluginsDir: pluginsDir}
|
||||
|
||||
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)
|
||||
pluginID := "grafana-simple-json-datasource"
|
||||
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip"), pluginID)
|
||||
require.Equal(t, filepath.Join(pluginsDir, pluginID), path)
|
||||
require.NoError(t, err)
|
||||
|
||||
// File in zip has permissions 755
|
||||
@ -122,7 +166,9 @@ func TestExtractFiles(t *testing.T) {
|
||||
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)
|
||||
pluginID := "plugin-with-symlink"
|
||||
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-symlink.zip"), pluginID)
|
||||
require.Equal(t, filepath.Join(pluginsDir, pluginID), path)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = os.Stat(pluginsDir + "/plugin-with-symlink/symlink_to_txt")
|
||||
@ -136,7 +182,9 @@ func TestExtractFiles(t *testing.T) {
|
||||
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)
|
||||
pluginID := "plugin-with-symlink-dir"
|
||||
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-symlink-dir.zip"), pluginID)
|
||||
require.Equal(t, filepath.Join(pluginsDir, pluginID), path)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = os.Stat(pluginsDir + "/plugin-with-symlink-dir/symlink_to_dir")
|
||||
@ -150,7 +198,9 @@ func TestExtractFiles(t *testing.T) {
|
||||
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)
|
||||
pluginID := "plugin-with-absolute-symlink"
|
||||
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-absolute-symlink.zip"), pluginID)
|
||||
require.Equal(t, filepath.Join(pluginsDir, pluginID), path)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = os.Stat(pluginsDir + "/plugin-with-absolute-symlink/test.txt")
|
||||
@ -160,7 +210,9 @@ func TestExtractFiles(t *testing.T) {
|
||||
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)
|
||||
pluginID := "plugin-with-absolute-symlink-dir"
|
||||
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-absolute-symlink-dir.zip"), pluginID)
|
||||
require.Equal(t, filepath.Join(pluginsDir, pluginID), path)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = os.Stat(pluginsDir + "/plugin-with-absolute-symlink-dir/target")
|
||||
@ -168,7 +220,8 @@ func TestExtractFiles(t *testing.T) {
|
||||
})
|
||||
|
||||
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)
|
||||
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-parent-member.zip"), "plugin-with-parent-member")
|
||||
require.Empty(t, path)
|
||||
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,
|
||||
@ -176,7 +229,8 @@ func TestExtractFiles(t *testing.T) {
|
||||
})
|
||||
|
||||
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)
|
||||
path, err := i.extractFiles(context.Background(), zipFile(t, "testdata/plugin-with-absolute-member.zip"), "plugin-with-absolute-member")
|
||||
require.Empty(t, path)
|
||||
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,
|
||||
@ -184,47 +238,11 @@ func TestExtractFiles(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestSelectVersion(t *testing.T) {
|
||||
i := &Installer{log: &fakeLogger{}}
|
||||
func zipFile(t *testing.T, zipPath string) *zip.ReadCloser {
|
||||
rc, err := zip.OpenReader(zipPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
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)
|
||||
})
|
||||
return rc
|
||||
}
|
||||
|
||||
func TestRemoveGitBuildFromName(t *testing.T) {
|
||||
@ -346,36 +364,6 @@ func skipWindows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{}) {}
|
11
pkg/plugins/storage/ifaces.go
Normal file
11
pkg/plugins/storage/ifaces.go
Normal file
@ -0,0 +1,11 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
)
|
||||
|
||||
type Manager interface {
|
||||
Add(ctx context.Context, pluginID string, rc *zip.ReadCloser) (*ExtractedPluginArchive, error)
|
||||
Remove(ctx context.Context, pluginID string) error
|
||||
}
|
@ -1,4 +1,26 @@
|
||||
package installer
|
||||
package storage
|
||||
|
||||
import "fmt"
|
||||
|
||||
type ErrPermissionDenied struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
func (e ErrPermissionDenied) Error() string {
|
||||
return fmt.Sprintf("could not create %q, permission denied, make sure you have write access to plugin dir", e.Path)
|
||||
}
|
||||
|
||||
type ExtractedPluginArchive struct {
|
||||
ID string
|
||||
Version string
|
||||
Dependencies []*Dependency
|
||||
Path string
|
||||
}
|
||||
|
||||
type Dependency struct {
|
||||
ID string
|
||||
Version string
|
||||
}
|
||||
|
||||
type InstalledPlugin struct {
|
||||
ID string `json:"id"`
|
||||
@ -24,25 +46,3 @@ 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"`
|
||||
}
|
@ -38,6 +38,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/loader"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
||||
"github.com/grafana/grafana/pkg/plugins/plugincontext"
|
||||
"github.com/grafana/grafana/pkg/plugins/repo"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/alerting"
|
||||
@ -166,6 +167,8 @@ var wireBasicSet = wire.NewSet(
|
||||
wire.Bind(new(usagestats.Service), new(*uss.UsageStats)),
|
||||
registry.ProvideService,
|
||||
wire.Bind(new(registry.Service), new(*registry.InMemory)),
|
||||
repo.ProvideService,
|
||||
wire.Bind(new(repo.Service), new(*repo.Manager)),
|
||||
manager.ProvideService,
|
||||
wire.Bind(new(plugins.Manager), new(*manager.PluginManager)),
|
||||
wire.Bind(new(plugins.Client), new(*manager.PluginManager)),
|
||||
|
Loading…
Reference in New Issue
Block a user