diff --git a/pkg/api/fakes.go b/pkg/api/fakes.go index 6b1eda6f2ff..a1576267e00 100644 --- a/pkg/api/fakes.go +++ b/pkg/api/fakes.go @@ -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, diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index c3169187507..4256dc3fa78 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -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) } diff --git a/pkg/cmd/grafana-cli/commands/install_command.go b/pkg/cmd/grafana-cli/commands/install_command.go index fe12cdf380d..ba1620a4048 100644 --- a/pkg/cmd/grafana-cli/commands/install_command.go +++ b/pkg/cmd/grafana-cli/commands/install_command.go @@ -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 { diff --git a/pkg/cmd/grafana-cli/commands/upgrade_all_command.go b/pkg/cmd/grafana-cli/commands/upgrade_all_command.go index d669e60d0dc..9e6e655253e 100644 --- a/pkg/cmd/grafana-cli/commands/upgrade_all_command.go +++ b/pkg/cmd/grafana-cli/commands/upgrade_all_command.go @@ -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 } diff --git a/pkg/cmd/grafana-cli/commands/upgrade_command.go b/pkg/cmd/grafana-cli/commands/upgrade_command.go index 4d7ce1a2f0f..5e7fffe9037 100644 --- a/pkg/cmd/grafana-cli/commands/upgrade_command.go +++ b/pkg/cmd/grafana-cli/commands/upgrade_command.go @@ -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) diff --git a/pkg/cmd/grafana-cli/logger/loggerV2.go b/pkg/cmd/grafana-cli/logger/loggerV2.go index 15f54e41460..59c0b7237ed 100644 --- a/pkg/cmd/grafana-cli/logger/loggerV2.go +++ b/pkg/cmd/grafana-cli/logger/loggerV2.go @@ -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...))) } } diff --git a/pkg/cmd/grafana-cli/runner/wire.go b/pkg/cmd/grafana-cli/runner/wire.go index a3d2818de43..5cf247a005f 100644 --- a/pkg/cmd/grafana-cli/runner/wire.go +++ b/pkg/cmd/grafana-cli/runner/wire.go @@ -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)), diff --git a/pkg/plugins/ifaces.go b/pkg/plugins/ifaces.go index 062cf8e9e95..6a1f158777c 100644 --- a/pkg/plugins/ifaces.go +++ b/pkg/plugins/ifaces.go @@ -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 } diff --git a/pkg/plugins/logger/ifaces.go b/pkg/plugins/logger/ifaces.go new file mode 100644 index 00000000000..838407b2047 --- /dev/null +++ b/pkg/plugins/logger/ifaces.go @@ -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{}) +} diff --git a/pkg/plugins/manager/logger.go b/pkg/plugins/logger/logger.go similarity index 57% rename from pkg/plugins/manager/logger.go rename to pkg/plugins/logger/logger.go index 3352d38b673..e56bd19729b 100644 --- a/pkg/plugins/manager/logger.go +++ b/pkg/plugins/logger/logger.go @@ -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...)) } diff --git a/pkg/plugins/manager/installer/ifaces.go b/pkg/plugins/manager/installer/ifaces.go deleted file mode 100644 index 1de32b2aa45..00000000000 --- a/pkg/plugins/manager/installer/ifaces.go +++ /dev/null @@ -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{}) -} diff --git a/pkg/plugins/manager/installer/installer.go b/pkg/plugins/manager/installer/installer.go deleted file mode 100644 index 56064105720..00000000000 --- a/pkg/plugins/manager/installer/installer.go +++ /dev/null @@ -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 -} diff --git a/pkg/plugins/manager/manager.go b/pkg/plugins/manager/manager.go index f8c631655a1..1365935eda5 100644 --- a/pkg/plugins/manager/manager.go +++ b/pkg/plugins/manager/manager.go @@ -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"), } } diff --git a/pkg/plugins/manager/manager_integration_test.go b/pkg/plugins/manager/manager_integration_test.go index 96c903a05a1..da57fbd765b 100644 --- a/pkg/plugins/manager/manager_integration_test.go +++ b/pkg/plugins/manager/manager_integration_test.go @@ -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() diff --git a/pkg/plugins/manager/manager_test.go b/pkg/plugins/manager/manager_test.go index d29318cad33..c63d9d9008c 100644 --- a/pkg/plugins/manager/manager_test.go +++ b/pkg/plugins/manager/manager_test.go @@ -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 +} diff --git a/pkg/plugins/manager/store.go b/pkg/plugins/manager/store.go index 696866bcefb..304409c6f02 100644 --- a/pkg/plugins/manager/store.go +++ b/pkg/plugins/manager/store.go @@ -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) } diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index 0a3702f48a2..d9fe4e8ab24 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -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 { diff --git a/pkg/plugins/pfs/pfs_test.go b/pkg/plugins/pfs/pfs_test.go index a758314f99e..ad92138ba3e 100644 --- a/pkg/plugins/pfs/pfs_test.go +++ b/pkg/plugins/pfs/pfs_test.go @@ -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) diff --git a/pkg/plugins/repo/client.go b/pkg/plugins/repo/client.go new file mode 100644 index 00000000000..55ab43eefe0 --- /dev/null +++ b/pkg/plugins/repo/client.go @@ -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, + } +} diff --git a/pkg/plugins/repo/ifaces.go b/pkg/plugins/repo/ifaces.go new file mode 100644 index 00000000000..91c50ddacb3 --- /dev/null +++ b/pkg/plugins/repo/ifaces.go @@ -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()) +} diff --git a/pkg/plugins/repo/models.go b/pkg/plugins/repo/models.go new file mode 100644 index 00000000000..080e348108e --- /dev/null +++ b/pkg/plugins/repo/models.go @@ -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) +} diff --git a/pkg/plugins/repo/service.go b/pkg/plugins/repo/service.go new file mode 100644 index 00000000000..bdca0ec11b9 --- /dev/null +++ b/pkg/plugins/repo/service.go @@ -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 +} diff --git a/pkg/plugins/repo/service_test.go b/pkg/plugins/repo/service_test.go new file mode 100644 index 00000000000..6fcf89146cb --- /dev/null +++ b/pkg/plugins/repo/service_test.go @@ -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{}) {} diff --git a/pkg/plugins/storage/fs.go b/pkg/plugins/storage/fs.go new file mode 100644 index 00000000000..30a370ff9df --- /dev/null +++ b/pkg/plugins/storage/fs.go @@ -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 +} diff --git a/pkg/plugins/manager/installer/installer_test.go b/pkg/plugins/storage/fs_test.go similarity index 71% rename from pkg/plugins/manager/installer/installer_test.go rename to pkg/plugins/storage/fs_test.go index 0d6d9339cdd..85d10674671 100644 --- a/pkg/plugins/manager/installer/installer_test.go +++ b/pkg/plugins/storage/fs_test.go @@ -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{}) {} diff --git a/pkg/plugins/storage/ifaces.go b/pkg/plugins/storage/ifaces.go new file mode 100644 index 00000000000..1316ccfb3fa --- /dev/null +++ b/pkg/plugins/storage/ifaces.go @@ -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 +} diff --git a/pkg/plugins/manager/installer/models.go b/pkg/plugins/storage/models.go similarity index 56% rename from pkg/plugins/manager/installer/models.go rename to pkg/plugins/storage/models.go index 490e7246bcb..5d0b6dbebb8 100644 --- a/pkg/plugins/manager/installer/models.go +++ b/pkg/plugins/storage/models.go @@ -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"` -} diff --git a/pkg/plugins/manager/installer/testdata/grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip b/pkg/plugins/storage/testdata/grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip similarity index 100% rename from pkg/plugins/manager/installer/testdata/grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip rename to pkg/plugins/storage/testdata/grafana-simple-json-datasource-ec18fa4da8096a952608a7e4c7782b4260b41bcf.zip diff --git a/pkg/plugins/manager/installer/testdata/plugin-with-absolute-member.zip b/pkg/plugins/storage/testdata/plugin-with-absolute-member.zip similarity index 100% rename from pkg/plugins/manager/installer/testdata/plugin-with-absolute-member.zip rename to pkg/plugins/storage/testdata/plugin-with-absolute-member.zip diff --git a/pkg/plugins/manager/installer/testdata/plugin-with-absolute-symlink-dir.zip b/pkg/plugins/storage/testdata/plugin-with-absolute-symlink-dir.zip similarity index 100% rename from pkg/plugins/manager/installer/testdata/plugin-with-absolute-symlink-dir.zip rename to pkg/plugins/storage/testdata/plugin-with-absolute-symlink-dir.zip diff --git a/pkg/plugins/manager/installer/testdata/plugin-with-absolute-symlink.zip b/pkg/plugins/storage/testdata/plugin-with-absolute-symlink.zip similarity index 100% rename from pkg/plugins/manager/installer/testdata/plugin-with-absolute-symlink.zip rename to pkg/plugins/storage/testdata/plugin-with-absolute-symlink.zip diff --git a/pkg/plugins/manager/installer/testdata/plugin-with-parent-member.zip b/pkg/plugins/storage/testdata/plugin-with-parent-member.zip similarity index 100% rename from pkg/plugins/manager/installer/testdata/plugin-with-parent-member.zip rename to pkg/plugins/storage/testdata/plugin-with-parent-member.zip diff --git a/pkg/plugins/manager/installer/testdata/plugin-with-symlink-dir.zip b/pkg/plugins/storage/testdata/plugin-with-symlink-dir.zip similarity index 100% rename from pkg/plugins/manager/installer/testdata/plugin-with-symlink-dir.zip rename to pkg/plugins/storage/testdata/plugin-with-symlink-dir.zip diff --git a/pkg/plugins/manager/installer/testdata/plugin-with-symlink.zip b/pkg/plugins/storage/testdata/plugin-with-symlink.zip similarity index 100% rename from pkg/plugins/manager/installer/testdata/plugin-with-symlink.zip rename to pkg/plugins/storage/testdata/plugin-with-symlink.zip diff --git a/pkg/plugins/manager/installer/testdata/plugin-with-symlinks.zip b/pkg/plugins/storage/testdata/plugin-with-symlinks.zip similarity index 100% rename from pkg/plugins/manager/installer/testdata/plugin-with-symlinks.zip rename to pkg/plugins/storage/testdata/plugin-with-symlinks.zip diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 1d850dd1135..e46a8823ec7 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -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)),