From 12dc56ad0cb5b6b982007d1498346fb03be05f41 Mon Sep 17 00:00:00 2001 From: Will Browne Date: Tue, 30 May 2023 11:48:52 +0200 Subject: [PATCH] Plugins: Refactor plugin repository API (#69063) * support grafana wildcard version * undo go.mod changes * tidy * flesh out tests * refactor * add tests * tidy naming * undo some changes * split interfaces * separation * update new signature * simplify * update var namings * unexport types * introduce opts pattern * reorder test * fix compat checks * middle ground * unexport client * move back * fix tests * inline logger * make client usable * use fake logger * tidy errors * remove unused types * fix test * review fixes * rework compatibility * adjust installer * fix tests * opts => cfg * remove unused var * fix var name --- pkg/api/plugins.go | 11 +- .../grafana-cli/commands/install_command.go | 7 +- pkg/plugins/ifaces.go | 27 ++- pkg/plugins/log/fake.go | 19 ++ pkg/plugins/manager/fakes/fakes.go | 16 +- pkg/plugins/manager/installer.go | 36 ++- pkg/plugins/manager/installer_test.go | 21 +- pkg/plugins/repo/client.go | 115 +++++----- pkg/plugins/repo/errors.go | 81 +++++++ pkg/plugins/repo/errors_test.go | 26 +++ pkg/plugins/repo/ifaces.go | 80 ++++++- pkg/plugins/repo/models.go | 70 +----- pkg/plugins/repo/service.go | 182 +++++----------- pkg/plugins/repo/service_test.go | 205 +++++++++++++----- pkg/plugins/repo/version.go | 110 ++++++++++ pkg/plugins/repo/version_test.go | 51 +++++ pkg/plugins/storage/fs_test.go | 19 +- pkg/tests/api/plugins/api_plugins_test.go | 6 + 18 files changed, 724 insertions(+), 358 deletions(-) create mode 100644 pkg/plugins/repo/errors.go create mode 100644 pkg/plugins/repo/errors_test.go create mode 100644 pkg/plugins/repo/version.go create mode 100644 pkg/plugins/repo/version_test.go diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 792f177909d..e10b70a7319 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -444,11 +444,8 @@ func (hs *HTTPServer) InstallPlugin(c *contextmodel.ReqContext) response.Respons } pluginID := web.Params(c.Req)[":pluginId"] - err := hs.pluginInstaller.Add(c.Req.Context(), pluginID, dto.Version, plugins.CompatOpts{ - GrafanaVersion: hs.Cfg.BuildVersion, - OS: runtime.GOOS, - Arch: runtime.GOARCH, - }) + compatOpts := plugins.NewCompatOpts(hs.Cfg.BuildVersion, runtime.GOOS, runtime.GOARCH) + err := hs.pluginInstaller.Add(c.Req.Context(), pluginID, dto.Version, compatOpts) if err != nil { var dupeErr plugins.DuplicateError if errors.As(err, &dupeErr) { @@ -462,9 +459,9 @@ func (hs *HTTPServer) InstallPlugin(c *contextmodel.ReqContext) response.Respons if errors.As(err, &versionNotFoundErr) { return response.Error(http.StatusNotFound, "Plugin version not found", err) } - var clientError repo.Response4xxError + var clientError repo.ErrResponse4xx if errors.As(err, &clientError) { - return response.Error(clientError.StatusCode, clientError.Message, err) + return response.Error(clientError.StatusCode(), clientError.Message(), err) } if errors.Is(err, plugins.ErrInstallCorePlugin) { return response.Error(http.StatusForbidden, "Cannot install or change a Core plugin", err) diff --git a/pkg/cmd/grafana-cli/commands/install_command.go b/pkg/cmd/grafana-cli/commands/install_command.go index eb15d1a5f84..3e90a403347 100644 --- a/pkg/cmd/grafana-cli/commands/install_command.go +++ b/pkg/cmd/grafana-cli/commands/install_command.go @@ -67,8 +67,11 @@ func installCommand(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) + repository := repo.NewManager(repo.ManagerCfg{ + SkipTLSVerify: c.Bool("insecure"), + BaseURL: c.PluginRepoURL(), + Logger: services.Logger, + }) compatOpts := repo.NewCompatOpts(services.GrafanaVersion, runtime.GOOS, runtime.GOARCH) diff --git a/pkg/plugins/ifaces.go b/pkg/plugins/ifaces.go index a75b218a028..f9e5e506b45 100644 --- a/pkg/plugins/ifaces.go +++ b/pkg/plugins/ifaces.go @@ -42,9 +42,30 @@ type File struct { } type CompatOpts struct { - GrafanaVersion string - OS string - Arch string + grafanaVersion string + + os string + arch string +} + +func (co CompatOpts) GrafanaVersion() string { + return co.grafanaVersion +} + +func (co CompatOpts) OS() string { + return co.os +} + +func (co CompatOpts) Arch() string { + return co.arch +} + +func NewCompatOpts(grafanaVersion, os, arch string) CompatOpts { + return CompatOpts{grafanaVersion: grafanaVersion, arch: arch, os: os} +} + +func NewSystemCompatOpts(os, arch string) CompatOpts { + return CompatOpts{arch: arch, os: os} } type UpdateInfo struct { diff --git a/pkg/plugins/log/fake.go b/pkg/plugins/log/fake.go index 72adf48b6ff..9085e24da5b 100644 --- a/pkg/plugins/log/fake.go +++ b/pkg/plugins/log/fake.go @@ -46,3 +46,22 @@ type Logs struct { Message string Ctx []interface{} } + +var _ PrettyLogger = (*TestPrettyLogger)(nil) + +type TestPrettyLogger struct{} + +func NewTestPrettyLogger() *TestPrettyLogger { + return &TestPrettyLogger{} +} + +func (f *TestPrettyLogger) Successf(_ string, _ ...interface{}) {} +func (f *TestPrettyLogger) Failuref(_ string, _ ...interface{}) {} +func (f *TestPrettyLogger) Info(_ ...interface{}) {} +func (f *TestPrettyLogger) Infof(_ string, _ ...interface{}) {} +func (f *TestPrettyLogger) Debug(_ ...interface{}) {} +func (f *TestPrettyLogger) Debugf(_ string, _ ...interface{}) {} +func (f *TestPrettyLogger) Warn(_ ...interface{}) {} +func (f *TestPrettyLogger) Warnf(_ string, _ ...interface{}) {} +func (f *TestPrettyLogger) Error(_ ...interface{}) {} +func (f *TestPrettyLogger) Errorf(_ string, _ ...interface{}) {} diff --git a/pkg/plugins/manager/fakes/fakes.go b/pkg/plugins/manager/fakes/fakes.go index 73585979579..f14d8ce0692 100644 --- a/pkg/plugins/manager/fakes/fakes.go +++ b/pkg/plugins/manager/fakes/fakes.go @@ -200,9 +200,9 @@ func (f *FakePluginRegistry) Remove(_ context.Context, id string) error { } type FakePluginRepo struct { - GetPluginArchiveFunc func(_ context.Context, pluginID, version string, _ repo.CompatOpts) (*repo.PluginArchive, error) - GetPluginArchiveByURLFunc func(_ context.Context, archiveURL string, _ repo.CompatOpts) (*repo.PluginArchive, error) - GetPluginDownloadOptionsFunc func(_ context.Context, pluginID, version string, _ repo.CompatOpts) (*repo.PluginDownloadOptions, error) + GetPluginArchiveFunc func(_ context.Context, pluginID, version string, _ repo.CompatOpts) (*repo.PluginArchive, error) + GetPluginArchiveByURLFunc func(_ context.Context, archiveURL string, _ repo.CompatOpts) (*repo.PluginArchive, error) + GetPluginArchiveInfoFunc func(_ context.Context, pluginID, version string, _ repo.CompatOpts) (*repo.PluginArchiveInfo, error) } // GetPluginArchive fetches the requested plugin archive. @@ -223,12 +223,12 @@ func (r *FakePluginRepo) GetPluginArchiveByURL(ctx context.Context, archiveURL s return &repo.PluginArchive{}, nil } -// GetPluginDownloadOptions fetches information for downloading the requested plugin. -func (r *FakePluginRepo) GetPluginDownloadOptions(ctx context.Context, pluginID, version string, opts repo.CompatOpts) (*repo.PluginDownloadOptions, error) { - if r.GetPluginDownloadOptionsFunc != nil { - return r.GetPluginDownloadOptionsFunc(ctx, pluginID, version, opts) +// GetPluginArchiveInfo fetches information for downloading the requested plugin. +func (r *FakePluginRepo) GetPluginArchiveInfo(ctx context.Context, pluginID, version string, opts repo.CompatOpts) (*repo.PluginArchiveInfo, error) { + if r.GetPluginArchiveInfoFunc != nil { + return r.GetPluginArchiveInfoFunc(ctx, pluginID, version, opts) } - return &repo.PluginDownloadOptions{}, nil + return &repo.PluginArchiveInfo{}, nil } type FakePluginStorage struct { diff --git a/pkg/plugins/manager/installer.go b/pkg/plugins/manager/installer.go index 2ecce8a18ae..5bba15f9054 100644 --- a/pkg/plugins/manager/installer.go +++ b/pkg/plugins/manager/installer.go @@ -2,6 +2,7 @@ package manager import ( "context" + "errors" "fmt" "github.com/grafana/grafana/pkg/plugins" @@ -26,7 +27,8 @@ type PluginInstaller struct { func ProvideInstaller(cfg *config.Cfg, pluginRegistry registry.Service, pluginLoader loader.Service, pluginRepo repo.Service) *PluginInstaller { - return New(pluginRegistry, pluginLoader, pluginRepo, storage.FileSystem(log.NewPrettyLogger("installer.fs"), cfg.PluginsPath)) + return New(pluginRegistry, pluginLoader, pluginRepo, + storage.FileSystem(log.NewPrettyLogger("installer.fs"), cfg.PluginsPath)) } func New(pluginRegistry registry.Service, pluginLoader loader.Service, pluginRepo repo.Service, @@ -41,7 +43,10 @@ func New(pluginRegistry registry.Service, pluginLoader loader.Service, pluginRep } func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opts plugins.CompatOpts) error { - compatOpts := repo.NewCompatOpts(opts.GrafanaVersion, opts.OS, opts.Arch) + compatOpts, err := repoCompatOpts(opts) + if err != nil { + return err + } var pluginArchive *repo.PluginArchive if plugin, exists := m.plugin(ctx, pluginID); exists { @@ -56,19 +61,19 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt } // get plugin update information to confirm if target update is possible - dlOpts, err := m.pluginRepo.GetPluginDownloadOptions(ctx, pluginID, version, compatOpts) + pluginArchiveInfo, err := m.pluginRepo.GetPluginArchiveInfo(ctx, pluginID, version, compatOpts) if err != nil { return err } // if existing plugin version is the same as the target update version - if dlOpts.Version == plugin.Info.Version { + if pluginArchiveInfo.Version == plugin.Info.Version { return plugins.DuplicateError{ PluginID: plugin.ID, } } - if dlOpts.PluginZipURL == "" && dlOpts.Version == "" { + if pluginArchiveInfo.URL == "" && pluginArchiveInfo.Version == "" { return fmt.Errorf("could not determine update options for %s", pluginID) } @@ -78,13 +83,13 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt return err } - if dlOpts.PluginZipURL != "" { - pluginArchive, err = m.pluginRepo.GetPluginArchiveByURL(ctx, dlOpts.PluginZipURL, compatOpts) + if pluginArchiveInfo.URL != "" { + pluginArchive, err = m.pluginRepo.GetPluginArchiveByURL(ctx, pluginArchiveInfo.URL, compatOpts) if err != nil { return err } } else { - pluginArchive, err = m.pluginRepo.GetPluginArchive(ctx, pluginID, dlOpts.Version, compatOpts) + pluginArchive, err = m.pluginRepo.GetPluginArchive(ctx, pluginID, pluginArchiveInfo.Version, compatOpts) if err != nil { return err } @@ -153,3 +158,18 @@ func (m *PluginInstaller) plugin(ctx context.Context, pluginID string) (*plugins return p, true } + +func repoCompatOpts(opts plugins.CompatOpts) (repo.CompatOpts, error) { + os := opts.OS() + arch := opts.Arch() + if len(os) == 0 || len(arch) == 0 { + return repo.CompatOpts{}, errors.New("invalid system compatibility options provided") + } + + grafanaVersion := opts.GrafanaVersion() + if len(grafanaVersion) == 0 { + return repo.NewSystemCompatOpts(os, arch), nil + } + + return repo.NewCompatOpts(grafanaVersion, os, arch), nil +} diff --git a/pkg/plugins/manager/installer_test.go b/pkg/plugins/manager/installer_test.go index 94769b29b6d..ef2591a5b27 100644 --- a/pkg/plugins/manager/installer_test.go +++ b/pkg/plugins/manager/installer_test.go @@ -4,6 +4,7 @@ import ( "archive/zip" "context" "fmt" + "runtime" "testing" "github.com/stretchr/testify/require" @@ -62,7 +63,7 @@ func TestPluginManager_Add_Remove(t *testing.T) { } inst := New(fakes.NewFakePluginRegistry(), loader, pluginRepo, fs) - err := inst.Add(context.Background(), pluginID, v1, plugins.CompatOpts{}) + err := inst.Add(context.Background(), pluginID, v1, testCompatOpts()) require.NoError(t, err) t.Run("Won't add if already exists", func(t *testing.T) { @@ -72,7 +73,7 @@ func TestPluginManager_Add_Remove(t *testing.T) { }, } - err = inst.Add(context.Background(), pluginID, v1, plugins.CompatOpts{}) + err = inst.Add(context.Background(), pluginID, v1, testCompatOpts()) require.Equal(t, plugins.DuplicateError{ PluginID: pluginV1.ID, }, err) @@ -96,9 +97,9 @@ func TestPluginManager_Add_Remove(t *testing.T) { require.Equal(t, []string{zipNameV2}, src.PluginURIs(ctx)) return []*plugins.Plugin{pluginV2}, nil } - pluginRepo.GetPluginDownloadOptionsFunc = func(_ context.Context, pluginID, version string, _ repo.CompatOpts) (*repo.PluginDownloadOptions, error) { - return &repo.PluginDownloadOptions{ - PluginZipURL: "https://grafanaplugins.com", + pluginRepo.GetPluginArchiveInfoFunc = func(_ context.Context, _, _ string, _ repo.CompatOpts) (*repo.PluginArchiveInfo, error) { + return &repo.PluginArchiveInfo{ + URL: "https://grafanaplugins.com", }, nil } pluginRepo.GetPluginArchiveByURLFunc = func(_ context.Context, pluginZipURL string, _ repo.CompatOpts) (*repo.PluginArchive, error) { @@ -115,7 +116,7 @@ func TestPluginManager_Add_Remove(t *testing.T) { }, nil } - err = inst.Add(context.Background(), pluginID, v2, plugins.CompatOpts{}) + err = inst.Add(context.Background(), pluginID, v2, testCompatOpts()) require.NoError(t, err) }) @@ -168,10 +169,10 @@ func TestPluginManager_Add_Remove(t *testing.T) { } pm := New(reg, &fakes.FakeLoader{}, &fakes.FakePluginRepo{}, &fakes.FakePluginStorage{}) - err := pm.Add(context.Background(), p.ID, "3.2.0", plugins.CompatOpts{}) + err := pm.Add(context.Background(), p.ID, "3.2.0", testCompatOpts()) require.ErrorIs(t, err, plugins.ErrInstallCorePlugin) - err = pm.Add(context.Background(), testPluginID, "", plugins.CompatOpts{}) + err = pm.Add(context.Background(), testPluginID, "", testCompatOpts()) require.Equal(t, plugins.ErrInstallCorePlugin, err) t.Run(fmt.Sprintf("Can't uninstall %s plugin", tc.class), func(t *testing.T) { @@ -206,3 +207,7 @@ func createPlugin(t *testing.T, pluginID string, class plugins.Class, managed, b return p } + +func testCompatOpts() plugins.CompatOpts { + return plugins.NewCompatOpts("10.0.0", runtime.GOOS, runtime.GOARCH) +} diff --git a/pkg/plugins/repo/client.go b/pkg/plugins/repo/client.go index 1588f619754..3276dd01034 100644 --- a/pkg/plugins/repo/client.go +++ b/pkg/plugins/repo/client.go @@ -26,7 +26,7 @@ type Client struct { log log.PrettyLogger } -func newClient(skipTLSVerify bool, logger log.PrettyLogger) *Client { +func NewClient(skipTLSVerify bool, logger log.PrettyLogger) *Client { return &Client{ httpClient: makeHttpClient(skipTLSVerify, 10*time.Second), httpClientNoTimeout: makeHttpClient(skipTLSVerify, 0), @@ -34,7 +34,7 @@ func newClient(skipTLSVerify bool, logger log.PrettyLogger) *Client { } } -func (c *Client) download(_ context.Context, pluginZipURL, checksum string, compatOpts CompatOpts) (*PluginArchive, error) { +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 { @@ -53,7 +53,7 @@ func (c *Client) download(_ context.Context, pluginZipURL, checksum string, comp if err := tmpFile.Close(); err != nil { c.log.Warn("Failed to close file", "err", err) } - return nil, fmt.Errorf("%w: failed to download plugin archive (%s)", err, pluginZipURL) + return nil, fmt.Errorf("failed to download plugin archive: %w", err) } rc, err := zip.OpenReader(tmpFile.Name()) @@ -61,9 +61,29 @@ func (c *Client) download(_ context.Context, pluginZipURL, checksum string, comp return nil, err } - return &PluginArchive{ - File: rc, - }, nil + return &PluginArchive{File: rc}, 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) downloadFile(tmpFile *os.File, pluginURL, checksum string, compatOpts CompatOpts) (err error) { @@ -122,8 +142,8 @@ func (c *Client) downloadFile(tmpFile *os.File, pluginURL, checksum string, comp 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. + // Using no timeout as some plugin archives make take longer to fetch due to size, network performance, etc. + // Note: This is also used as part of the grafana plugin install CLI operation bodyReader, err := c.sendReqNoTimeout(u, compatOpts) if err != nil { return err @@ -139,37 +159,15 @@ func (c *Client) downloadFile(tmpFile *os.File, pluginURL, checksum string, comp 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 { + 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 (%s) - please contact security@grafana.com", pluginURL) + return ErrChecksumMismatch{archiveURL: pluginURL} } 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 { @@ -189,10 +187,18 @@ func (c *Client) createReq(url *url.URL, compatOpts CompatOpts) (*http.Request, 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) + if gVer, exists := compatOpts.GrafanaVersion(); exists { + req.Header.Set("grafana-version", gVer) + req.Header.Set("User-Agent", "grafana "+gVer) + } + + if sysOS, exists := compatOpts.system.OS(); exists { + req.Header.Set("grafana-os", sysOS) + } + + if sysArch, exists := compatOpts.system.Arch(); exists { + req.Header.Set("grafana-arch", sysArch) + } return req, err } @@ -206,7 +212,7 @@ func (c *Client) handleResp(res *http.Response, compatOpts CompatOpts) (io.ReadC } }() if err != nil || len(body) == 0 { - return nil, Response4xxError{StatusCode: res.StatusCode} + return nil, newErrResponse4xx(res.StatusCode) } var message string var jsonBody map[string]string @@ -216,7 +222,8 @@ func (c *Client) handleResp(res *http.Response, compatOpts CompatOpts) (io.ReadC } else { message = jsonBody["message"] } - return nil, Response4xxError{StatusCode: res.StatusCode, Message: message, SystemInfo: compatOpts.String()} + + return nil, newErrResponse4xx(res.StatusCode).withMessage(message).withCompatibilityInfo(compatOpts) } if res.StatusCode/100 != 2 { @@ -227,23 +234,21 @@ func (c *Client) handleResp(res *http.Response, compatOpts CompatOpts) (io.ReadC } 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: &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/errors.go b/pkg/plugins/repo/errors.go new file mode 100644 index 00000000000..d8a402dd09c --- /dev/null +++ b/pkg/plugins/repo/errors.go @@ -0,0 +1,81 @@ +package repo + +import "fmt" + +type ErrResponse4xx struct { + message string + statusCode int + compatibilityInfo CompatOpts +} + +func newErrResponse4xx(statusCode int) ErrResponse4xx { + return ErrResponse4xx{ + statusCode: statusCode, + } +} + +func (e ErrResponse4xx) Message() string { + return e.message +} + +func (e ErrResponse4xx) StatusCode() int { + return e.statusCode +} + +func (e ErrResponse4xx) withMessage(message string) ErrResponse4xx { + e.message = message + return e +} + +func (e ErrResponse4xx) withCompatibilityInfo(compatibilityInfo CompatOpts) ErrResponse4xx { + e.compatibilityInfo = compatibilityInfo + return e +} + +func (e ErrResponse4xx) Error() string { + if len(e.message) > 0 { + compatInfo := e.compatibilityInfo.String() + if len(compatInfo) > 0 { + return fmt.Sprintf("%d: %s (%s)", e.statusCode, e.message, compatInfo) + } + 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) +} + +type ErrArcNotFound struct { + pluginID string + systemInfo string +} + +func (e ErrArcNotFound) Error() string { + return fmt.Sprintf("%s is not compatible with your system architecture: %s", e.pluginID, e.systemInfo) +} + +type ErrChecksumMismatch struct { + archiveURL string +} + +func (e ErrChecksumMismatch) Error() string { + return fmt.Sprintf("expected SHA256 checksum does not match the downloaded archive (%s) - please contact security@grafana.com", e.archiveURL) +} diff --git a/pkg/plugins/repo/errors_test.go b/pkg/plugins/repo/errors_test.go new file mode 100644 index 00000000000..d7870e534e0 --- /dev/null +++ b/pkg/plugins/repo/errors_test.go @@ -0,0 +1,26 @@ +package repo + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestErrResponse4xx(t *testing.T) { + t.Run("newErrResponse4xx returns expected error string", func(t *testing.T) { + err := newErrResponse4xx(http.StatusBadRequest) + require.Equal(t, "400", err.Error()) + require.Equal(t, http.StatusBadRequest, err.StatusCode()) + + msg := "This is terrible news" + err = err.withMessage(msg) + require.Equal(t, "400: This is terrible news", err.Error()) + require.Equal(t, msg, err.Message()) + + compatInfo := NewCompatOpts("10.0.0", "darwin", "amd64") + err = err.withCompatibilityInfo(compatInfo) + require.Equal(t, "400: This is terrible news (Grafana v10.0.0 darwin-amd64)", err.Error()) + require.Equal(t, compatInfo, err.compatibilityInfo) + }) +} diff --git a/pkg/plugins/repo/ifaces.go b/pkg/plugins/repo/ifaces.go index 91c50ddacb3..1f63010a285 100644 --- a/pkg/plugins/repo/ifaces.go +++ b/pkg/plugins/repo/ifaces.go @@ -6,34 +6,90 @@ import ( "strings" ) -// Service is responsible for retrieving plugin information from a repository. +// Service is responsible for retrieving plugin archive 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) + // GetPluginArchiveInfo fetches information needed for downloading the requested plugin. + GetPluginArchiveInfo(ctx context.Context, pluginID, version string, opts CompatOpts) (*PluginArchiveInfo, error) } type CompatOpts struct { - GrafanaVersion string - OS string - Arch string + grafanaVersion string + system SystemCompatOpts +} + +type SystemCompatOpts struct { + os string + arch string +} + +func (co CompatOpts) GrafanaVersion() (string, bool) { + if len(co.grafanaVersion) > 0 { + return co.grafanaVersion, true + } + return "", false +} + +func (co CompatOpts) System() (SystemCompatOpts, bool) { + os, osSet := co.system.OS() + arch, archSet := co.system.Arch() + if !osSet || !archSet { + return SystemCompatOpts{}, false + } + return SystemCompatOpts{os: os, arch: arch}, true +} + +func (co SystemCompatOpts) OS() (string, bool) { + if len(co.os) > 0 { + return co.os, true + } + return "", false +} + +func (co SystemCompatOpts) Arch() (string, bool) { + if len(co.arch) > 0 { + return co.arch, true + } + return "", false } func NewCompatOpts(grafanaVersion, os, arch string) CompatOpts { return CompatOpts{ - GrafanaVersion: grafanaVersion, - OS: os, - Arch: arch, + grafanaVersion: grafanaVersion, + system: SystemCompatOpts{ + os: os, + arch: arch, + }, } } -func (co CompatOpts) OSAndArch() string { - return fmt.Sprintf("%s-%s", strings.ToLower(co.OS), co.Arch) +func NewSystemCompatOpts(os, arch string) CompatOpts { + return CompatOpts{ + system: SystemCompatOpts{ + os: os, + arch: arch, + }, + } +} + +func (co SystemCompatOpts) OSAndArch() string { + if os, exists := co.OS(); !exists { + return "" + } else if arch, exists := co.Arch(); !exists { + return "" + } else { + return fmt.Sprintf("%s-%s", strings.ToLower(os), arch) + } } func (co CompatOpts) String() string { - return fmt.Sprintf("Grafana v%s %s", co.GrafanaVersion, co.OSAndArch()) + grafanaVersion, exists := co.GrafanaVersion() + if !exists { + return co.system.OSAndArch() + } + + return fmt.Sprintf("Grafana v%s %s", grafanaVersion, co.system.OSAndArch()) } diff --git a/pkg/plugins/repo/models.go b/pkg/plugins/repo/models.go index 35f3153022d..4816bdaf30f 100644 --- a/pkg/plugins/repo/models.go +++ b/pkg/plugins/repo/models.go @@ -1,29 +1,23 @@ package repo -import ( - "archive/zip" - "fmt" -) +import "archive/zip" type PluginArchive struct { File *zip.ReadCloser } -type PluginDownloadOptions struct { - PluginZipURL string - Version string - Checksum string +type PluginArchiveInfo struct { + URL string + Version string + Checksum string } -type Plugin struct { - ID string `json:"id"` - Category string `json:"category"` +// PluginRepo is (a subset of) the JSON response from /api/plugins/repo/$pluginID +type PluginRepo struct { 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"` } @@ -31,53 +25,3 @@ type Version struct { 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 ErrArcNotFound struct { - PluginID string - SystemInfo string -} - -func (e ErrArcNotFound) Error() string { - return fmt.Sprintf("%s is not compatible with your system architecture: %s", e.PluginID, e.SystemInfo) -} - -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 index 946feb70602..b6422a1dd8a 100644 --- a/pkg/plugins/repo/service.go +++ b/pkg/plugins/repo/service.go @@ -3,10 +3,10 @@ package repo import ( "context" "encoding/json" + "errors" "fmt" "net/url" "path" - "strings" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" @@ -20,167 +20,101 @@ type Manager struct { } func ProvideService(cfg *config.Cfg) (*Manager, error) { - defaultBaseURL, err := url.JoinPath(cfg.GrafanaComURL, "/api/plugins") + baseURL, err := url.JoinPath(cfg.GrafanaComURL, "/api/plugins") if err != nil { return nil, err } - return New(false, defaultBaseURL, log.NewPrettyLogger("plugin.repository")), nil + + return NewManager(ManagerCfg{ + SkipTLSVerify: false, + BaseURL: baseURL, + Logger: log.NewPrettyLogger("plugin.repository"), + }), nil } -func New(skipTLSVerify bool, baseURL string, logger log.PrettyLogger) *Manager { +type ManagerCfg struct { + SkipTLSVerify bool + BaseURL string + Logger log.PrettyLogger +} + +func NewManager(cfg ManagerCfg) *Manager { return &Manager{ - client: newClient(skipTLSVerify, logger), - baseURL: baseURL, - log: logger, + baseURL: cfg.BaseURL, + client: NewClient(cfg.SkipTLSVerify, cfg.Logger), + log: cfg.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) + dlOpts, err := m.GetPluginArchiveInfo(ctx, pluginID, version, compatOpts) if err != nil { return nil, err } - return m.client.download(ctx, dlOpts.PluginZipURL, dlOpts.Checksum, compatOpts) + return m.client.Download(ctx, dlOpts.URL, 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) + 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) +// GetPluginArchiveInfo returns the options for downloading the requested plugin (with optional `version`) +func (m *Manager) GetPluginArchiveInfo(_ context.Context, pluginID, version string, compatOpts CompatOpts) (*PluginArchiveInfo, error) { + v, err := m.pluginVersion(pluginID, version, 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), + return &PluginArchiveInfo{ + Version: v.Version, + Checksum: v.Checksum, + URL: m.downloadURL(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) +// pluginVersion will return plugin version based on the requested information +func (m *Manager) pluginVersion(pluginID, version string, compatOpts CompatOpts) (VersionData, error) { + versions, err := m.grafanaCompatiblePluginVersions(pluginID, compatOpts) + if err != nil { + return VersionData{}, err + } + sysCompatOpts, exists := compatOpts.System() + if !exists { + return VersionData{}, errors.New("no system compatibility requirements set") + } + + return SelectSystemCompatibleVersion(m.log, versions, pluginID, version, sysCompatOpts) +} + +func (m *Manager) downloadURL(pluginID, version string) string { + return fmt.Sprintf("%s/%s/versions/%s/download", m.baseURL, pluginID, version) +} + +// grafanaCompatiblePluginVersions will get version info from /api/plugins/repo/$pluginID based on +// the provided compatibility information (sent via HTTP headers) +func (m *Manager) grafanaCompatiblePluginVersions(pluginID string, compatOpts CompatOpts) ([]Version, error) { u, err := url.Parse(m.baseURL) if err != nil { - return Plugin{}, err + return nil, err } + u.Path = path.Join(u.Path, "repo", pluginID) - body, err := m.client.sendReq(u, compatOpts) + body, err := m.client.SendReq(u, compatOpts) if err != nil { - return Plugin{}, err + return nil, err } - var data Plugin - err = json.Unmarshal(body, &data) + var v PluginRepo + err = json.Unmarshal(body, &v) if err != nil { - m.log.Error("Failed to unmarshal plugin repo response error", err) - return Plugin{}, err + m.log.Error("Failed to unmarshal plugin repo response", err) + return nil, 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, ErrArcNotFound{ - PluginID: plugin.ID, - SystemInfo: compatOpts.OSAndArch(), - } - } - - 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 + return v.Versions, nil } diff --git a/pkg/plugins/repo/service_test.go b/pkg/plugins/repo/service_test.go index 6fcf89146cb..1536b032deb 100644 --- a/pkg/plugins/repo/service_test.go +++ b/pkg/plugins/repo/service_test.go @@ -1,53 +1,169 @@ package repo import ( + "archive/zip" + "bytes" + "context" "fmt" + "net/http" + "net/http/httptest" + "os" "testing" "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/plugins/log" ) -func TestSelectVersion(t *testing.T) { - i := &Manager{log: &fakeLogger{}} +const ( + dummyPluginJSON = `{ "id": "grafana-test-datasource" }` +) - 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) - }) +func TestGetPluginArchive(t *testing.T) { + tcs := []struct { + name string + sha string + err error + }{ + { + name: "Happy path", + sha: "69f698961b6ea651211a187874434821c4727cc22de022e3a7059116d21c75b1", + }, + { + name: "Incorrect SHA returns error", + sha: "1a2b3c", + err: &ErrChecksumMismatch{}, + }, + } - 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) - }) + pluginZip := createPluginArchive(t) + d, err := os.ReadFile(pluginZip.Name()) + require.NoError(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{}) + t.Cleanup(func() { + err = pluginZip.Close() + require.NoError(t, err) + err = os.RemoveAll(pluginZip.Name()) 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) + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + const ( + pluginID = "grafana-test-datasource" + version = "1.0.2" + opSys = "darwin" + arch = "amd64" + grafanaVersion = "10.0.0" + ) + + srv := mockPluginRepoAPI(t, + srvData{ + pluginID: pluginID, + version: version, + opSys: opSys, + arch: arch, + grafanaVersion: grafanaVersion, + sha: tc.sha, + archive: d, + }, + ) + t.Cleanup(srv.Close) + + m := NewManager(ManagerCfg{ + SkipTLSVerify: false, + BaseURL: srv.URL, + Logger: log.NewTestPrettyLogger(), + }) + co := NewCompatOpts(grafanaVersion, opSys, arch) + archive, err := m.GetPluginArchive(context.Background(), pluginID, version, co) + if tc.err != nil { + require.ErrorAs(t, err, tc.err) + return + } + require.NoError(t, err) + verifyArchive(t, archive) + }) + } +} + +func verifyArchive(t *testing.T, archive *PluginArchive) { + t.Helper() + require.NotNil(t, archive) + + pJSON, err := archive.File.Open("plugin.json") + require.NoError(t, err) + defer func() { require.NoError(t, pJSON.Close()) }() + buf := new(bytes.Buffer) + _, err = buf.ReadFrom(pJSON) + require.NoError(t, err) + require.Equal(t, dummyPluginJSON, buf.String()) +} + +func createPluginArchive(t *testing.T) *os.File { + t.Helper() + + pluginZip, err := os.CreateTemp(".", "test-plugin.zip") + require.NoError(t, err) + + zipWriter := zip.NewWriter(pluginZip) + pJSON, err := zipWriter.Create("plugin.json") + require.NoError(t, err) + _, err = pJSON.Write([]byte(dummyPluginJSON)) + require.NoError(t, err) + err = zipWriter.Close() + require.NoError(t, err) + + return pluginZip +} + +type srvData struct { + pluginID string + version string + opSys string + arch string + sha string + grafanaVersion string + archive []byte +} + +func mockPluginRepoAPI(t *testing.T, data srvData) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + // mock plugin version data + mux.HandleFunc(fmt.Sprintf("/repo/%s", data.pluginID), func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, data.grafanaVersion, r.Header.Get("grafana-version")) + require.Equal(t, data.opSys, r.Header.Get("grafana-os")) + require.Equal(t, data.arch, r.Header.Get("grafana-arch")) + require.NotNil(t, fmt.Sprintf("grafana %s", data.grafanaVersion), r.Header.Get("User-Agent")) + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + + _, _ = w.Write([]byte(fmt.Sprintf(` + { + "versions": [{ + "version": "%s", + "arch": { + "%s-%s": { + "sha256": "%s" + } + } + }] + } + `, data.version, data.opSys, data.arch, data.sha), + )) }) - 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) + // mock plugin archive + mux.HandleFunc(fmt.Sprintf("/%s/versions/%s/download", data.pluginID, data.version), func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(data.archive) }) + + return httptest.NewServer(mux) } type versionArg struct { @@ -55,16 +171,12 @@ type versionArg struct { arch []string } -func createPlugin(versions ...versionArg) *Plugin { - p := &Plugin{ - Versions: []Version{}, - } +func createPluginVersions(versions ...versionArg) []Version { + var vs []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{} @@ -74,21 +186,8 @@ func createPlugin(versions ...versionArg) *Plugin { } } } - p.Versions = append(p.Versions, ver) + vs = append(vs, ver) } - return p + return vs } - -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/repo/version.go b/pkg/plugins/repo/version.go new file mode 100644 index 00000000000..44489eff9a6 --- /dev/null +++ b/pkg/plugins/repo/version.go @@ -0,0 +1,110 @@ +package repo + +import ( + "strings" + + "github.com/grafana/grafana/pkg/plugins/log" +) + +type VersionData struct { + Version string + Checksum string +} + +// SelectSystemCompatibleVersion selects the most appropriate plugin version based on os + architecture +// 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 SelectSystemCompatibleVersion(log log.PrettyLogger, versions []Version, pluginID, version string, compatOpts SystemCompatOpts) (VersionData, error) { + version = normalizeVersion(version) + + var ver Version + latestForArch, exists := latestSupportedVersion(versions, compatOpts) + if !exists { + return VersionData{}, ErrArcNotFound{ + pluginID: pluginID, + systemInfo: compatOpts.OSAndArch(), + } + } + + if version == "" { + return VersionData{ + Version: latestForArch.Version, + Checksum: checksum(latestForArch, compatOpts), + }, nil + } + for _, v := range versions { + if v.Version == version { + ver = v + break + } + } + + if len(ver.Version) == 0 { + log.Debugf("Requested plugin version %s v%s not found but potential fallback version '%s' was found", + pluginID, version, latestForArch.Version) + return VersionData{}, ErrVersionNotFound{ + pluginID: pluginID, + requestedVersion: version, + systemInfo: compatOpts.OSAndArch(), + } + } + + if !supportsCurrentArch(ver, compatOpts) { + log.Debugf("Requested plugin version %s v%s is not supported on your system but potential fallback version '%s' was found", + pluginID, version, latestForArch.Version) + return VersionData{}, ErrVersionUnsupported{ + pluginID: pluginID, + requestedVersion: version, + systemInfo: compatOpts.OSAndArch(), + } + } + + return VersionData{ + Version: ver.Version, + Checksum: checksum(ver, compatOpts), + }, nil +} + +func checksum(v Version, compatOpts SystemCompatOpts) string { + if v.Arch != nil { + archMeta, exists := v.Arch[compatOpts.OSAndArch()] + if !exists { + archMeta = v.Arch["any"] + } + return archMeta.SHA256 + } + return "" +} + +func supportsCurrentArch(version Version, compatOpts SystemCompatOpts) bool { + if version.Arch == nil { + return true + } + for arch := range version.Arch { + if arch == compatOpts.OSAndArch() || arch == "any" { + return true + } + } + return false +} + +func latestSupportedVersion(versions []Version, compatOpts SystemCompatOpts) (Version, bool) { + for _, v := range versions { + if supportsCurrentArch(v, compatOpts) { + return v, true + } + } + return Version{}, false +} + +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/version_test.go b/pkg/plugins/repo/version_test.go new file mode 100644 index 00000000000..e3ee36ebaa0 --- /dev/null +++ b/pkg/plugins/repo/version_test.go @@ -0,0 +1,51 @@ +package repo + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/plugins/log" +) + +func TestSelectSystemCompatibleVersion(t *testing.T) { + logger := log.NewTestPrettyLogger() + t.Run("Should return error when requested version does not exist", func(t *testing.T) { + _, err := SelectSystemCompatibleVersion(log.NewTestPrettyLogger(), createPluginVersions(versionArg{version: "version"}), "test", "1.1.1", SystemCompatOpts{}) + require.Error(t, err) + }) + + t.Run("Should return error when no version supports current arch", func(t *testing.T) { + _, err := SelectSystemCompatibleVersion(logger, createPluginVersions(versionArg{version: "version", arch: []string{"non-existent"}}), "test", "", SystemCompatOpts{}) + require.Error(t, err) + }) + + t.Run("Should return error when requested version does not support current arch", func(t *testing.T) { + _, err := SelectSystemCompatibleVersion(logger, createPluginVersions( + versionArg{version: "2.0.0"}, + versionArg{version: "1.1.1", arch: []string{"non-existent"}}, + ), "test", "1.1.1", SystemCompatOpts{}) + require.Error(t, err) + }) + + t.Run("Should return latest available for arch when no version specified", func(t *testing.T) { + ver, err := SelectSystemCompatibleVersion(logger, createPluginVersions( + versionArg{version: "2.0.0", arch: []string{"non-existent"}}, + versionArg{version: "1.0.0"}, + ), "test", "", SystemCompatOpts{}) + 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 := SelectSystemCompatibleVersion(logger, createPluginVersions(versionArg{version: "2.0.0"}, versionArg{version: "1.0.0"}), "test", "", SystemCompatOpts{}) + require.NoError(t, err) + require.Equal(t, "2.0.0", ver.Version) + }) + + t.Run("Should return requested version", func(t *testing.T) { + ver, err := SelectSystemCompatibleVersion(logger, createPluginVersions(versionArg{version: "2.0.0"}, versionArg{version: "1.0.0"}), "test", "1.0.0", SystemCompatOpts{}) + require.NoError(t, err) + require.Equal(t, "1.0.0", ver.Version) + }) +} diff --git a/pkg/plugins/storage/fs_test.go b/pkg/plugins/storage/fs_test.go index cc9169a8bb9..900069f5a5c 100644 --- a/pkg/plugins/storage/fs_test.go +++ b/pkg/plugins/storage/fs_test.go @@ -10,6 +10,8 @@ import ( "testing" "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/plugins/log" ) func TestAdd(t *testing.T) { @@ -24,7 +26,7 @@ func TestAdd(t *testing.T) { pluginID := "test-app" - fs := FileSystem(&fakeLogger{}, testDir) + fs := FileSystem(log.NewTestPrettyLogger(), testDir) archive, err := fs.Extract(context.Background(), pluginID, zipFile(t, "./testdata/plugin-with-symlinks.zip")) require.NotNil(t, archive) require.NoError(t, err) @@ -51,7 +53,7 @@ func TestAdd(t *testing.T) { func TestExtractFiles(t *testing.T) { pluginsDir := setupFakePluginsDir(t) - i := &FS{log: &fakeLogger{}, pluginsDir: pluginsDir} + i := &FS{log: log.NewTestPrettyLogger(), pluginsDir: pluginsDir} t.Run("Should preserve file permissions for plugin backend binaries for linux and darwin", func(t *testing.T) { skipWindows(t) @@ -282,16 +284,3 @@ func skipWindows(t *testing.T) { t.Skip("Skipping test on Windows") } } - -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/tests/api/plugins/api_plugins_test.go b/pkg/tests/api/plugins/api_plugins_test.go index 8b6de85888e..32ce64272e0 100644 --- a/pkg/tests/api/plugins/api_plugins_test.go +++ b/pkg/tests/api/plugins/api_plugins_test.go @@ -41,6 +41,12 @@ func TestIntegrationPlugins(t *testing.T) { PluginAdminEnabled: true, }) + origBuildVersion := setting.BuildVersion + setting.BuildVersion = "0.0.0-test" + t.Cleanup(func() { + setting.BuildVersion = origBuildVersion + }) + grafanaListedAddr, store := testinfra.StartGrafana(t, dir, cfgPath) type testCase struct {