Plugins: Refactor plugin download/installation (#43046)

* installer -> repo

* add semver format checking

* add plugin callbacks in test

* remove newline

* post install only scans new directories

* remove unused stuff

* everything in own package

* add missing cli params

* make grafana version part of the API

* resolve conflicts

* tidy up logger

* fix cli and tidy log statements

* rename log package

* update struct name

* fix linter issue

* fs -> filestore

* reorder imports

* alias import

* fix test

* fix test

* inline var

* revert jsonc file

* make repo dep of manager

* actually inject the thing

* accept all args for compatability checks

* accept compat from store

* pass os + arch vals

* don't inject fs

* tidy up

* tidy up

* merge with main and tidy fs storage

* fix test

* fix packages

* fix comment + field name

* update fs naming

* fixed wire

* remove unused func

* fix mocks

* fix storage test

* renaming

* fix log line

* fix test

* re-order field

* tidying

* add test for update with same version

* fix wire for CLI

* remove use of ioutil

* don't pass field

* small tidy

* ignore code scanning warn

* fix testdata link

* update lgtm code
This commit is contained in:
Will Browne 2022-08-23 11:50:50 +02:00 committed by GitHub
parent cc78486535
commit 26dfdd5af3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1399 additions and 998 deletions

View File

@ -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,

View File

@ -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)
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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)

View File

@ -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...)))
}
}

View File

@ -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)),

View File

@ -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
}

View 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{})
}

View File

@ -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...))
}

View File

@ -1,31 +0,0 @@
package installer
import (
"context"
"github.com/grafana/grafana/pkg/plugins"
)
// Service is responsible for managing plugins (add / remove) on the file system.
type Service interface {
// Install downloads the requested plugin in the provided file system location.
Install(ctx context.Context, pluginID, version, pluginsDir, pluginZipURL, pluginRepoURL string) error
// Uninstall removes the requested plugin from the provided file system location.
Uninstall(ctx context.Context, pluginDir string) error
// GetUpdateInfo provides update information for the requested plugin.
GetUpdateInfo(ctx context.Context, pluginID, version, pluginRepoURL string) (plugins.UpdateInfo, error)
}
type Logger interface {
Successf(format string, args ...interface{})
Failuref(format string, args ...interface{})
Info(args ...interface{})
Infof(format string, args ...interface{})
Debug(args ...interface{})
Debugf(format string, args ...interface{})
Warn(args ...interface{})
Warnf(format string, args ...interface{})
Error(args ...interface{})
Errorf(format string, args ...interface{})
}

View File

@ -1,703 +0,0 @@
package installer
import (
"archive/zip"
"bufio"
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
"github.com/grafana/grafana/pkg/plugins"
)
type Installer struct {
retryCount int
httpClient http.Client
httpClientNoTimeout http.Client
grafanaVersion string
log Logger
}
const (
permissionsDeniedMessage = "could not create %q, permission denied, make sure you have write access to plugin dir"
)
var (
reGitBuild = regexp.MustCompile("^[a-zA-Z0-9_.-]*/")
)
type Response4xxError struct {
Message string
StatusCode int
SystemInfo string
}
func (e Response4xxError) Error() string {
if len(e.Message) > 0 {
if len(e.SystemInfo) > 0 {
return fmt.Sprintf("%s (%s)", e.Message, e.SystemInfo)
}
return fmt.Sprintf("%d: %s", e.StatusCode, e.Message)
}
return fmt.Sprintf("%d", e.StatusCode)
}
type ErrVersionUnsupported struct {
PluginID string
RequestedVersion string
SystemInfo string
}
func (e ErrVersionUnsupported) Error() string {
return fmt.Sprintf("%s v%s is not supported on your system (%s)", e.PluginID, e.RequestedVersion, e.SystemInfo)
}
type ErrVersionNotFound struct {
PluginID string
RequestedVersion string
SystemInfo string
}
func (e ErrVersionNotFound) Error() string {
return fmt.Sprintf("%s v%s either does not exist or is not supported on your system (%s)", e.PluginID, e.RequestedVersion, e.SystemInfo)
}
func New(skipTLSVerify bool, grafanaVersion string, logger Logger) Service {
return &Installer{
httpClient: makeHttpClient(skipTLSVerify, 10*time.Second),
httpClientNoTimeout: makeHttpClient(skipTLSVerify, 0),
log: logger,
grafanaVersion: grafanaVersion,
}
}
// Install downloads the plugin code as a zip file from specified URL
// and then extracts the zip into the provided plugins directory.
func (i *Installer) Install(ctx context.Context, pluginID, version, pluginsDir, pluginZipURL, pluginRepoURL string) error {
var checksum string
if pluginZipURL == "" {
plugin, err := i.getPluginMetadataFromPluginRepo(pluginID, pluginRepoURL)
if err != nil {
return err
}
v, err := i.selectVersion(&plugin, version)
if err != nil {
return err
}
if version == "" {
version = v.Version
}
pluginZipURL = fmt.Sprintf("%s/%s/versions/%s/download",
pluginRepoURL,
pluginID,
version,
)
// Plugins which are downloaded just as sourcecode zipball from github do not have checksum
if v.Arch != nil {
archMeta, exists := v.Arch[osAndArchString()]
if !exists {
archMeta = v.Arch["any"]
}
checksum = archMeta.SHA256
}
}
i.log.Debugf("Installing plugin\nfrom: %s\ninto: %s", pluginZipURL, pluginsDir)
// Create temp file for downloading zip file
tmpFile, err := os.CreateTemp("", "*.zip")
if err != nil {
return fmt.Errorf("%v: %w", "failed to create temporary file", err)
}
defer func() {
if err := os.Remove(tmpFile.Name()); err != nil {
i.log.Warn("Failed to remove temporary file", "file", tmpFile.Name(), "err", err)
}
}()
err = i.DownloadFile(pluginID, tmpFile, pluginZipURL, checksum)
if err != nil {
if err := tmpFile.Close(); err != nil {
i.log.Warn("Failed to close file", "err", err)
}
return fmt.Errorf("%v: %w", "failed to download plugin archive", err)
}
err = tmpFile.Close()
if err != nil {
return fmt.Errorf("%v: %w", "failed to close tmp file", err)
}
err = i.extractFiles(tmpFile.Name(), pluginID, pluginsDir)
if err != nil {
return fmt.Errorf("%v: %w", "failed to extract plugin archive", err)
}
res, _ := toPluginDTO(pluginsDir, pluginID)
i.log.Successf("Downloaded %s v%s zip successfully", res.ID, res.Info.Version)
// download dependency plugins
for _, dep := range res.Dependencies.Plugins {
i.log.Infof("Fetching %s dependencies...", res.ID)
if err := i.Install(ctx, dep.ID, normalizeVersion(dep.Version), pluginsDir, "", pluginRepoURL); err != nil {
return fmt.Errorf("failed to install plugin %s: %w", dep.ID, err)
}
}
return err
}
// Uninstall removes the specified plugin from the provided plugin directory.
func (i *Installer) Uninstall(ctx context.Context, pluginDir string) error {
// verify it's a plugin directory
if _, err := os.Stat(filepath.Join(pluginDir, "plugin.json")); err != nil {
if os.IsNotExist(err) {
if _, err := os.Stat(filepath.Join(pluginDir, "dist", "plugin.json")); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("tried to remove %s, but it doesn't seem to be a plugin", pluginDir)
}
}
}
}
i.log.Infof("Uninstalling plugin %v", pluginDir)
return os.RemoveAll(pluginDir)
}
func (i *Installer) DownloadFile(pluginID string, tmpFile *os.File, url string, checksum string) (err error) {
// Try handling URL as a local file path first
if _, err := os.Stat(url); err == nil {
// We can ignore this gosec G304 warning since `url` stems from command line flag "pluginUrl". If the
// user shouldn't be able to read the file, it should be handled through filesystem permissions.
// nolint:gosec
f, err := os.Open(url)
if err != nil {
return fmt.Errorf("%v: %w", "Failed to read plugin archive", err)
}
defer func() {
if err := f.Close(); err != nil {
i.log.Warn("Failed to close file", "err", err)
}
}()
_, err = io.Copy(tmpFile, f)
if err != nil {
return fmt.Errorf("%v: %w", "Failed to copy plugin archive", err)
}
return nil
}
i.retryCount = 0
defer func() {
if r := recover(); r != nil {
i.retryCount++
if i.retryCount < 3 {
i.log.Debug("Failed downloading. Will retry once.")
err = tmpFile.Truncate(0)
if err != nil {
return
}
_, err = tmpFile.Seek(0, 0)
if err != nil {
return
}
err = i.DownloadFile(pluginID, tmpFile, url, checksum)
} else {
i.retryCount = 0
failure := fmt.Sprintf("%v", r)
if failure == "runtime error: makeslice: len out of range" {
err = fmt.Errorf("corrupt HTTP response from source, please try again")
} else {
panic(r)
}
}
}
}()
// Using no timeout here as some plugins can be bigger and smaller timeout would prevent to download a plugin on
// slow network. As this is CLI operation hanging is not a big of an issue as user can just abort.
bodyReader, err := i.sendRequestWithoutTimeout(url)
if err != nil {
return err
}
defer func() {
if err := bodyReader.Close(); err != nil {
i.log.Warn("Failed to close body", "err", err)
}
}()
w := bufio.NewWriter(tmpFile)
h := sha256.New()
if _, err = io.Copy(w, io.TeeReader(bodyReader, h)); err != nil {
return fmt.Errorf("%v: %w", "failed to compute SHA256 checksum", err)
}
if err := w.Flush(); err != nil {
return fmt.Errorf("failed to write to %q: %w", tmpFile.Name(), err)
}
if len(checksum) > 0 && checksum != fmt.Sprintf("%x", h.Sum(nil)) {
return fmt.Errorf("expected SHA256 checksum does not match the downloaded archive - please contact security@grafana.com")
}
return nil
}
func (i *Installer) getPluginMetadataFromPluginRepo(pluginID, pluginRepoURL string) (Plugin, error) {
i.log.Debugf("Fetching metadata for plugin \"%s\" from repo %s", pluginID, pluginRepoURL)
body, err := i.sendRequestGetBytes(pluginRepoURL, "repo", pluginID)
if err != nil {
return Plugin{}, err
}
var data Plugin
err = json.Unmarshal(body, &data)
if err != nil {
i.log.Error("Failed to unmarshal plugin repo response error", err)
return Plugin{}, err
}
return data, nil
}
func (i *Installer) sendRequestGetBytes(URL string, subPaths ...string) ([]byte, error) {
bodyReader, err := i.sendRequest(URL, subPaths...)
if err != nil {
return []byte{}, err
}
defer func() {
if err := bodyReader.Close(); err != nil {
i.log.Warn("Failed to close stream", "err", err)
}
}()
return io.ReadAll(bodyReader)
}
func (i *Installer) sendRequest(URL string, subPaths ...string) (io.ReadCloser, error) {
req, err := i.createRequest(URL, subPaths...)
if err != nil {
return nil, err
}
res, err := i.httpClient.Do(req)
if err != nil {
return nil, err
}
return i.handleResponse(res)
}
func (i *Installer) sendRequestWithoutTimeout(URL string, subPaths ...string) (io.ReadCloser, error) {
req, err := i.createRequest(URL, subPaths...)
if err != nil {
return nil, err
}
res, err := i.httpClientNoTimeout.Do(req)
if err != nil {
return nil, err
}
return i.handleResponse(res)
}
func (i *Installer) createRequest(URL string, subPaths ...string) (*http.Request, error) {
u, err := url.Parse(URL)
if err != nil {
return nil, err
}
for _, v := range subPaths {
u.Path = path.Join(u.Path, v)
}
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("grafana-version", i.grafanaVersion)
req.Header.Set("grafana-os", runtime.GOOS)
req.Header.Set("grafana-arch", runtime.GOARCH)
req.Header.Set("User-Agent", "grafana "+i.grafanaVersion)
return req, err
}
func (i *Installer) handleResponse(res *http.Response) (io.ReadCloser, error) {
if res.StatusCode/100 == 4 {
body, err := io.ReadAll(res.Body)
defer func() {
if err := res.Body.Close(); err != nil {
i.log.Warn("Failed to close response body", "err", err)
}
}()
if err != nil || len(body) == 0 {
return nil, Response4xxError{StatusCode: res.StatusCode}
}
var message string
var jsonBody map[string]string
err = json.Unmarshal(body, &jsonBody)
if err != nil || len(jsonBody["message"]) == 0 {
message = string(body)
} else {
message = jsonBody["message"]
}
return nil, Response4xxError{StatusCode: res.StatusCode, Message: message, SystemInfo: i.fullSystemInfoString()}
}
if res.StatusCode/100 != 2 {
return nil, fmt.Errorf("API returned invalid status: %s", res.Status)
}
return res.Body, nil
}
func makeHttpClient(skipTLSVerify bool, timeout time.Duration) http.Client {
tr := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: skipTLSVerify,
},
}
return http.Client{
Timeout: timeout,
Transport: tr,
}
}
func normalizeVersion(version string) string {
normalized := strings.ReplaceAll(version, " ", "")
if strings.HasPrefix(normalized, "^") || strings.HasPrefix(normalized, "v") {
return normalized[1:]
}
return normalized
}
func (i *Installer) GetUpdateInfo(ctx context.Context, pluginID, version, pluginRepoURL string) (plugins.UpdateInfo, error) {
plugin, err := i.getPluginMetadataFromPluginRepo(pluginID, pluginRepoURL)
if err != nil {
return plugins.UpdateInfo{}, err
}
v, err := i.selectVersion(&plugin, version)
if err != nil {
return plugins.UpdateInfo{}, err
}
return plugins.UpdateInfo{
PluginZipURL: fmt.Sprintf("%s/%s/versions/%s/download", pluginRepoURL, pluginID, v.Version),
}, nil
}
// selectVersion selects the most appropriate plugin version
// returns the specified version if supported.
// returns latest version if no specific version is specified.
// returns error if the supplied version does not exist.
// returns error if supplied version exists but is not supported.
// NOTE: It expects plugin.Versions to be sorted so the newest version is first.
func (i *Installer) selectVersion(plugin *Plugin, version string) (*Version, error) {
var ver Version
latestForArch := latestSupportedVersion(plugin)
if latestForArch == nil {
return nil, ErrVersionUnsupported{
PluginID: plugin.ID,
RequestedVersion: version,
SystemInfo: i.fullSystemInfoString(),
}
}
if version == "" {
return latestForArch, nil
}
for _, v := range plugin.Versions {
if v.Version == version {
ver = v
break
}
}
if len(ver.Version) == 0 {
i.log.Debugf("Requested plugin version %s v%s not found but potential fallback version '%s' was found",
plugin.ID, version, latestForArch.Version)
return nil, ErrVersionNotFound{
PluginID: plugin.ID,
RequestedVersion: version,
SystemInfo: i.fullSystemInfoString(),
}
}
if !supportsCurrentArch(&ver) {
i.log.Debugf("Requested plugin version %s v%s not found but potential fallback version '%s' was found",
plugin.ID, version, latestForArch.Version)
return nil, ErrVersionUnsupported{
PluginID: plugin.ID,
RequestedVersion: version,
SystemInfo: i.fullSystemInfoString(),
}
}
return &ver, nil
}
func (i *Installer) fullSystemInfoString() string {
return fmt.Sprintf("Grafana v%s %s", i.grafanaVersion, osAndArchString())
}
func osAndArchString() string {
osString := strings.ToLower(runtime.GOOS)
arch := runtime.GOARCH
return osString + "-" + arch
}
func supportsCurrentArch(version *Version) bool {
if version.Arch == nil {
return true
}
for arch := range version.Arch {
if arch == osAndArchString() || arch == "any" {
return true
}
}
return false
}
func latestSupportedVersion(plugin *Plugin) *Version {
for _, v := range plugin.Versions {
ver := v
if supportsCurrentArch(&ver) {
return &ver
}
}
return nil
}
func (i *Installer) extractFiles(archiveFile string, pluginID string, dest string) error {
var err error
dest, err = filepath.Abs(dest)
if err != nil {
return err
}
i.log.Debug(fmt.Sprintf("Extracting archive %q to %q...", archiveFile, dest))
existingInstallDir := filepath.Join(dest, pluginID)
if _, err := os.Stat(existingInstallDir); !os.IsNotExist(err) {
i.log.Debugf("Removing existing installation of plugin %s", existingInstallDir)
err = os.RemoveAll(existingInstallDir)
if err != nil {
return err
}
}
r, err := zip.OpenReader(archiveFile)
if err != nil {
return err
}
defer func() {
if err := r.Close(); err != nil {
i.log.Warn("failed to close zip file", "err", err)
}
}()
for _, zf := range r.File {
// We can ignore gosec G305 here since we check for the ZipSlip vulnerability below
// nolint:gosec
fullPath := filepath.Join(dest, zf.Name)
// Check for ZipSlip. More Info: http://bit.ly/2MsjAWE
if filepath.IsAbs(zf.Name) ||
!strings.HasPrefix(fullPath, filepath.Clean(dest)+string(os.PathSeparator)) ||
strings.HasPrefix(zf.Name, ".."+string(os.PathSeparator)) {
return fmt.Errorf(
"archive member %q tries to write outside of plugin directory: %q, this can be a security risk",
zf.Name, dest)
}
dstPath := filepath.Clean(filepath.Join(dest, removeGitBuildFromName(zf.Name, pluginID)))
if zf.FileInfo().IsDir() {
// We can ignore gosec G304 here since it makes sense to give all users read access
// nolint:gosec
if err := os.MkdirAll(dstPath, 0755); err != nil {
if os.IsPermission(err) {
return fmt.Errorf(permissionsDeniedMessage, dstPath)
}
return err
}
continue
}
// Create needed directories to extract file
// We can ignore gosec G304 here since it makes sense to give all users read access
// nolint:gosec
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
return fmt.Errorf("%v: %w", "failed to create directory to extract plugin files", err)
}
if isSymlink(zf) {
if err := extractSymlink(existingInstallDir, zf, dstPath); err != nil {
i.log.Warn("failed to extract symlink", "err", err)
continue
}
continue
}
if err := extractFile(zf, dstPath); err != nil {
return fmt.Errorf("%v: %w", "failed to extract file", err)
}
}
return nil
}
func isSymlink(file *zip.File) bool {
return file.Mode()&os.ModeSymlink == os.ModeSymlink
}
func extractSymlink(basePath string, file *zip.File, filePath string) error {
// symlink target is the contents of the file
src, err := file.Open()
if err != nil {
return fmt.Errorf("%v: %w", "failed to extract file", err)
}
buf := new(bytes.Buffer)
if _, err := io.Copy(buf, src); err != nil {
return fmt.Errorf("%v: %w", "failed to copy symlink contents", err)
}
symlinkPath := strings.TrimSpace(buf.String())
if !isSymlinkRelativeTo(basePath, symlinkPath, filePath) {
return fmt.Errorf("symlink %q pointing outside plugin directory is not allowed", filePath)
}
if err := os.Symlink(symlinkPath, filePath); err != nil {
return fmt.Errorf("failed to make symbolic link for %v: %w", filePath, err)
}
return nil
}
// isSymlinkRelativeTo checks whether symlinkDestPath is relative to basePath.
// symlinkOrigPath is the path to file holding the symbolic link.
func isSymlinkRelativeTo(basePath string, symlinkDestPath string, symlinkOrigPath string) bool {
if filepath.IsAbs(symlinkDestPath) {
return false
} else {
fileDir := filepath.Dir(symlinkOrigPath)
cleanPath := filepath.Clean(filepath.Join(fileDir, "/", symlinkDestPath))
p, err := filepath.Rel(basePath, cleanPath)
if err != nil {
return false
}
if p == ".." || strings.HasPrefix(p, ".."+string(filepath.Separator)) {
return false
}
}
return true
}
func extractFile(file *zip.File, filePath string) (err error) {
fileMode := file.Mode()
// This is entry point for backend plugins so we want to make them executable
if strings.HasSuffix(filePath, "_linux_amd64") || strings.HasSuffix(filePath, "_darwin_amd64") {
fileMode = os.FileMode(0755)
}
// We can ignore the gosec G304 warning on this one, since the variable part of the file path stems
// from command line flag "pluginsDir", and the only possible damage would be writing to the wrong directory.
// If the user shouldn't be writing to this directory, they shouldn't have the permission in the file system.
// nolint:gosec
dst, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileMode)
if err != nil {
if os.IsPermission(err) {
return fmt.Errorf(permissionsDeniedMessage, filePath)
}
unwrappedError := errors.Unwrap(err)
if unwrappedError != nil && strings.EqualFold(unwrappedError.Error(), "text file busy") {
return fmt.Errorf("file %q is in use - please stop Grafana, install the plugin and restart Grafana", filePath)
}
return fmt.Errorf("%v: %w", "failed to open file", err)
}
defer func() {
err = dst.Close()
}()
src, err := file.Open()
if err != nil {
return fmt.Errorf("%v: %w", "failed to extract file", err)
}
defer func() {
err = src.Close()
}()
_, err = io.Copy(dst, src)
return err
}
func removeGitBuildFromName(filename, pluginID string) string {
return reGitBuild.ReplaceAllString(filename, pluginID+"/")
}
func toPluginDTO(pluginDir, pluginID string) (InstalledPlugin, error) {
distPluginDataPath := filepath.Join(pluginDir, pluginID, "dist", "plugin.json")
// It's safe to ignore gosec warning G304 since the file path suffix is hardcoded
// nolint:gosec
data, err := os.ReadFile(distPluginDataPath)
if err != nil {
pluginDataPath := filepath.Join(pluginDir, pluginID, "plugin.json")
// It's safe to ignore gosec warning G304 since the file path suffix is hardcoded
// nolint:gosec
data, err = os.ReadFile(pluginDataPath)
if err != nil {
return InstalledPlugin{}, errors.New("Could not find dist/plugin.json or plugin.json on " + pluginID + " in " + pluginDir)
}
}
res := InstalledPlugin{}
if err := json.Unmarshal(data, &res); err != nil {
return res, err
}
if res.Info.Version == "" {
res.Info.Version = "0.0.0"
}
if res.ID == "" {
return InstalledPlugin{}, errors.New("could not find plugin " + pluginID + " in " + pluginDir)
}
return res, nil
}

View File

@ -10,16 +10,15 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
"github.com/grafana/grafana/pkg/plugins/manager/installer"
"github.com/grafana/grafana/pkg/plugins/logger"
"github.com/grafana/grafana/pkg/plugins/manager/loader"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/repo"
"github.com/grafana/grafana/pkg/plugins/storage"
"github.com/grafana/grafana/pkg/setting"
)
const (
grafanaComURL = "https://grafana.com/api/plugins"
)
var _ plugins.Manager = (*PluginManager)(nil)
var _ plugins.Client = (*PluginManager)(nil)
var _ plugins.Store = (*PluginManager)(nil)
var _ plugins.StaticRouteResolver = (*PluginManager)(nil)
@ -27,13 +26,14 @@ var _ plugins.RendererManager = (*PluginManager)(nil)
var _ plugins.SecretsPluginManager = (*PluginManager)(nil)
type PluginManager struct {
cfg *plugins.Cfg
pluginRegistry registry.Service
pluginInstaller installer.Service
pluginLoader loader.Service
pluginsMu sync.RWMutex
pluginSources []PluginSource
log log.Logger
cfg *plugins.Cfg
pluginRegistry registry.Service
pluginLoader loader.Service
pluginRepo repo.Service
pluginSources []PluginSource
pluginStorage storage.Manager
pluginsMu sync.RWMutex
log log.Logger
}
type PluginSource struct {
@ -41,26 +41,29 @@ type PluginSource struct {
Paths []string
}
func ProvideService(grafanaCfg *setting.Cfg, pluginRegistry registry.Service, pluginLoader loader.Service) (*PluginManager, error) {
func ProvideService(grafanaCfg *setting.Cfg, pluginRegistry registry.Service, pluginLoader loader.Service,
pluginRepo repo.Service) (*PluginManager, error) {
pm := New(plugins.FromGrafanaCfg(grafanaCfg), pluginRegistry, []PluginSource{
{Class: plugins.Core, Paths: corePluginPaths(grafanaCfg)},
{Class: plugins.Bundled, Paths: []string{grafanaCfg.BundledPluginsPath}},
{Class: plugins.External, Paths: append([]string{grafanaCfg.PluginsPath}, pluginSettingPaths(grafanaCfg)...)},
}, pluginLoader)
}, pluginLoader, pluginRepo, storage.FileSystem(logger.NewLogger("plugin.fs"), grafanaCfg.PluginsPath))
if err := pm.Init(); err != nil {
return nil, err
}
return pm, nil
}
func New(cfg *plugins.Cfg, pluginRegistry registry.Service, pluginSources []PluginSource, pluginLoader loader.Service) *PluginManager {
func New(cfg *plugins.Cfg, pluginRegistry registry.Service, pluginSources []PluginSource, pluginLoader loader.Service,
pluginRepo repo.Service, pluginFs storage.Manager) *PluginManager {
return &PluginManager{
cfg: cfg,
pluginLoader: pluginLoader,
pluginSources: pluginSources,
pluginRegistry: pluginRegistry,
log: log.New("plugin.manager"),
pluginInstaller: installer.New(false, cfg.BuildVersion, newInstallerLogger("plugin.installer", true)),
cfg: cfg,
pluginLoader: pluginLoader,
pluginSources: pluginSources,
pluginRegistry: pluginRegistry,
pluginRepo: pluginRepo,
pluginStorage: pluginFs,
log: log.New("plugin.manager"),
}
}

View File

@ -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()

View File

@ -1,8 +1,11 @@
package manager
import (
"archive/zip"
"context"
"net/http"
"os"
"path/filepath"
"sync"
"testing"
"time"
@ -16,6 +19,8 @@ import (
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/repo"
"github.com/grafana/grafana/pkg/plugins/storage"
)
const (
@ -29,7 +34,7 @@ func TestPluginManager_Init(t *testing.T) {
{Class: plugins.Bundled, Paths: []string{"path1"}},
{Class: plugins.Core, Paths: []string{"path2"}},
{Class: plugins.External, Paths: []string{"path3"}},
}, loader)
}, loader, &fakePluginRepo{}, &fakeFsManager{})
err := pm.Init()
require.NoError(t, err)
@ -39,7 +44,9 @@ func TestPluginManager_Init(t *testing.T) {
func TestPluginManager_loadPlugins(t *testing.T) {
t.Run("Managed backend plugin", func(t *testing.T) {
p, pc := createPlugin(t, testPluginID, "", plugins.External, true, true)
p, pc := createPlugin(t, testPluginID, plugins.External, true, func(p *plugins.Plugin) {
p.Backend = true
})
loader := &fakeLoader{
mockedLoadedPlugins: []*plugins.Plugin{p},
@ -65,7 +72,9 @@ func TestPluginManager_loadPlugins(t *testing.T) {
})
t.Run("Unmanaged backend plugin", func(t *testing.T) {
p, pc := createPlugin(t, testPluginID, "", plugins.External, false, true)
p, pc := createPlugin(t, testPluginID, plugins.External, false, func(p *plugins.Plugin) {
p.Backend = true
})
loader := &fakeLoader{
mockedLoadedPlugins: []*plugins.Plugin{p},
@ -91,7 +100,9 @@ func TestPluginManager_loadPlugins(t *testing.T) {
})
t.Run("Managed non-backend plugin", func(t *testing.T) {
p, pc := createPlugin(t, testPluginID, "", plugins.External, false, true)
p, pc := createPlugin(t, testPluginID, plugins.External, false, func(p *plugins.Plugin) {
p.Backend = true
})
loader := &fakeLoader{
mockedLoadedPlugins: []*plugins.Plugin{p},
@ -117,7 +128,7 @@ func TestPluginManager_loadPlugins(t *testing.T) {
})
t.Run("Unmanaged non-backend plugin", func(t *testing.T) {
p, pc := createPlugin(t, testPluginID, "", plugins.External, false, false)
p, pc := createPlugin(t, testPluginID, plugins.External, false)
loader := &fakeLoader{
mockedLoadedPlugins: []*plugins.Plugin{p},
@ -144,24 +155,36 @@ func TestPluginManager_loadPlugins(t *testing.T) {
}
func TestPluginManager_Installer(t *testing.T) {
t.Run("Install", func(t *testing.T) {
p, pc := createPlugin(t, testPluginID, "1.0.0", plugins.External, true, true)
t.Run("Add new plugin", func(t *testing.T) {
testDir, err := os.CreateTemp(os.TempDir(), "plugin-manager-test-*")
require.NoError(t, err)
t.Cleanup(func() {
err := os.RemoveAll(testDir.Name())
assert.NoError(t, err)
})
p, pc := createPlugin(t, testPluginID, plugins.External, true, func(p *plugins.Plugin) {
p.PluginDir = filepath.Join(testDir.Name(), p.ID)
p.Backend = true
})
l := &fakeLoader{
mockedLoadedPlugins: []*plugins.Plugin{p},
}
fsm := &fakeFsManager{}
i := &fakePluginInstaller{}
repository := &fakePluginRepo{}
pm := createManager(t, func(pm *PluginManager) {
pm.pluginInstaller = i
pm.cfg.PluginsPath = testDir.Name()
pm.pluginLoader = l
pm.pluginStorage = fsm
pm.pluginRepo = repository
})
err := pm.Add(context.Background(), testPluginID, "1.0.0")
err = pm.Add(context.Background(), testPluginID, "1.0.0", plugins.CompatOpts{})
require.NoError(t, err)
assert.Equal(t, 1, i.installCount)
assert.Equal(t, 0, i.uninstallCount)
assert.Equal(t, 1, repository.downloadCount)
verifyNoPluginErrors(t, pm)
@ -169,6 +192,10 @@ func TestPluginManager_Installer(t *testing.T) {
assert.Equal(t, p.ID, pm.Routes()[0].PluginID)
assert.Equal(t, p.PluginDir, pm.Routes()[0].Directory)
assert.Equal(t, 1, repository.downloadCount)
assert.Equal(t, 0, fsm.removed)
assert.Equal(t, 1, fsm.added)
assert.Equal(t, 1, pc.startCount)
assert.Equal(t, 0, pc.stopCount)
assert.False(t, pc.exited)
@ -180,27 +207,63 @@ func TestPluginManager_Installer(t *testing.T) {
assert.Len(t, pm.Plugins(context.Background()), 1)
t.Run("Won't install if already installed", func(t *testing.T) {
err := pm.Add(context.Background(), testPluginID, "1.0.0")
require.Equal(t, plugins.DuplicateError{
err := pm.Add(context.Background(), testPluginID, "1.0.0", plugins.CompatOpts{})
assert.Equal(t, plugins.DuplicateError{
PluginID: p.ID,
ExistingPluginDir: p.PluginDir,
}, err)
})
t.Run("Update", func(t *testing.T) {
p, pc := createPlugin(t, testPluginID, "1.2.0", plugins.External, true, true)
t.Run("Update option is the same as installed version", func(t *testing.T) {
repository.downloadOptionsHandler = func(_ context.Context, _, _ string, _ repo.CompatOpts) (*repo.PluginDownloadOptions, error) {
return &repo.PluginDownloadOptions{
Version: p.Info.Version,
}, nil
}
err = pm.Add(context.Background(), p.ID, "", plugins.CompatOpts{})
require.ErrorIs(t, err, plugins.DuplicateError{
PluginID: p.ID,
ExistingPluginDir: p.PluginDir,
})
assert.Equal(t, 1, repository.downloadCount)
assert.Equal(t, 0, fsm.removed)
assert.Equal(t, 1, fsm.added)
assert.Equal(t, 1, pc.startCount)
assert.Equal(t, 0, pc.stopCount)
assert.False(t, pc.exited)
assert.False(t, pc.decommissioned)
testPlugin, exists = pm.Plugin(context.Background(), p.ID)
assert.True(t, exists)
assert.Equal(t, p.ToDTO(), testPlugin)
assert.Len(t, pm.Plugins(context.Background()), 1)
})
t.Run("Update existing plugin", func(t *testing.T) {
p, pc := createPlugin(t, testPluginID, plugins.External, true, func(p *plugins.Plugin) {
p.Backend = true
p.PluginDir = filepath.Join(testDir.Name(), p.ID)
})
l := &fakeLoader{
mockedLoadedPlugins: []*plugins.Plugin{p},
}
pm.pluginLoader = l
err = pm.Add(context.Background(), testPluginID, "1.2.0")
repository.downloadOptionsHandler = func(_ context.Context, _, _ string, _ repo.CompatOpts) (*repo.PluginDownloadOptions, error) {
return &repo.PluginDownloadOptions{
Version: "1.2.0",
}, nil
}
err = pm.Add(context.Background(), testPluginID, "1.2.0", plugins.CompatOpts{})
assert.NoError(t, err)
assert.Equal(t, 2, i.installCount)
assert.Equal(t, 1, i.uninstallCount)
assert.Equal(t, 2, repository.downloadCount)
assert.Equal(t, 1, fsm.removed)
assert.Equal(t, 2, fsm.added)
assert.Equal(t, 1, pc.startCount)
assert.Equal(t, 0, pc.stopCount)
assert.False(t, pc.exited)
@ -212,12 +275,11 @@ func TestPluginManager_Installer(t *testing.T) {
assert.Len(t, pm.Plugins(context.Background()), 1)
})
t.Run("Uninstall", func(t *testing.T) {
t.Run("Uninstall existing plugin", func(t *testing.T) {
err := pm.Remove(context.Background(), p.ID)
require.NoError(t, err)
assert.Equal(t, 2, i.installCount)
assert.Equal(t, 2, i.uninstallCount)
assert.Equal(t, 2, repository.downloadCount)
p, exists := pm.Plugin(context.Background(), p.ID)
assert.False(t, exists)
@ -232,7 +294,9 @@ func TestPluginManager_Installer(t *testing.T) {
})
t.Run("Can't update core plugin", func(t *testing.T) {
p, pc := createPlugin(t, testPluginID, "", plugins.Core, true, true)
p, pc := createPlugin(t, testPluginID, plugins.Core, true, func(p *plugins.Plugin) {
p.Backend = true
})
loader := &fakeLoader{
mockedLoadedPlugins: []*plugins.Plugin{p},
@ -256,7 +320,7 @@ func TestPluginManager_Installer(t *testing.T) {
verifyNoPluginErrors(t, pm)
err = pm.Add(context.Background(), testPluginID, "")
err = pm.Add(context.Background(), testPluginID, "1.0.0", plugins.CompatOpts{})
assert.Equal(t, plugins.ErrInstallCorePlugin, err)
t.Run("Can't uninstall core plugin", func(t *testing.T) {
@ -266,7 +330,9 @@ func TestPluginManager_Installer(t *testing.T) {
})
t.Run("Can't update bundled plugin", func(t *testing.T) {
p, pc := createPlugin(t, testPluginID, "", plugins.Bundled, true, true)
p, pc := createPlugin(t, testPluginID, plugins.Bundled, true, func(p *plugins.Plugin) {
p.Backend = true
})
loader := &fakeLoader{
mockedLoadedPlugins: []*plugins.Plugin{p},
@ -290,7 +356,7 @@ func TestPluginManager_Installer(t *testing.T) {
verifyNoPluginErrors(t, pm)
err = pm.Add(context.Background(), testPluginID, "")
err = pm.Add(context.Background(), testPluginID, "1.0.0", plugins.CompatOpts{})
assert.Equal(t, plugins.ErrInstallCorePlugin, err)
t.Run("Can't uninstall bundled plugin", func(t *testing.T) {
@ -302,20 +368,20 @@ func TestPluginManager_Installer(t *testing.T) {
func TestPluginManager_registeredPlugins(t *testing.T) {
t.Run("Decommissioned plugins are included in registeredPlugins", func(t *testing.T) {
decommissionedPlugin, _ := createPlugin(t, testPluginID, "", plugins.Core, false, true,
func(plugin *plugins.Plugin) {
err := plugin.Decommission()
require.NoError(t, err)
},
)
require.True(t, decommissionedPlugin.IsDecommissioned())
decommissionedPlugin, _ := createPlugin(t, testPluginID, plugins.External, true, func(p *plugins.Plugin) {
p.Backend = true
err := p.Decommission()
require.NoError(t, err)
})
pm := New(&plugins.Cfg{}, &fakePluginRegistry{
store: map[string]*plugins.Plugin{
testPluginID: decommissionedPlugin,
"test-app": {},
},
}, []PluginSource{}, &fakeLoader{})
}, []PluginSource{}, &fakeLoader{}, &fakePluginRepo{}, &fakeFsManager{})
require.True(t, decommissionedPlugin.IsDecommissioned())
rps := pm.registeredPlugins(context.Background())
require.Equal(t, 2, len(rps))
@ -520,29 +586,17 @@ func TestPluginManager_lifecycle_unmanaged(t *testing.T) {
})
}
func createManager(t *testing.T, cbs ...func(*PluginManager)) *PluginManager {
t.Helper()
pm := New(&plugins.Cfg{}, newFakePluginRegistry(), nil, &fakeLoader{})
for _, cb := range cbs {
cb(pm)
}
return pm
}
func createPlugin(t *testing.T, pluginID, version string, class plugins.Class, managed, backend bool, cbs ...func(*plugins.Plugin)) (*plugins.Plugin, *fakePluginClient) {
func createPlugin(t *testing.T, pluginID string, class plugins.Class, managed bool,
cbs ...func(*plugins.Plugin)) (*plugins.Plugin, *fakePluginClient) {
t.Helper()
p := &plugins.Plugin{
Class: class,
JSONData: plugins.JSONData{
ID: pluginID,
Type: plugins.DataSource,
Backend: backend,
ID: pluginID,
Type: plugins.DataSource,
Info: plugins.Info{
Version: version,
Version: "1.0.0",
},
},
}
@ -566,6 +620,22 @@ func createPlugin(t *testing.T, pluginID, version string, class plugins.Class, m
return p, pc
}
func createManager(t *testing.T, cbs ...func(*PluginManager)) *PluginManager {
t.Helper()
cfg := &plugins.Cfg{
DevMode: false,
}
pm := New(cfg, newFakePluginRegistry(), nil, &fakeLoader{}, &fakePluginRepo{}, &fakeFsManager{})
for _, cb := range cbs {
cb(pm)
}
return pm
}
type managerScenarioCtx struct {
manager *PluginManager
plugin *plugins.Plugin
@ -583,12 +653,16 @@ func newScenario(t *testing.T, managed bool, fn func(t *testing.T, ctx *managerS
ManagedIdentityClientId: "client-id",
}
manager := New(cfg, registry.NewInMemory(), nil, &fakeLoader{})
loader := &fakeLoader{}
manager := New(cfg, registry.NewInMemory(), nil, loader, &fakePluginRepo{}, &fakeFsManager{})
manager.pluginLoader = loader
ctx := &managerScenarioCtx{
manager: manager,
}
ctx.plugin, ctx.pluginClient = createPlugin(t, testPluginID, "", plugins.External, managed, true)
ctx.plugin, ctx.pluginClient = createPlugin(t, testPluginID, plugins.External, managed, func(p *plugins.Plugin) {
p.Backend = true
})
fn(t, ctx)
}
@ -599,23 +673,33 @@ func verifyNoPluginErrors(t *testing.T, pm *PluginManager) {
}
}
type fakePluginInstaller struct {
installCount int
uninstallCount int
type fakePluginRepo struct {
repo.Service
downloadOptionsHandler func(_ context.Context, _, _ string, _ repo.CompatOpts) (*repo.PluginDownloadOptions, error)
downloadOptionsCount int
downloadCount int
}
func (f *fakePluginInstaller) Install(_ context.Context, _, _, _, _, _ string) error {
f.installCount++
return nil
func (pr *fakePluginRepo) GetPluginArchive(_ context.Context, _, _ string, _ repo.CompatOpts) (*repo.PluginArchive, error) {
pr.downloadCount++
return &repo.PluginArchive{}, nil
}
func (f *fakePluginInstaller) Uninstall(_ context.Context, _ string) error {
f.uninstallCount++
return nil
// DownloadWithURL downloads the requested plugin from the specified URL.
func (pr *fakePluginRepo) GetPluginArchiveByURL(_ context.Context, _ string, _ repo.CompatOpts) (*repo.PluginArchive, error) {
pr.downloadCount++
return &repo.PluginArchive{}, nil
}
func (f *fakePluginInstaller) GetUpdateInfo(_ context.Context, _, _, _ string) (plugins.UpdateInfo, error) {
return plugins.UpdateInfo{}, nil
// GetDownloadOptions provides information for downloading the requested plugin.
func (pr *fakePluginRepo) GetPluginDownloadOptions(ctx context.Context, pluginID, version string, opts repo.CompatOpts) (*repo.PluginDownloadOptions, error) {
pr.downloadOptionsCount++
if pr.downloadOptionsHandler != nil {
return pr.downloadOptionsHandler(ctx, pluginID, version, opts)
}
return &repo.PluginDownloadOptions{}, nil
}
type fakeLoader struct {
@ -790,3 +874,20 @@ func (f *fakePluginRegistry) Remove(_ context.Context, id string) error {
delete(f.store, id)
return nil
}
type fakeFsManager struct {
storage.Manager
added int
removed int
}
func (fsm *fakeFsManager) Add(_ context.Context, _ string, _ *zip.ReadCloser) (*storage.ExtractedPluginArchive, error) {
fsm.added++
return &storage.ExtractedPluginArchive{}, nil
}
func (fsm *fakeFsManager) Remove(_ context.Context, _ string) error {
fsm.removed++
return nil
}

View File

@ -2,10 +2,10 @@ package manager
import (
"context"
"path/filepath"
"strings"
"fmt"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/repo"
)
func (m *PluginManager) Plugin(ctx context.Context, pluginID string) (plugins.PluginDTO, bool) {
@ -68,9 +68,10 @@ func (m *PluginManager) registeredPlugins(ctx context.Context) map[string]struct
return pluginsByID
}
func (m *PluginManager) Add(ctx context.Context, pluginID, version string) error {
var pluginZipURL string
func (m *PluginManager) Add(ctx context.Context, pluginID, version string, opts plugins.CompatOpts) error {
compatOpts := repo.NewCompatOpts(opts.GrafanaVersion, opts.OS, opts.Arch)
var pluginArchive *repo.PluginArchive
if plugin, exists := m.plugin(ctx, pluginID); exists {
if !plugin.IsExternalPlugin() {
return plugins.ErrInstallCorePlugin
@ -83,28 +84,74 @@ func (m *PluginManager) Add(ctx context.Context, pluginID, version string) error
}
}
// get plugin update information to confirm if upgrading is possible
updateInfo, err := m.pluginInstaller.GetUpdateInfo(ctx, pluginID, version, grafanaComURL)
// get plugin update information to confirm if target update is possible
dlOpts, err := m.pluginRepo.GetPluginDownloadOptions(ctx, pluginID, version, compatOpts)
if err != nil {
return err
}
pluginZipURL = updateInfo.PluginZipURL
// if existing plugin version is the same as the target update version
if dlOpts.Version == plugin.Info.Version {
return plugins.DuplicateError{
PluginID: plugin.ID,
ExistingPluginDir: plugin.PluginDir,
}
}
if dlOpts.PluginZipURL == "" && dlOpts.Version == "" {
return fmt.Errorf("could not determine update options for %s", pluginID)
}
// remove existing installation of plugin
err = m.Remove(ctx, plugin.ID)
if err != nil {
return err
}
if dlOpts.PluginZipURL != "" {
pluginArchive, err = m.pluginRepo.GetPluginArchiveByURL(ctx, dlOpts.PluginZipURL, compatOpts)
if err != nil {
return err
}
} else {
pluginArchive, err = m.pluginRepo.GetPluginArchive(ctx, pluginID, dlOpts.Version, compatOpts)
if err != nil {
return err
}
}
} else {
var err error
pluginArchive, err = m.pluginRepo.GetPluginArchive(ctx, pluginID, version, compatOpts)
if err != nil {
return err
}
}
err := m.pluginInstaller.Install(ctx, pluginID, version, m.cfg.PluginsPath, pluginZipURL, grafanaComURL)
extractedArchive, err := m.pluginStorage.Add(ctx, pluginID, pluginArchive.File)
if err != nil {
return err
}
err = m.loadPlugins(context.Background(), plugins.External, m.cfg.PluginsPath)
// download dependency plugins
pathsToScan := []string{extractedArchive.Path}
for _, dep := range extractedArchive.Dependencies {
m.log.Info("Fetching %s dependencies...", dep.ID)
d, err := m.pluginRepo.GetPluginArchive(ctx, dep.ID, dep.Version, compatOpts)
if err != nil {
return fmt.Errorf("%v: %w", fmt.Sprintf("failed to download plugin %s from repository", dep.ID), err)
}
depArchive, err := m.pluginStorage.Add(ctx, dep.ID, d.File)
if err != nil {
return err
}
pathsToScan = append(pathsToScan, depArchive.Path)
}
err = m.loadPlugins(context.Background(), plugins.External, pathsToScan...)
if err != nil {
m.log.Error("Could not load plugins", "paths", pathsToScan, "err", err)
return err
}
@ -121,15 +168,9 @@ func (m *PluginManager) Remove(ctx context.Context, pluginID string) error {
return plugins.ErrUninstallCorePlugin
}
// extra security check to ensure we only remove plugins that are located in the configured plugins directory
path, err := filepath.Rel(m.cfg.PluginsPath, plugin.PluginDir)
if err != nil || strings.HasPrefix(path, ".."+string(filepath.Separator)) {
return plugins.ErrUninstallOutsideOfPluginDir
}
if err := m.unregisterAndStop(ctx, plugin); err != nil {
return err
}
return m.pluginInstaller.Uninstall(ctx, plugin.PluginDir)
return m.pluginStorage.Remove(ctx, plugin.ID)
}

View File

@ -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 {

View File

@ -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
View 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,
}
}

View 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())
}

View 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
View 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
}

View 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
View 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
}

View File

@ -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{}) {}

View 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
}

View File

@ -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"`
}

View File

@ -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)),