diff --git a/pkg/api/fakes.go b/pkg/api/fakes.go index 28b1a747443..1f2508812d4 100644 --- a/pkg/api/fakes.go +++ b/pkg/api/fakes.go @@ -22,7 +22,7 @@ func NewFakePluginInstaller() *fakePluginInstaller { return &fakePluginInstaller{plugins: map[string]fakePlugin{}} } -func (pm *fakePluginInstaller) Add(_ context.Context, pluginID, version string, _ plugins.CompatOpts) error { +func (pm *fakePluginInstaller) Add(_ context.Context, pluginID, version string, _ plugins.AddOpts) error { pm.plugins[pluginID] = fakePlugin{ pluginID: pluginID, version: version, diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 905feddef0f..fffadca6a2d 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -467,7 +467,7 @@ func (hs *HTTPServer) InstallPlugin(c *contextmodel.ReqContext) response.Respons } } - compatOpts := plugins.NewCompatOpts(hs.Cfg.BuildVersion, runtime.GOOS, runtime.GOARCH) + compatOpts := plugins.NewAddOpts(hs.Cfg.BuildVersion, runtime.GOOS, runtime.GOARCH, "") ctx := repo.WithRequestOrigin(c.Req.Context(), "api") err := hs.pluginInstaller.Add(ctx, pluginID, dto.Version, compatOpts) if err != nil { diff --git a/pkg/plugins/ifaces.go b/pkg/plugins/ifaces.go index 14684b3bc55..0160fc1112b 100644 --- a/pkg/plugins/ifaces.go +++ b/pkg/plugins/ifaces.go @@ -12,7 +12,7 @@ import ( type Installer interface { // Add adds a new plugin. - Add(ctx context.Context, pluginID, version string, opts CompatOpts) error + Add(ctx context.Context, pluginID, version string, opts AddOpts) error // Remove removes an existing plugin. Remove(ctx context.Context, pluginID, version string) error } @@ -33,31 +33,33 @@ type File struct { ModTime time.Time } -type CompatOpts struct { +type AddOpts struct { grafanaVersion string os string arch string + + url string } -func (co CompatOpts) GrafanaVersion() string { +func (co AddOpts) GrafanaVersion() string { return co.grafanaVersion } -func (co CompatOpts) OS() string { +func (co AddOpts) OS() string { return co.os } -func (co CompatOpts) Arch() string { +func (co AddOpts) Arch() string { return co.arch } -func NewCompatOpts(grafanaVersion, os, arch string) CompatOpts { - return CompatOpts{grafanaVersion: grafanaVersion, arch: arch, os: os} +func (co AddOpts) URL() string { + return co.url } -func NewSystemCompatOpts(os, arch string) CompatOpts { - return CompatOpts{arch: arch, os: os} +func NewAddOpts(grafanaVersion, os, arch, url string) AddOpts { + return AddOpts{grafanaVersion: grafanaVersion, arch: arch, os: os, url: url} } type UpdateInfo struct { diff --git a/pkg/plugins/manager/fakes/fakes.go b/pkg/plugins/manager/fakes/fakes.go index 8120b460b79..d06e81ceaa3 100644 --- a/pkg/plugins/manager/fakes/fakes.go +++ b/pkg/plugins/manager/fakes/fakes.go @@ -21,12 +21,12 @@ import ( ) type FakePluginInstaller struct { - AddFunc func(ctx context.Context, pluginID, version string, opts plugins.CompatOpts) error + AddFunc func(ctx context.Context, pluginID, version string, opts plugins.AddOpts) error // Remove removes a plugin from the store. RemoveFunc func(ctx context.Context, pluginID, version string) error } -func (i *FakePluginInstaller) Add(ctx context.Context, pluginID, version string, opts plugins.CompatOpts) error { +func (i *FakePluginInstaller) Add(ctx context.Context, pluginID, version string, opts plugins.AddOpts) error { if i.AddFunc != nil { return i.AddFunc(ctx, pluginID, version, opts) } diff --git a/pkg/plugins/manager/installer.go b/pkg/plugins/manager/installer.go index 227d8a35450..cd445383d2e 100644 --- a/pkg/plugins/manager/installer.go +++ b/pkg/plugins/manager/installer.go @@ -51,12 +51,7 @@ func New(pluginRegistry registry.Service, pluginLoader loader.Service, pluginRep } } -func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opts plugins.CompatOpts) error { - compatOpts, err := RepoCompatOpts(opts) - if err != nil { - return err - } - +func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opts plugins.AddOpts) error { if ok, _ := m.installing.Load(pluginID); ok != nil { return nil } @@ -65,7 +60,7 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt m.installing.Delete(pluginID) }() - archive, err := m.install(ctx, pluginID, version, compatOpts) + archive, err := m.install(ctx, pluginID, version, opts) if err != nil { return err } @@ -93,8 +88,12 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt return nil } -func (m *PluginInstaller) install(ctx context.Context, pluginID, version string, compatOpts repo.CompatOpts) (*storage.ExtractedPluginArchive, error) { +func (m *PluginInstaller) install(ctx context.Context, pluginID, version string, opts plugins.AddOpts) (*storage.ExtractedPluginArchive, error) { var pluginArchive *repo.PluginArchive + compatOpts, err := RepoCompatOpts(opts) + if err != nil { + return nil, err + } if plugin, exists := m.plugin(ctx, pluginID, version); exists { if plugin.IsCorePlugin() || plugin.IsBundledPlugin() { return nil, plugins.ErrInstallCorePlugin @@ -105,46 +104,21 @@ func (m *PluginInstaller) install(ctx context.Context, pluginID, version string, PluginID: plugin.ID, } } - - // get plugin update information to confirm if target update is possible - pluginArchiveInfo, err := m.pluginRepo.GetPluginArchiveInfo(ctx, pluginID, version, compatOpts) - if err != nil { - return nil, err - } - - m.log.Info("Updating plugin", "pluginId", pluginID, "from", plugin.Info.Version, "to", pluginArchiveInfo.Version) - - // if existing plugin version is the same as the target update version - if pluginArchiveInfo.Version == plugin.Info.Version { - return nil, plugins.DuplicateError{ - PluginID: plugin.ID, - } - } - - if pluginArchiveInfo.URL == "" && pluginArchiveInfo.Version == "" { - return nil, fmt.Errorf("could not determine update options for %s", pluginID) - } - - // remove existing installation of plugin - err = m.Remove(ctx, plugin.ID, plugin.Info.Version) - if err != nil { - return nil, err - } - - if pluginArchiveInfo.URL != "" { - pluginArchive, err = m.pluginRepo.GetPluginArchiveByURL(ctx, pluginArchiveInfo.URL, compatOpts) - if err != nil { - return nil, err - } + if opts.URL() != "" { + pluginArchive, err = m.updateFromURL(ctx, plugin, opts.URL(), compatOpts) } else { - pluginArchive, err = m.pluginRepo.GetPluginArchive(ctx, pluginID, pluginArchiveInfo.Version, compatOpts) - if err != nil { - return nil, err - } + pluginArchive, err = m.updateFromCatalog(ctx, plugin, version, compatOpts) + } + if err != nil { + return nil, err } } else { var err error - pluginArchive, err = m.pluginRepo.GetPluginArchive(ctx, pluginID, version, compatOpts) + if opts.URL() != "" { + pluginArchive, err = m.pluginRepo.GetPluginArchiveByURL(ctx, opts.URL(), compatOpts) + } else { + pluginArchive, err = m.pluginRepo.GetPluginArchive(ctx, pluginID, version, compatOpts) + } if err != nil { return nil, err } @@ -156,9 +130,64 @@ func (m *PluginInstaller) install(ctx context.Context, pluginID, version string, return nil, err } + // Check that the extracted plugin archive has the expected ID and version + // but avoid a hard error for backwards compatibility with older plugins + // and because in the case of an update, the previous version has been already uninstalled + if extractedArchive.ID != pluginID { + m.log.Error("Installed plugin ID mismatch", "expected", pluginID, "got", extractedArchive.ID) + } + if version != "" && extractedArchive.Version != version { + m.log.Error("Installed plugin version mismatch", "expected", version, "got", extractedArchive.Version) + } + return extractedArchive, nil } +func (m *PluginInstaller) updateFromURL(ctx context.Context, plugin *plugins.Plugin, url string, compatOpts repo.CompatOpts) (*repo.PluginArchive, error) { + m.log.Info("Updating plugin", "pluginId", plugin.ID, "from", plugin.Info.Version, "url", url) + + // remove existing installation of plugin + err := m.Remove(ctx, plugin.ID, plugin.Info.Version) + if err != nil { + return nil, err + } + + return m.pluginRepo.GetPluginArchiveByURL(ctx, url, compatOpts) +} + +func (m *PluginInstaller) updateFromCatalog(ctx context.Context, plugin *plugins.Plugin, version string, compatOpts repo.CompatOpts) (*repo.PluginArchive, error) { + // get plugin update information to confirm if target update is possible + pluginArchiveInfo, err := m.pluginRepo.GetPluginArchiveInfo(ctx, plugin.ID, version, compatOpts) + if err != nil { + return nil, err + } + + m.log.Info("Updating plugin", "pluginId", plugin.ID, "from", plugin.Info.Version, "to", pluginArchiveInfo.Version) + + // if existing plugin version is the same as the target update version + if pluginArchiveInfo.Version == plugin.Info.Version { + return nil, plugins.DuplicateError{ + PluginID: plugin.ID, + } + } + + if pluginArchiveInfo.URL == "" && pluginArchiveInfo.Version == "" { + return nil, fmt.Errorf("could not determine update options for %s", plugin.ID) + } + + // remove existing installation of plugin + err = m.Remove(ctx, plugin.ID, plugin.Info.Version) + if err != nil { + return nil, err + } + + if pluginArchiveInfo.URL != "" { + return m.pluginRepo.GetPluginArchiveByURL(ctx, pluginArchiveInfo.URL, compatOpts) + } else { + return m.pluginRepo.GetPluginArchive(ctx, plugin.ID, pluginArchiveInfo.Version, compatOpts) + } +} + func (m *PluginInstaller) Remove(ctx context.Context, pluginID, version string) error { plugin, exists := m.plugin(ctx, pluginID, version) if !exists { @@ -197,7 +226,7 @@ func (m *PluginInstaller) plugin(ctx context.Context, pluginID, pluginVersion st return p, true } -func RepoCompatOpts(opts plugins.CompatOpts) (repo.CompatOpts, error) { +func RepoCompatOpts(opts plugins.AddOpts) (repo.CompatOpts, error) { os := opts.OS() arch := opts.Arch() if len(os) == 0 || len(arch) == 0 { diff --git a/pkg/plugins/manager/installer_test.go b/pkg/plugins/manager/installer_test.go index bb4564c7d25..d89a63e3c5b 100644 --- a/pkg/plugins/manager/installer_test.go +++ b/pkg/plugins/manager/installer_test.go @@ -3,6 +3,7 @@ package manager import ( "archive/zip" "context" + "errors" "fmt" "runtime" "testing" @@ -62,7 +63,9 @@ func TestPluginManager_Add_Remove(t *testing.T) { require.Equal(t, pluginID, id) require.Equal(t, mockZipV1, z) return &storage.ExtractedPluginArchive{ - Path: zipNameV1, + ID: pluginID, + Version: v1, + Path: zipNameV1, }, nil }, } @@ -84,6 +87,22 @@ func TestPluginManager_Add_Remove(t *testing.T) { }, err) }) + t.Run("Add from URL", func(t *testing.T) { + url := "https://grafanaplugins.com" + pluginRepo := &fakes.FakePluginRepo{ + GetPluginArchiveByURLFunc: func(_ context.Context, archiveURL string, _ repo.CompatOpts) (*repo.PluginArchive, error) { + require.Equal(t, pluginID, pluginID) + require.Equal(t, url, archiveURL) + return &repo.PluginArchive{ + File: mockZipV1, + }, nil + }, + } + inst := New(fakes.NewFakePluginRegistry(), loader, pluginRepo, fs, storage.SimpleDirNameGeneratorFunc, &fakes.FakeAuthService{}) + err := inst.Add(context.Background(), pluginID, v1, plugins.NewAddOpts(v1, runtime.GOOS, runtime.GOARCH, url)) + require.NoError(t, err) + }) + t.Run("Update plugin to different version", func(t *testing.T) { // mock a plugin to be returned automatically by the plugin loader pluginV2 := createPlugin(t, pluginID, plugins.ClassExternal, true, true, func(plugin *plugins.Plugin) { @@ -113,7 +132,9 @@ func TestPluginManager_Add_Remove(t *testing.T) { require.Equal(t, pluginV1.ID, pluginID) require.Equal(t, mockZipV2, z) return &storage.ExtractedPluginArchive{ - Path: zipNameV2, + ID: pluginID, + Version: v2, + Path: zipNameV2, }, nil } @@ -121,6 +142,47 @@ func TestPluginManager_Add_Remove(t *testing.T) { require.NoError(t, err) }) + t.Run("Update plugin from url", func(t *testing.T) { + url := "https://grafanaplugins.com" + // mock a plugin to be returned automatically by the plugin loader + pluginV2 := createPlugin(t, pluginID, plugins.ClassExternal, true, true, func(plugin *plugins.Plugin) { + plugin.Info.Version = v2 + }) + + mockZipV2 := &zip.ReadCloser{Reader: zip.Reader{File: []*zip.File{{ + FileHeader: zip.FileHeader{Name: zipNameV2}, + }}}} + loader.LoadFunc = func(ctx context.Context, src plugins.PluginSource) ([]*plugins.Plugin, error) { + require.Equal(t, plugins.ClassExternal, src.PluginClass(ctx)) + require.Equal(t, []string{zipNameV2}, src.PluginURIs(ctx)) + return []*plugins.Plugin{pluginV2}, nil + } + pluginRepo.GetPluginArchiveInfoFunc = func(_ context.Context, _, _ string, _ repo.CompatOpts) (*repo.PluginArchiveInfo, error) { + return nil, errors.New("shouldn't be called") + } + getPluginArchiveByURLCalled := false + pluginRepo.GetPluginArchiveByURLFunc = func(_ context.Context, pluginZipURL string, _ repo.CompatOpts) (*repo.PluginArchive, error) { + require.Equal(t, url, pluginZipURL) + getPluginArchiveByURLCalled = true + return &repo.PluginArchive{ + File: mockZipV2, + }, nil + } + fs.ExtractFunc = func(_ context.Context, pluginID string, _ storage.DirNameGeneratorFunc, z *zip.ReadCloser) (*storage.ExtractedPluginArchive, error) { + require.Equal(t, pluginV1.ID, pluginID) + require.Equal(t, mockZipV2, z) + return &storage.ExtractedPluginArchive{ + ID: pluginID, + Version: v2, + Path: zipNameV2, + }, nil + } + + err = inst.Add(context.Background(), pluginID, v2, plugins.NewAddOpts(v2, runtime.GOOS, runtime.GOARCH, url)) + require.NoError(t, err) + require.True(t, getPluginArchiveByURLCalled) + }) + t.Run("Removing an existing plugin", func(t *testing.T) { inst.pluginRegistry = &fakes.FakePluginRegistry{ Store: map[string]*plugins.Plugin{ @@ -210,14 +272,19 @@ func TestPluginManager_Add_Remove(t *testing.T) { ExtractFunc: func(_ context.Context, id string, _ storage.DirNameGeneratorFunc, z *zip.ReadCloser) (*storage.ExtractedPluginArchive, error) { switch id { case p1: - return &storage.ExtractedPluginArchive{Path: p1Zip}, nil + return &storage.ExtractedPluginArchive{ + ID: p1, + Path: p1Zip, + }, nil case p2: return &storage.ExtractedPluginArchive{ + ID: p2, Dependencies: []*storage.Dependency{{ID: p1}}, Path: p2Zip, }, nil case p3: return &storage.ExtractedPluginArchive{ + ID: p3, Dependencies: []*storage.Dependency{{ID: p2}}, Path: p3Zip, }, nil @@ -260,11 +327,13 @@ func TestPluginManager_Add_Remove(t *testing.T) { switch id { case p1: return &storage.ExtractedPluginArchive{ + ID: p1, Dependencies: []*storage.Dependency{{ID: p2}}, Path: p1Zip, }, nil case p2: return &storage.ExtractedPluginArchive{ + ID: p2, Dependencies: []*storage.Dependency{{ID: p1}}, Path: p2Zip, }, nil @@ -309,6 +378,7 @@ func TestPluginManager_Add_Remove(t *testing.T) { switch id { case testPluginID: return &storage.ExtractedPluginArchive{ + ID: testPluginID, Dependencies: []*storage.Dependency{{ID: pluginDependencyID}}, Path: "test-plugin.zip", }, nil @@ -352,6 +422,6 @@ 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) +func testCompatOpts() plugins.AddOpts { + return plugins.NewAddOpts("10.0.0", runtime.GOOS, runtime.GOARCH, "") } diff --git a/pkg/services/pluginsintegration/plugininstaller/service.go b/pkg/services/pluginsintegration/plugininstaller/service.go index b8d0b4e37f9..d49fe12b0da 100644 --- a/pkg/services/pluginsintegration/plugininstaller/service.go +++ b/pkg/services/pluginsintegration/plugininstaller/service.go @@ -135,8 +135,6 @@ func (s *Service) shouldUpdate(ctx context.Context, pluginID, currentVersion str } func (s *Service) installPlugins(ctx context.Context) error { - compatOpts := plugins.NewCompatOpts(s.cfg.BuildVersion, runtime.GOOS, runtime.GOARCH) - for _, installPlugin := range s.cfg.PreinstallPlugins { // Check if the plugin is already installed p, exists := s.pluginStore.Plugin(ctx, installPlugin.ID) @@ -162,6 +160,7 @@ func (s *Service) installPlugins(ctx context.Context) error { s.log.Info("Installing plugin", "pluginId", installPlugin.ID, "version", installPlugin.Version) start := time.Now() ctx = repo.WithRequestOrigin(ctx, "preinstall") + compatOpts := plugins.NewAddOpts(s.cfg.BuildVersion, runtime.GOOS, runtime.GOARCH, installPlugin.URL) err := s.pluginInstaller.Add(ctx, installPlugin.ID, installPlugin.Version, compatOpts) if err != nil { var dupeErr plugins.DuplicateError diff --git a/pkg/services/pluginsintegration/plugininstaller/service_test.go b/pkg/services/pluginsintegration/plugininstaller/service_test.go index 6ec4ec516ec..d2b8668560f 100644 --- a/pkg/services/pluginsintegration/plugininstaller/service_test.go +++ b/pkg/services/pluginsintegration/plugininstaller/service_test.go @@ -115,6 +115,11 @@ func TestService_Run(t *testing.T) { existingPlugins: []*plugins.Plugin{{JSONData: plugins.JSONData{ID: "myplugin", Info: plugins.Info{Version: "1.0.0"}}}}, latestPlugin: &repo.PluginArchiveInfo{Version: "2.0.0"}, }, + { + name: "Should install a plugin with a URL", + shouldInstall: true, + pluginsToInstall: []setting.InstallPlugin{{ID: "myplugin", URL: "https://example.com/myplugin.tar.gz"}}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -124,6 +129,7 @@ func TestService_Run(t *testing.T) { require.NoError(t, err) } installed := 0 + installedFromURL := 0 s, err := ProvideService( &setting.Cfg{ PreinstallPlugins: tt.pluginsToInstall, @@ -131,7 +137,7 @@ func TestService_Run(t *testing.T) { }, pluginstore.New(preg, &fakes.FakeLoader{}), &fakes.FakePluginInstaller{ - AddFunc: func(ctx context.Context, pluginID string, version string, opts plugins.CompatOpts) error { + AddFunc: func(ctx context.Context, pluginID string, version string, opts plugins.AddOpts) error { for _, plugin := range tt.pluginsToFail { if plugin == pluginID { return errors.New("Failed to install plugin") @@ -143,7 +149,11 @@ func TestService_Run(t *testing.T) { } for _, plugin := range tt.pluginsToInstall { if plugin.ID == pluginID && plugin.Version == version { - installed++ + if opts.URL() != "" { + installedFromURL++ + } else { + installed++ + } } } return nil @@ -168,7 +178,27 @@ func TestService_Run(t *testing.T) { require.NoError(t, err) } if tt.shouldInstall { - require.Equal(t, len(tt.pluginsToInstall)-len(tt.pluginsToFail), installed) + expectedInstalled := 0 + expectedInstalledFromURL := 0 + for _, plugin := range tt.pluginsToInstall { + expectedFailed := false + for _, pluginFail := range tt.pluginsToFail { + if plugin.ID == pluginFail { + expectedFailed = true + break + } + } + if expectedFailed { + continue + } + if plugin.URL != "" { + expectedInstalledFromURL++ + } else { + expectedInstalled++ + } + } + require.Equal(t, expectedInstalled, installed) + require.Equal(t, expectedInstalledFromURL, installedFromURL) } }) } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 9dcd3bcde78..f0039db5ae2 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -543,6 +543,7 @@ type UnifiedStorageConfig struct { type InstallPlugin struct { ID string `json:"id"` Version string `json:"version"` + URL string `json:"url,omitempty"` } // AddChangePasswordLink returns if login form is disabled or not since diff --git a/pkg/setting/setting_plugins.go b/pkg/setting/setting_plugins.go index d3ba8384a40..8ba9fd134b0 100644 --- a/pkg/setting/setting_plugins.go +++ b/pkg/setting/setting_plugins.go @@ -29,7 +29,7 @@ func extractPluginSettings(sections []*ini.Section) PluginSettings { var ( defaultPreinstallPlugins = map[string]InstallPlugin{ // Default preinstalled plugins - "grafana-lokiexplore-app": {"grafana-lokiexplore-app", ""}, + "grafana-lokiexplore-app": {"grafana-lokiexplore-app", "", ""}, } ) @@ -58,11 +58,16 @@ func (cfg *Cfg) readPluginSettings(iniFile *ini.File) error { for _, plugin := range rawInstallPlugins { parts := strings.Split(plugin, "@") id := parts[0] - v := "" - if len(parts) == 2 { - v = parts[1] + version := "" + url := "" + if len(parts) > 1 { + version = parts[1] + if len(parts) > 2 { + url = parts[2] + } } - preinstallPlugins[id] = InstallPlugin{id, v} + + preinstallPlugins[id] = InstallPlugin{id, version, url} } // Remove from the list the plugins that have been disabled for _, disabledPlugin := range cfg.DisablePlugins { diff --git a/pkg/setting/setting_plugins_test.go b/pkg/setting/setting_plugins_test.go index de1e45d9f42..9ddac396a76 100644 --- a/pkg/setting/setting_plugins_test.go +++ b/pkg/setting/setting_plugins_test.go @@ -120,12 +120,12 @@ func Test_readPluginSettings(t *testing.T) { { name: "should add the default preinstalled plugin and the one defined", rawInput: "plugin1", - expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", ""}), + expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", "", ""}), }, { name: "should add the default preinstalled plugin and the one defined with version", rawInput: "plugin1@1.0.0", - expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", "1.0.0"}), + expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", "1.0.0", ""}), }, { name: "it should remove the disabled plugin", @@ -149,7 +149,17 @@ func Test_readPluginSettings(t *testing.T) { name: "should mark preinstall as sync", rawInput: "plugin1", disableAsync: true, - expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", ""}), + expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", "", ""}), + }, + { + name: "should parse a plugin with version and URL", + rawInput: "plugin1@1.0.1@https://example.com/plugin1.tar.gz", + expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", "1.0.1", "https://example.com/plugin1.tar.gz"}), + }, + { + name: "should parse a plugin with URL", + rawInput: "plugin1@@https://example.com/plugin1.tar.gz", + expected: append(defaultPreinstallPluginsList, InstallPlugin{"plugin1", "", "https://example.com/plugin1.tar.gz"}), }, } for _, tc := range tests {