Plugins: Move plugin installing + uninstalling logic from CLI to plugins package (#33274)

* move guts from cli to server

* renaming + refactoring

* add pluginsDir arg

* arg fixes

* add support for repo URL override

* add funcs to interface

* use pluginID consistently

* swap args

* pass mandatory grafanaVersion field

* introduce logger interface

* create central logger for CLI

* add infra log wrapper

* re-add log initer step

* remove unused logger

* add checks for uninstalling

* improve debug blue

* make sure to close file

* fix linter issues

* remove space

* improve newline usage

* refactor packaging

* improve logger API

* fix interface func names

* close file and reformat zipslip catch

* handle G305 linter warning

* add helpful debug log
This commit is contained in:
Will Browne
2021-04-26 16:13:40 +02:00
committed by GitHub
parent d0239ac958
commit 8e6205c107
13 changed files with 874 additions and 16 deletions

View File

@@ -3,6 +3,8 @@ package commands
import ( import (
"strings" "strings"
"github.com/fatih/color"
"github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/commands/datamigrations" "github.com/grafana/grafana/pkg/cmd/grafana-cli/commands/datamigrations"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
@@ -57,7 +59,7 @@ func runPluginCommand(command func(commandLine utils.CommandLine) error) func(co
return err return err
} }
logger.Info("\nRestart Grafana after installing plugins. Refer to Grafana documentation for instructions if necessary.\n\n\n\n") logger.Info(color.GreenString("Please restart Grafana after installing plugins. Refer to Grafana documentation for instructions if necessary.\n\n"))
return nil return nil
} }
} }

View File

@@ -14,12 +14,13 @@ import (
"strings" "strings"
"github.com/fatih/color" "github.com/fatih/color"
"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/cmd/grafana-cli/utils"
"github.com/grafana/grafana/pkg/plugins/manager/installer"
"github.com/grafana/grafana/pkg/util/errutil" "github.com/grafana/grafana/pkg/util/errutil"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" "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"
) )
func validateInput(c utils.CommandLine, pluginFolder string) error { func validateInput(c utils.CommandLine, pluginFolder string) error {
@@ -54,10 +55,12 @@ func (cmd Command) installCommand(c utils.CommandLine) error {
return err return err
} }
pluginToInstall := c.Args().First() pluginID := c.Args().First()
version := c.Args().Get(1) version := c.Args().Get(1)
skipTLSVerify := c.Bool("insecure")
return InstallPlugin(pluginToInstall, version, c, cmd.Client) i := installer.New(skipTLSVerify, services.GrafanaVersion, services.Logger)
return i.Install(pluginID, version, c.PluginDirectory(), c.PluginURL(), c.PluginRepoURL())
} }
// InstallPlugin downloads the plugin code as a zip file from the Grafana.com API // InstallPlugin downloads the plugin code as a zip file from the Grafana.com API
@@ -76,7 +79,7 @@ func InstallPlugin(pluginName, version string, c utils.CommandLine, client utils
// is up to the user to know what she is doing. // is up to the user to know what she is doing.
isInternal = true isInternal = true
} }
plugin, err := client.GetPlugin(pluginName, c.RepoDirectory()) plugin, err := client.GetPlugin(pluginName, c.PluginRepoURL())
if err != nil { if err != nil {
return err return err
} }

View File

@@ -8,7 +8,7 @@ import (
// listRemoteCommand prints out all plugins in the remote repo with latest version supported on current platform. // listRemoteCommand prints out all plugins in the remote repo with latest version supported on current platform.
// If there are no supported versions for plugin it is skipped. // If there are no supported versions for plugin it is skipped.
func (cmd Command) listRemoteCommand(c utils.CommandLine) error { func (cmd Command) listRemoteCommand(c utils.CommandLine) error {
plugin, err := cmd.Client.ListAllPlugins(c.RepoDirectory()) plugin, err := cmd.Client.ListAllPlugins(c.PluginRepoURL())
if err != nil { if err != nil {
return err return err
} }

View File

@@ -18,7 +18,7 @@ func (cmd Command) upgradeCommand(c utils.CommandLine) error {
return err return err
} }
plugin, err2 := cmd.Client.GetPlugin(pluginName, c.RepoDirectory()) plugin, err2 := cmd.Client.GetPlugin(pluginName, c.PluginRepoURL())
if err2 != nil { if err2 != nil {
return err2 return err2
} }

View File

@@ -0,0 +1,74 @@
package logger
import (
"fmt"
"strings"
"github.com/fatih/color"
)
type CLILogger struct {
DebugMode bool
}
func New(debugMode bool) *CLILogger {
return &CLILogger{
DebugMode: debugMode,
}
}
func (l *CLILogger) Successf(format string, args ...interface{}) {
fmt.Printf(fmt.Sprintf("%s %s\n\n", color.GreenString("✔"), format), args...)
}
func (l *CLILogger) Failuref(format string, args ...interface{}) {
fmt.Printf(fmt.Sprintf("%s %s %s\n\n", color.RedString("Error"), color.RedString("✗"), format), args...)
}
func (l *CLILogger) Info(args ...interface{}) {
args = append(args, "\n\n")
fmt.Print(args...)
}
func (l *CLILogger) Infof(format string, args ...interface{}) {
fmt.Printf(addNewlines(format), args...)
}
func (l *CLILogger) Debug(args ...interface{}) {
args = append(args, "\n\n")
if l.DebugMode {
fmt.Print(color.HiBlueString(fmt.Sprint(args...)))
}
}
func (l *CLILogger) Debugf(format string, args ...interface{}) {
if l.DebugMode {
fmt.Print(color.HiBlueString(fmt.Sprintf(addNewlines(format), args...)))
}
}
func (l *CLILogger) Warn(args ...interface{}) {
args = append(args, "\n\n")
fmt.Print(args...)
}
func (l *CLILogger) Warnf(format string, args ...interface{}) {
fmt.Printf(addNewlines(format), args...)
}
func (l *CLILogger) Error(args ...interface{}) {
args = append(args, "\n\n")
fmt.Print(args...)
}
func (l *CLILogger) Errorf(format string, args ...interface{}) {
fmt.Printf(addNewlines(format), args...)
}
func addNewlines(str string) string {
var s strings.Builder
s.WriteString(str)
s.WriteString("\n\n")
return s.String()
}

View File

@@ -72,7 +72,7 @@ func main() {
} }
app.Before = func(c *cli.Context) error { app.Before = func(c *cli.Context) error {
services.Init(version, c.Bool("insecure")) services.Init(version, c.Bool("insecure"), c.Bool("debug"))
return nil return nil
} }

View File

@@ -174,10 +174,10 @@ func createRequest(repoUrl string, subPaths ...string) (*http.Request, error) {
return nil, err return nil, err
} }
req.Header.Set("grafana-version", grafanaVersion) req.Header.Set("grafana-version", GrafanaVersion)
req.Header.Set("grafana-os", runtime.GOOS) req.Header.Set("grafana-os", runtime.GOOS)
req.Header.Set("grafana-arch", runtime.GOARCH) req.Header.Set("grafana-arch", runtime.GOARCH)
req.Header.Set("User-Agent", "grafana "+grafanaVersion) req.Header.Set("User-Agent", "grafana "+GrafanaVersion)
return req, err return req, err
} }

View File

@@ -18,8 +18,9 @@ var (
IoHelper models.IoUtil = IoUtilImp{} IoHelper models.IoUtil = IoUtilImp{}
HttpClient http.Client HttpClient http.Client
HttpClientNoTimeout http.Client HttpClientNoTimeout http.Client
grafanaVersion string GrafanaVersion string
ErrNotFoundError = errors.New("404 not found error") ErrNotFoundError = errors.New("404 not found error")
Logger *logger.CLILogger
) )
type BadRequestError struct { type BadRequestError struct {
@@ -34,11 +35,12 @@ func (e *BadRequestError) Error() string {
return e.Status return e.Status
} }
func Init(version string, skipTLSVerify bool) { func Init(version string, skipTLSVerify bool, debugMode bool) {
grafanaVersion = version GrafanaVersion = version
HttpClient = makeHttpClient(skipTLSVerify, 10*time.Second) HttpClient = makeHttpClient(skipTLSVerify, 10*time.Second)
HttpClientNoTimeout = makeHttpClient(skipTLSVerify, 0) HttpClientNoTimeout = makeHttpClient(skipTLSVerify, 0)
Logger = logger.New(debugMode)
} }
func makeHttpClient(skipTLSVerify bool, timeout time.Duration) http.Client { func makeHttpClient(skipTLSVerify bool, timeout time.Duration) http.Client {
@@ -113,5 +115,6 @@ func RemoveInstalledPlugin(pluginPath, pluginName string) error {
return err return err
} }
logger.Debugf("Removing directory %v\n", pluginDir)
return IoHelper.RemoveAll(pluginDir) return IoHelper.RemoveAll(pluginDir)
} }

View File

@@ -20,7 +20,7 @@ type CommandLine interface {
Generic(name string) interface{} Generic(name string) interface{}
PluginDirectory() string PluginDirectory() string
RepoDirectory() string PluginRepoURL() string
PluginURL() string PluginURL() string
} }
@@ -54,7 +54,7 @@ func (c *ContextCommandLine) PluginDirectory() string {
return c.String("pluginsDir") return c.String("pluginsDir")
} }
func (c *ContextCommandLine) RepoDirectory() string { func (c *ContextCommandLine) PluginRepoURL() string {
return c.String("repo") return c.String("repo")
} }

View File

@@ -2,6 +2,7 @@ package plugins
import ( import (
"context" "context"
"os"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
@@ -70,3 +71,26 @@ type DataRequestHandler interface {
// HandleRequest handles a data request. // HandleRequest handles a data request.
HandleRequest(context.Context, *models.DataSource, DataQuery) (DataResponse, error) HandleRequest(context.Context, *models.DataSource, DataQuery) (DataResponse, error)
} }
type PluginInstaller interface {
// Install finds the plugin given the provided information
// and installs in the provided plugins directory.
Install(pluginID, version, pluginsDirectory, pluginZipURL, pluginRepoURL string) error
// Uninstall removes the specified plugin from the provided plugins directory.
Uninstall(pluginID, pluginPath string) error
DownloadFile(pluginID string, tmpFile *os.File, url string, checksum string) error
}
type PluginInstallerLogger 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

@@ -0,0 +1,640 @@
package installer
import (
"archive/zip"
"bufio"
"bytes"
"crypto/sha256"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"runtime"
"strings"
"time"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/util/errutil"
)
type Installer struct {
retryCount int
httpClient http.Client
httpClientNoTimeout http.Client
grafanaVersion string
log plugins.PluginInstallerLogger
}
const (
permissionsDeniedMessage = "could not create %q, permission denied, make sure you have write access to plugin dir"
)
var (
ErrNotFoundError = errors.New("404 not found error")
reGitBuild = regexp.MustCompile("^[a-zA-Z0-9_.-]*/")
)
type BadRequestError struct {
Message string
Status string
}
func (e *BadRequestError) Error() string {
if len(e.Message) > 0 {
return fmt.Sprintf("%s: %s", e.Status, e.Message)
}
return e.Status
}
func New(skipTLSVerify bool, grafanaVersion string, logger plugins.PluginInstallerLogger) *Installer {
return &Installer{
httpClient: makeHttpClient(skipTLSVerify, 10*time.Second),
httpClientNoTimeout: makeHttpClient(skipTLSVerify, 10*time.Second),
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(pluginID, version, pluginsDir, pluginZipURL, pluginRepoURL string) error {
isInternal := false
var checksum string
if pluginZipURL == "" {
if strings.HasPrefix(pluginID, "grafana-") {
// At this point the plugin download is going through grafana.com API and thus the name is validated.
// Checking for grafana prefix is how it is done there so no 3rd party plugin should have that prefix.
// You can supply custom plugin name and then set custom download url to 3rd party plugin but then that
// is up to the user to know what she is doing.
isInternal = true
}
plugin, err := i.getPluginMetadataFromPluginRepo(pluginID, pluginRepoURL)
if err != nil {
return err
}
v, err := 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 := ioutil.TempFile("", "*.zip")
if err != nil {
return errutil.Wrap("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 errutil.Wrap("failed to download plugin archive", err)
}
err = tmpFile.Close()
if err != nil {
return errutil.Wrap("failed to close tmp file", err)
}
err = i.extractFiles(tmpFile.Name(), pluginID, pluginsDir, isInternal)
if err != nil {
return errutil.Wrap("failed to extract plugin archive", err)
}
res, _ := toPluginDTO(pluginsDir, pluginID)
i.log.Successf("Installed %s v%s 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(dep.ID, normalizeVersion(dep.Version), pluginsDir, "", pluginRepoURL); err != nil {
return errutil.Wrapf(err, "failed to install plugin '%s'", dep.ID)
}
}
return err
}
// Uninstall removes the specified plugin from the provided plugins directory.
func (i *Installer) Uninstall(pluginID, pluginPath string) error {
pluginDir := filepath.Join(pluginPath, pluginID)
// 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", pluginPath)
}
}
}
}
i.log.Infof("Uninstalling plugin %v", pluginID)
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 errutil.Wrap("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 errutil.Wrap("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 errutil.Wrap("Failed to send request", 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 errutil.Wrap("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 {
if errors.Is(err, ErrNotFoundError) {
return Plugin{},
fmt.Errorf("failed to find plugin \"%s\" in plugin repository. Please check if plugin ID is correct",
pluginID)
}
return Plugin{}, errutil.Wrap("Failed to send request", 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 ioutil.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 == 404 {
return nil, ErrNotFoundError
}
if res.StatusCode/100 != 2 && res.StatusCode/100 != 4 {
return nil, fmt.Errorf("API returned invalid status: %s", res.Status)
}
if res.StatusCode/100 == 4 {
body, err := ioutil.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, &BadRequestError{Status: res.Status}
}
var message string
var jsonBody map[string]string
err = json.Unmarshal(body, &jsonBody)
if err != nil || len(jsonBody["message"]) == 0 {
message = string(body)
} else {
message = jsonBody["message"]
}
return nil, &BadRequestError{Status: res.Status, Message: message}
}
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
}
// selectVersion returns latest version if none is specified or the specified version. If the version string is not
// matched to existing version it errors out. It also errors out if version that is matched is not available for current
// os and platform. It expects plugin.Versions to be sorted so the newest version is first.
func selectVersion(plugin *Plugin, version string) (*Version, error) {
var ver Version
latestForArch := latestSupportedVersion(plugin)
if latestForArch == nil {
return nil, fmt.Errorf("%s is not supported on your architecture and OS", plugin.ID)
}
if version == "" {
return latestForArch, nil
}
for _, v := range plugin.Versions {
if v.Version == version {
ver = v
break
}
}
if len(ver.Version) == 0 {
return nil, fmt.Errorf("could not find a version %s for %s. The latest suitable version is %s",
version, plugin.ID, latestForArch.Version)
}
if !supportsCurrentArch(&ver) {
return nil, fmt.Errorf(
"the version you requested is not supported on your architecture and OS, latest suitable version is %s",
latestForArch.Version)
}
return &ver, nil
}
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, allowSymlinks bool) 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)
defer func() {
if err := r.Close(); err != nil {
i.log.Warn("failed to close zip file", "err", err)
}
}()
if err != nil {
return 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 errutil.Wrap("failed to create directory to extract plugin files", err)
}
if isSymlink(zf) {
if !allowSymlinks {
i.log.Warnf("%v: plugin archive contains a symlink, which is not allowed. Skipping", zf.Name)
continue
}
if err := extractSymlink(zf, dstPath); err != nil {
i.log.Warn("failed to extract symlink", "err", err)
continue
}
continue
}
if err := extractFile(zf, dstPath); err != nil {
return errutil.Wrap("failed to extract file", err)
}
}
return nil
}
func isSymlink(file *zip.File) bool {
return file.Mode()&os.ModeSymlink == os.ModeSymlink
}
func extractSymlink(file *zip.File, filePath string) error {
// symlink target is the contents of the file
src, err := file.Open()
if err != nil {
return errutil.Wrap("failed to extract file", err)
}
buf := new(bytes.Buffer)
if _, err := io.Copy(buf, src); err != nil {
return errutil.Wrap("failed to copy symlink contents", err)
}
if err := os.Symlink(strings.TrimSpace(buf.String()), filePath); err != nil {
return errutil.Wrapf(err, "failed to make symbolic link for %v", filePath)
}
return nil
}
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 errutil.Wrap("failed to open file", err)
}
defer func() {
err = dst.Close()
}()
src, err := file.Open()
if err != nil {
return errutil.Wrap("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 := ioutil.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 = ioutil.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

@@ -0,0 +1,48 @@
package installer
type InstalledPlugin struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Info PluginInfo `json:"info"`
Dependencies Dependencies `json:"dependencies"`
}
type Dependencies struct {
GrafanaVersion string `json:"grafanaVersion"`
Plugins []PluginDependency `json:"plugins"`
}
type PluginDependency struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Version string `json:"version"`
}
type PluginInfo struct {
Version string `json:"version"`
Updated string `json:"updated"`
}
type Plugin struct {
ID string `json:"id"`
Category string `json:"category"`
Versions []Version `json:"versions"`
}
type Version struct {
Commit string `json:"commit"`
URL string `json:"url"`
Version string `json:"version"`
Arch map[string]ArchMeta `json:"arch"`
}
type ArchMeta struct {
SHA256 string `json:"sha256"`
}
type PluginRepo struct {
Plugins []Plugin `json:"plugins"`
Version string `json:"version"`
}

View File

@@ -0,0 +1,64 @@
package manager
import (
"fmt"
"github.com/grafana/grafana/pkg/infra/log"
)
type InfraLogWrapper struct {
l log.Logger
debugMode bool
}
func New(name string, debugMode bool) (l *InfraLogWrapper) {
return &InfraLogWrapper{
debugMode: debugMode,
l: log.New(name),
}
}
func (l *InfraLogWrapper) Successf(format string, args ...interface{}) {
l.l.Info(fmt.Sprintf(format, args...))
}
func (l *InfraLogWrapper) Failuref(format string, args ...interface{}) {
l.l.Error(fmt.Sprintf(format, args...))
}
func (l *InfraLogWrapper) Info(args ...interface{}) {
l.l.Info(fmt.Sprint(args...))
}
func (l *InfraLogWrapper) Infof(format string, args ...interface{}) {
l.l.Info(fmt.Sprintf(format, args...))
}
func (l *InfraLogWrapper) Debug(args ...interface{}) {
if l.debugMode {
l.l.Debug(fmt.Sprint(args...))
}
}
func (l *InfraLogWrapper) Debugf(format string, args ...interface{}) {
if l.debugMode {
l.l.Debug(fmt.Sprintf(format, args...))
}
}
func (l *InfraLogWrapper) Warn(args ...interface{}) {
l.l.Warn(fmt.Sprint(args...))
}
func (l *InfraLogWrapper) Warnf(format string, args ...interface{}) {
l.l.Warn(fmt.Sprintf(format, args...))
}
func (l *InfraLogWrapper) Error(args ...interface{}) {
l.l.Error(fmt.Sprint(args...))
}
func (l *InfraLogWrapper) Errorf(format string, args ...interface{}) {
l.l.Error(fmt.Sprintf(format, args...))
}