The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
grafana/pkg/cmd/grafana-cli/commands/install_command.go

359 lines
10 KiB

package commands
import (
"archive/zip"
"bytes"
Plugins: Enable plugin runtime install/uninstall capabilities (#33836) * 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>
4 years ago
"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/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)
Plugins: Enable plugin runtime install/uninstall capabilities (#33836) * 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>
4 years ago
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 fmt.Errorf("%v: %w", "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 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 = extractFiles(tmpFile.Name(), pluginName, pluginFolder, isInternal)
if err != nil {
return fmt.Errorf("%v: %w", "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 fmt.Errorf("failed to install plugin '%s': %w", v.ID, err)
}
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 fmt.Errorf("%v: %w", "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 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(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)
}
if err := os.Symlink(strings.TrimSpace(buf.String()), filePath); err != nil {
return fmt.Errorf("failed to make symbolic link for %v: %w", filePath, err)
}
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)
}
Security: Add gosec G304 auditing annotations (#29578) * Security: Add gosec G304 auditing annotations Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com> * add G304 auditing comment Signed-off-by: bergquist <carl.bergquist@gmail.com> * add G304 auditing comment Signed-off-by: bergquist <carl.bergquist@gmail.com> * add G304 auditing comment Signed-off-by: bergquist <carl.bergquist@gmail.com> * add G304 auditing comment Signed-off-by: bergquist <carl.bergquist@gmail.com> * add G304 auditing comment Signed-off-by: bergquist <carl.bergquist@gmail.com> * add G304 auditing comment Signed-off-by: bergquist <carl.bergquist@gmail.com> * add G304 auditing comment Signed-off-by: bergquist <carl.bergquist@gmail.com> * add G304 auditing comment Signed-off-by: bergquist <carl.bergquist@gmail.com> * Add gosec annotations Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com> * add G304 auditing comment Signed-off-by: bergquist <carl.bergquist@gmail.com> * add G304 auditing comment Signed-off-by: bergquist <carl.bergquist@gmail.com> * Add gosec annotations Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com> * add G304 auditing comment Signed-off-by: bergquist <carl.bergquist@gmail.com> * add G304 auditing comment Signed-off-by: bergquist <carl.bergquist@gmail.com> * add G304 auditing comment Signed-off-by: bergquist <carl.bergquist@gmail.com> * Add gosec annotations Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com> * space Signed-off-by: bergquist <carl.bergquist@gmail.com> * Add gosec annotations Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com> Co-authored-by: bergquist <carl.bergquist@gmail.com>
5 years ago
// 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
}