mirror of
https://github.com/grafana/grafana.git
synced 2025-02-12 00:25:46 -06:00
* add uninstall flow * add install flow * small cleanup * smaller-footprint solution * cleanup + make bp start auto * fix interface contract * improve naming * accept version arg * ensure use of shared logger * make installer a field * add plugin decommissioning * add basic error checking * fix api docs * making initialization idempotent * add mutex * fix comment * fix test * add test for decommission * improve existing test * add more test coverage * more tests * change test func to use read lock * refactoring + adding test asserts * improve purging old install flow * improve dupe checking * change log name * skip over dupe scanned * make test assertion more flexible * remove trailing line * fix pointer receiver name * update comment * add context to API * add config flag * add base http api test + fix update functionality * simplify existing check * clean up test * refactor tests based on feedback * add single quotes to errs * use gcmp in tests + fix logo issue * make plugin list testing more flexible * address feedback * fix API test * fix linter * undo preallocate * Update docs/sources/administration/configuration.md Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> * Update docs/sources/administration/configuration.md Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> * Update docs/sources/administration/configuration.md Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> * fix linting issue in test * add docs placeholder * update install notes * Update docs/sources/plugins/marketplace.md Co-authored-by: Marcus Olsson <marcus.olsson@hey.com> * update access wording * add more placeholder docs * add link to more info * PR feedback - improved errors, refactor, lock fix * improve err details * propagate plugin version errors * don't autostart renderer * add H1 * fix imports Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> Co-authored-by: Marcus Olsson <marcus.olsson@hey.com>
360 lines
10 KiB
Go
360 lines
10 KiB
Go
package commands
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"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/plugins/manager/installer"
|
|
"github.com/grafana/grafana/pkg/util/errutil"
|
|
|
|
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
|
)
|
|
|
|
func validateInput(c utils.CommandLine, pluginFolder string) error {
|
|
arg := c.Args().First()
|
|
if arg == "" {
|
|
return errors.New("please specify plugin to install")
|
|
}
|
|
|
|
pluginsDir := c.PluginDirectory()
|
|
if pluginsDir == "" {
|
|
return errors.New("missing pluginsDir flag")
|
|
}
|
|
|
|
fileInfo, err := os.Stat(pluginsDir)
|
|
if err != nil {
|
|
if err = os.MkdirAll(pluginsDir, os.ModePerm); err != nil {
|
|
return fmt.Errorf("pluginsDir (%s) is not a writable directory", pluginsDir)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if !fileInfo.IsDir() {
|
|
return errors.New("path is not a directory")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (cmd Command) installCommand(c utils.CommandLine) error {
|
|
pluginFolder := c.PluginDirectory()
|
|
if err := validateInput(c, pluginFolder); err != nil {
|
|
return err
|
|
}
|
|
|
|
pluginID := c.Args().First()
|
|
version := c.Args().Get(1)
|
|
skipTLSVerify := c.Bool("insecure")
|
|
|
|
i := installer.New(skipTLSVerify, services.GrafanaVersion, services.Logger)
|
|
return i.Install(context.Background(), pluginID, version, c.PluginDirectory(), c.PluginURL(), c.PluginRepoURL())
|
|
}
|
|
|
|
// 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(pluginName, version string, c utils.CommandLine, client utils.ApiClient) error {
|
|
pluginFolder := c.PluginDirectory()
|
|
downloadURL := c.PluginURL()
|
|
isInternal := false
|
|
|
|
var checksum string
|
|
if downloadURL == "" {
|
|
if strings.HasPrefix(pluginName, "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 := client.GetPlugin(pluginName, c.PluginRepoURL())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
v, err := SelectVersion(&plugin, version)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if version == "" {
|
|
version = v.Version
|
|
}
|
|
downloadURL = fmt.Sprintf("%s/%s/versions/%s/download",
|
|
c.String("repo"),
|
|
pluginName,
|
|
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
|
|
}
|
|
}
|
|
|
|
logger.Infof("installing %v @ %v\n", pluginName, version)
|
|
logger.Infof("from: %v\n", downloadURL)
|
|
logger.Infof("into: %v\n", pluginFolder)
|
|
logger.Info("\n")
|
|
|
|
// 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 {
|
|
logger.Warn("Failed to remove temporary file", "file", tmpFile.Name(), "err", err)
|
|
}
|
|
}()
|
|
|
|
err = client.DownloadFile(pluginName, tmpFile, downloadURL, checksum)
|
|
if err != nil {
|
|
if err := tmpFile.Close(); err != nil {
|
|
logger.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 = extractFiles(tmpFile.Name(), pluginName, pluginFolder, isInternal)
|
|
if err != nil {
|
|
return errutil.Wrap("failed to extract plugin archive", err)
|
|
}
|
|
|
|
logger.Infof("%s Installed %s successfully \n", color.GreenString("✔"), pluginName)
|
|
|
|
res, _ := services.ReadPlugin(pluginFolder, pluginName)
|
|
for _, v := range res.Dependencies.Plugins {
|
|
if err := InstallPlugin(v.ID, "", c, client); err != nil {
|
|
return errutil.Wrapf(err, "failed to install plugin '%s'", v.ID)
|
|
}
|
|
|
|
logger.Infof("Installed dependency: %v ✔\n", v.ID)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func osAndArchString() string {
|
|
osString := strings.ToLower(runtime.GOOS)
|
|
arch := runtime.GOARCH
|
|
return osString + "-" + arch
|
|
}
|
|
|
|
func supportsCurrentArch(version *models.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 *models.Plugin) *models.Version {
|
|
for _, v := range plugin.Versions {
|
|
ver := v
|
|
if supportsCurrentArch(&ver) {
|
|
return &ver
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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 *models.Plugin, version string) (*models.Version, error) {
|
|
var ver models.Version
|
|
|
|
latestForArch := latestSupportedVersion(plugin)
|
|
if latestForArch == nil {
|
|
return nil, fmt.Errorf("plugin is not supported on your architecture and OS")
|
|
}
|
|
|
|
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 the version you're looking for")
|
|
}
|
|
|
|
if !supportsCurrentArch(&ver) {
|
|
return nil, fmt.Errorf(
|
|
"the version you want is not supported on your architecture and OS, latest suitable version is %s",
|
|
latestForArch.Version)
|
|
}
|
|
|
|
return &ver, nil
|
|
}
|
|
|
|
var reGitBuild = regexp.MustCompile("^[a-zA-Z0-9_.-]*/")
|
|
|
|
func removeGitBuildFromName(pluginName, filename string) string {
|
|
return reGitBuild.ReplaceAllString(filename, pluginName+"/")
|
|
}
|
|
|
|
const permissionsDeniedMessage = "could not create %q, permission denied, make sure you have write access to plugin dir"
|
|
|
|
func extractFiles(archiveFile string, pluginName string, dstDir string, allowSymlinks bool) error {
|
|
var err error
|
|
dstDir, err = filepath.Abs(dstDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logger.Debugf("Extracting archive %q to %q...\n", archiveFile, dstDir)
|
|
|
|
existingInstallDir := filepath.Join(dstDir, pluginName)
|
|
if _, err := os.Stat(existingInstallDir); !os.IsNotExist(err) {
|
|
err = os.RemoveAll(existingInstallDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
logger.Infof("Removed existing installation of %s\n\n", pluginName)
|
|
}
|
|
|
|
r, err := zip.OpenReader(archiveFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, zf := range r.File {
|
|
if filepath.IsAbs(zf.Name) || strings.HasPrefix(zf.Name, ".."+string(filepath.Separator)) {
|
|
return fmt.Errorf(
|
|
"archive member %q tries to write outside of plugin directory: %q, this can be a security risk",
|
|
zf.Name, dstDir)
|
|
}
|
|
|
|
dstPath := filepath.Clean(filepath.Join(dstDir, removeGitBuildFromName(pluginName, zf.Name)))
|
|
|
|
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 {
|
|
logger.Warnf("%v: plugin archive contains a symlink, which is not allowed. Skipping \n", zf.Name)
|
|
continue
|
|
}
|
|
if err := extractSymlink(zf, dstPath); err != nil {
|
|
logger.Errorf("Failed to extract symlink: %v \n", 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
|
|
}
|