diff --git a/server/channels/api4/plugin_test.go b/server/channels/api4/plugin_test.go index bcc4b19c80..b515917ca9 100644 --- a/server/channels/api4/plugin_test.go +++ b/server/channels/api4/plugin_test.go @@ -1210,7 +1210,7 @@ func TestGetPrepackagedPluginInMarketplace(t *testing.T) { } env := th.App.GetPluginsEnvironment() - env.SetPrepackagedPlugins([]*plugin.PrepackagedPlugin{prepackagePlugin}) + env.SetPrepackagedPlugins([]*plugin.PrepackagedPlugin{prepackagePlugin}, nil) t.Run("get remote and prepackaged plugins", func(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { @@ -1263,7 +1263,7 @@ func TestGetPrepackagedPluginInMarketplace(t *testing.T) { } env := th.App.GetPluginsEnvironment() - env.SetPrepackagedPlugins([]*plugin.PrepackagedPlugin{newerPrepackagePlugin}) + env.SetPrepackagedPlugins([]*plugin.PrepackagedPlugin{newerPrepackagePlugin}, nil) plugins, _, err := th.SystemAdminClient.GetMarketplacePlugins(context.Background(), &model.MarketplacePluginFilter{}) require.NoError(t, err) diff --git a/server/channels/app/channels.go b/server/channels/app/channels.go index 7e5f94508f..76b6858f19 100644 --- a/server/channels/app/channels.go +++ b/server/channels/app/channels.go @@ -44,11 +44,12 @@ type Channels struct { postActionCookieSecret []byte - pluginCommandsLock sync.RWMutex - pluginCommands []*PluginCommand - pluginsLock sync.RWMutex - pluginsEnvironment *plugin.Environment - pluginConfigListenerID string + pluginCommandsLock sync.RWMutex + pluginCommands []*PluginCommand + pluginsLock sync.RWMutex + pluginsEnvironment *plugin.Environment + pluginConfigListenerID string + pluginClusterLeaderListenerID string productCommandsLock sync.RWMutex productCommands []*ProductCommand diff --git a/server/channels/app/plugin.go b/server/channels/app/plugin.go index 51da55dc43..e4ce41b7e3 100644 --- a/server/channels/app/plugin.go +++ b/server/channels/app/plugin.go @@ -4,6 +4,7 @@ package app import ( + "bytes" "encoding/base64" "fmt" "io" @@ -27,7 +28,6 @@ import ( "github.com/mattermost/mattermost/server/v8/channels/product" "github.com/mattermost/mattermost/server/v8/channels/utils/fileutils" "github.com/mattermost/mattermost/server/v8/platform/services/marketplace" - "github.com/mattermost/mattermost/server/v8/platform/shared/filestore" ) // prepackagedPluginsDir is the hard-coded folder name where prepackaged plugins are bundled @@ -253,13 +253,13 @@ func (ch *Channels) initPlugins(c *request.Context, pluginDir, webappPluginDir s ch.srv.Log().Error("Failed to sync plugins from the file store", mlog.Err(err)) } - plugins := ch.processPrepackagedPlugins(prepackagedPluginsDir) - pluginsEnvironment = ch.GetPluginsEnvironment() - if pluginsEnvironment == nil { - ch.srv.Log().Info("Plugins environment not found, server is likely shutting down") - return + if err := ch.processPrepackagedPlugins(prepackagedPluginsDir); err != nil { + ch.srv.Log().Error("Failed to process prepackaged plugins", mlog.Err(err)) } - pluginsEnvironment.SetPrepackagedPlugins(plugins) + ch.pluginClusterLeaderListenerID = ch.srv.AddClusterLeaderChangedListener(func() { + ch.persistTransitionallyPrepackagedPlugins() + }) + ch.persistTransitionallyPrepackagedPlugins() // Sync plugin active state when config changes. Also notify plugins. ch.pluginsLock.Lock() @@ -352,18 +352,22 @@ func (ch *Channels) syncPlugins() *model.AppError { } defer bundle.Close() - var signature filestore.ReadCloseSeeker if *ch.cfgSvc.Config().PluginSettings.RequirePluginSignature { - signature, appErr = ch.srv.fileReader(plugin.signaturePath) + signature, appErr := ch.srv.fileReader(plugin.signaturePath) if appErr != nil { logger.Error("Failed to open plugin signature from file store.", mlog.Err(appErr)) return } defer signature.Close() + + if appErr = ch.verifyPlugin(bundle, signature); appErr != nil { + logger.Error("Failed to validate plugin signature", mlog.Err(appErr)) + return + } } logger.Info("Syncing plugin from file store") - if _, err := ch.installPluginLocally(bundle, signature, installPluginLocallyAlways); err != nil && err.Id != "app.plugin.skip_installation.app_error" { + if _, err := ch.installPluginLocally(bundle, installPluginLocallyAlways); err != nil && err.Id != "app.plugin.skip_installation.app_error" { logger.Error("Failed to sync plugin from file store", mlog.Err(err)) } }(plugin) @@ -388,6 +392,8 @@ func (ch *Channels) ShutDownPlugins() { ch.RemoveConfigListener(ch.pluginConfigListenerID) ch.pluginConfigListenerID = "" + ch.srv.RemoveClusterLeaderChangedListener(ch.pluginClusterLeaderListenerID) + ch.pluginClusterLeaderListenerID = "" // Acquiring lock manually before cleaning up PluginsEnvironment. ch.pluginsLock.Lock() @@ -911,26 +917,48 @@ func (ch *Channels) getPluginsFromFilePaths(fileStorePaths []string) map[string] return pluginSignaturePathMap } -func (ch *Channels) processPrepackagedPlugins(pluginsDir string) []*plugin.PrepackagedPlugin { - prepackagedPluginsDir, found := fileutils.FindDir(pluginsDir) +// processPrepackagedPlugins processes the plugins prepackaged with this server in the +// prepackaged_plugins directory. +// +// If enabled, prepackaged plugins are installed or upgraded locally. A list of transitionally +// prepackaged plugins is also collected for later persistence to the filestore. +func (ch *Channels) processPrepackagedPlugins(prepackagedPluginsDir string) error { + prepackagedPluginsPath, found := fileutils.FindDir(prepackagedPluginsDir) if !found { ch.srv.Log().Debug("No prepackaged plugins directory found") return nil } + ch.srv.Log().Debug("Processing prepackaged plugins in directory", mlog.String("path", prepackagedPluginsPath)) + var fileStorePaths []string - err := filepath.Walk(prepackagedPluginsDir, func(walkPath string, info os.FileInfo, err error) error { + err := filepath.Walk(prepackagedPluginsPath, func(walkPath string, info os.FileInfo, err error) error { fileStorePaths = append(fileStorePaths, walkPath) return nil }) if err != nil { - ch.srv.Log().Error("Failed to walk prepackaged plugins", mlog.Err(err)) - return nil + return errors.Wrap(err, "failed to walk prepackaged plugins") } pluginSignaturePathMap := ch.getPluginsFromFilePaths(fileStorePaths) - plugins := make([]*plugin.PrepackagedPlugin, 0, len(pluginSignaturePathMap)) - prepackagedPlugins := make(chan *plugin.PrepackagedPlugin, len(pluginSignaturePathMap)) + plugins := make(chan *plugin.PrepackagedPlugin, len(pluginSignaturePathMap)) + + // Before processing any prepackaged plugins, take a snapshot of the available manifests + // to decide what was synced from the filestore. + pluginsEnvironment := ch.GetPluginsEnvironment() + if pluginsEnvironment == nil { + return errors.New("pluginsEnvironment is nil") + } + + availablePlugins, err := pluginsEnvironment.Available() + if err != nil { + return errors.Wrap(err, "failed to list available plugins") + } + + availablePluginsMap := make(map[string]*model.BundleInfo, len(availablePlugins)) + for _, bundleInfo := range availablePlugins { + availablePluginsMap[bundleInfo.Manifest.Id] = bundleInfo + } var wg sync.WaitGroup for _, psPath := range pluginSignaturePathMap { @@ -946,18 +974,29 @@ func (ch *Channels) processPrepackagedPlugins(pluginsDir string) []*plugin.Prepa ch.srv.Log().Error("Failed to install prepackaged plugin", mlog.String("bundle_path", psPath.bundlePath), mlog.Err(err)) return } - prepackagedPlugins <- p + + plugins <- p }(psPath) } wg.Wait() - close(prepackagedPlugins) + close(plugins) - for p := range prepackagedPlugins { - plugins = append(plugins, p) + prepackagedPlugins := make([]*plugin.PrepackagedPlugin, 0, len(pluginSignaturePathMap)) + transitionallyPrepackagedPlugins := make([]*plugin.PrepackagedPlugin, 0) + for p := range plugins { + if ch.pluginIsTransitionallyPrepackaged(p.Manifest.Id) { + if ch.shouldPersistTransitionallyPrepackagedPlugin(availablePluginsMap, p) { + transitionallyPrepackagedPlugins = append(transitionallyPrepackagedPlugins, p) + } + } else { + prepackagedPlugins = append(prepackagedPlugins, p) + } } - return plugins + pluginsEnvironment.SetPrepackagedPlugins(prepackagedPlugins, transitionallyPrepackagedPlugins) + + return nil } // processPrepackagedPlugin will return the prepackaged plugin metadata and will also @@ -984,25 +1023,172 @@ func (ch *Channels) processPrepackagedPlugin(pluginPath *pluginSignaturePath) (* return nil, errors.Wrapf(err, "Failed to get prepackaged plugin %s", pluginPath.bundlePath) } + logger = logger.With(mlog.String("plugin_id", plugin.Manifest.Id)) + // Skip installing the plugin at all if automatic prepackaged plugins is disabled if !*ch.cfgSvc.Config().PluginSettings.AutomaticPrepackagedPlugins { + logger.Info("Not installing prepackaged plugin: automatic prepackaged plugins disabled") return plugin, nil } // Skip installing if the plugin is has not been previously enabled. pluginState := ch.cfgSvc.Config().PluginSettings.PluginStates[plugin.Manifest.Id] if pluginState == nil || !pluginState.Enable { + logger.Info("Not installing prepackaged plugin: not previously enabled") return plugin, nil } - logger.Info("Installing prepackaged plugin") - if _, err := ch.installExtractedPlugin(plugin.Manifest, pluginDir, installPluginLocallyOnlyIfNewOrUpgrade); err != nil { + if _, err := ch.installExtractedPlugin(plugin.Manifest, pluginDir, installPluginLocallyOnlyIfNewOrUpgrade); err != nil && err.Id != "app.plugin.skip_installation.app_error" { return nil, errors.Wrapf(err, "Failed to install extracted prepackaged plugin %s", pluginPath.bundlePath) } return plugin, nil } +var transitionallyPrepackagedPlugins = []string{ + "antivirus", + "focalboard", + "mattermost-autolink", + "com.mattermost.aws-sns", + "com.mattermost.plugin-channel-export", + "com.mattermost.confluence", + "com.mattermost.custom-attributes", + "jenkins", + "jitsi", + "com.mattermost.plugin-todo", + "com.mattermost.welcomebot", + "com.mattermost.apps", +} + +// pluginIsTransitionallyPrepackaged identifies plugin ids that are currently prepackaged but +// slated for future removal. +func (ch *Channels) pluginIsTransitionallyPrepackaged(pluginID string) bool { + for _, id := range transitionallyPrepackagedPlugins { + if id == pluginID { + return true + } + } + + return false +} + +// shouldPersistTransitionallyPrepackagedPlugin determines if a transitionally prepackaged plugin +// should be persisted to the filestore, taking into account whether it's already enabled and +// would improve on what's already in the filestore. +func (ch *Channels) shouldPersistTransitionallyPrepackagedPlugin(availablePluginsMap map[string]*model.BundleInfo, p *plugin.PrepackagedPlugin) bool { + logger := ch.srv.Log().With(mlog.String("plugin_id", p.Manifest.Id), mlog.String("prepackaged_version", p.Manifest.Version)) + + // Ignore the plugin altogether unless it was previously enabled. + pluginState := ch.cfgSvc.Config().PluginSettings.PluginStates[p.Manifest.Id] + if pluginState == nil || !pluginState.Enable { + logger.Debug("Should not persist transitionally prepackaged plugin: not previously enabled") + return false + } + + // Ignore the plugin if the same or newer version is already available + // (having previously synced from the filestore). + existing, found := availablePluginsMap[p.Manifest.Id] + if !found { + logger.Info("Should persist transitionally prepackaged plugin: not currently in filestore") + return true + } + + prepackagedVersion, err := semver.Parse(p.Manifest.Version) + if err != nil { + logger.Error("Should not persist transitionally prepackged plugin: invalid prepackaged version", mlog.Err(err)) + return false + } + + logger = logger.With(mlog.String("existing_version", existing.Manifest.Version)) + + existingVersion, err := semver.Parse(existing.Manifest.Version) + if err != nil { + // Consider this an old version and replace with the prepackaged version instead. + logger.Warn("Should persist transitionally prepackged plugin: invalid existing version", mlog.Err(err)) + return true + } + + if prepackagedVersion.GT(existingVersion) { + logger.Info("Should persist transitionally prepackged plugin: newer version") + return true + } + + logger.Info("Should not persist transitionally prepackged plugin: not a newer version") + return false +} + +// persistTransitionallyPrepackagedPlugins writes plugins that are transitionally prepackaged with +// the server to the filestore to allow their continued use when the plugin eventually stops being +// prepackaged. +// +// We identify which plugins need to be persisted during startup via processPrepackagedPlugins. +// Once we persist the set of plugins to the filestore, we clear the list to prevent this server +// from trying again. +// +// In a multi-server cluster, only the cluster leader should persist these plugins to avoid +// concurrent writes to the filestore. But during an upgrade, there's no guarantee that a freshly +// upgraded server will be the cluster leader to perform this step in a timely fashion, so the +// persistence has to be able to happen sometime after startup. Additionally, while this is a +// kind of migration, it's not a one off: new versions of these plugins may still be shipped +// during the transition period, or new plugins may be added to the list. +// +// So instead of a one-time migration, we opt to run this method every time the cluster leader +// changes, but minimizing rework. More than one server may end up persisting the same plugin +// (but never concurrently!), but all servers will eventually converge on this method becoming a +// no-op (until this set of plugins changes in a subsequent release). +// +// Finally, if an error occurs persisting the plugin, we don't try again until the server restarts, +// or another server becomes cluster leader. +func (ch *Channels) persistTransitionallyPrepackagedPlugins() { + if !ch.srv.IsLeader() { + ch.srv.Log().Debug("Not persisting transitionally prepackaged plugins: not the leader") + return + } + + pluginsEnvironment := ch.GetPluginsEnvironment() + if pluginsEnvironment == nil { + ch.srv.Log().Debug("Not persisting transitionally prepackaged plugins: no plugin environment") + return + } + + transitionallyPrepackagedPlugins := pluginsEnvironment.TransitionallyPrepackagedPlugins() + if len(transitionallyPrepackagedPlugins) == 0 { + ch.srv.Log().Debug("Not persisting transitionally prepackaged plugins: none found") + return + } + + var wg sync.WaitGroup + for _, p := range transitionallyPrepackagedPlugins { + wg.Add(1) + go func(p *plugin.PrepackagedPlugin) { + defer wg.Done() + + logger := ch.srv.Log().With(mlog.String("plugin_id", p.Manifest.Id), mlog.String("version", p.Manifest.Version)) + + logger.Info("Persisting transitionally prepackaged plugin") + + bundleReader, err := os.Open(p.Path) + if err != nil { + logger.Error("Failed to read transitionally prepackaged plugin", mlog.Err(err)) + } + defer bundleReader.Close() + + signatureReader := bytes.NewReader(p.Signature) + + // Write the plugin to the filestore, but don't bother notifying the peers, + // as there's no reason to reload the plugin to run the same version again. + appErr := ch.installPluginToFilestore(p.Manifest, bundleReader, signatureReader) + if appErr != nil { + logger.Error("Failed to persist transitionally prepackaged plugin", mlog.Err(err)) + } + }(p) + } + wg.Wait() + + pluginsEnvironment.ClearTransitionallyPrepackagedPlugins() + ch.srv.Log().Info("Finished persisting transitionally prepackaged plugins") +} + // buildPrepackagedPlugin builds a PrepackagedPlugin from the plugin at the given path, additionally returning the directory in which it was extracted. func (ch *Channels) buildPrepackagedPlugin(pluginPath *pluginSignaturePath, pluginFile io.ReadSeeker, tmpDir string) (*plugin.PrepackagedPlugin, string, error) { manifest, pluginDir, appErr := extractPlugin(pluginFile, tmpDir) diff --git a/server/channels/app/plugin_install.go b/server/channels/app/plugin_install.go index f1967188ce..75ab7d4cd6 100644 --- a/server/channels/app/plugin_install.go +++ b/server/channels/app/plugin_install.go @@ -1,38 +1,85 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -// Installing a managed plugin consists of copying the uploaded plugin (*.tar.gz) to the filestore, -// unpacking to the configured local directory (PluginSettings.Directory), and copying any webapp bundle therein -// to the configured local client directory (PluginSettings.ClientDirectory). The unpacking and copy occurs -// each time the server starts, ensuring it remains synchronized with the set of installed plugins. +// ### Plugin Bundles // -// When a plugin is enabled, all connected websocket clients are notified so as to fetch any webapp bundle and -// load the client-side portion of the plugin. This works well in a single-server system, but requires careful -// coordination in a high-availability cluster with multiple servers. In particular, websocket clients must not be -// notified of the newly enabled plugin until all servers in the cluster have finished unpacking the plugin, otherwise -// the webapp bundle might not yet be available. Ideally, each server would just notify its own set of connected peers -// after it finishes this process, but nothing prevents those clients from re-connecting to a different server behind -// the load balancer that hasn't finished unpacking. +// A plugin bundle consists of a server and/or webapp component designed to extend the +// functionality of the server. Bundles are first extracted to the configured local directory +// (PluginSettings.Directory), with any webapp component additionally copied to the configured +// local client directory (PluginSettings.ClientDirectory) to be loaded alongside the webapp. // -// To achieve this coordination, each server instead checks the status of its peers after unpacking. If it finds peers with -// differing versions of the plugin, it skips the notification. If it finds all peers with the same version of the plugin, -// it notifies all websocket clients connected to all peers. There's a small chance that this never occurs if the last -// server to finish unpacking dies before it can announce. There is also a chance that multiple servers decide to notify, -// but the webapp handles this idempotently. +// Plugin bundles are sourced in one of three ways: +// - plugins prepackged with the server in the prepackaged_plugins/ directory +// - plugins transitionally prepackaged with the server in the prepackaged_plugins/ directory +// - plugins installed to the filestore (amazons3 or local, alongisde files and images) +// - unmanaged plugins manually extracted to the confgured local directory +// ┌────────────────────────────┐ +// │ ┌────────────────────────┐ │ +// │ │prepackaged_plugins/ │ │ +// │ │ prepackaged.tar.gz │ │ +// │ │ transitional.tar.gz │ │ +// │ │ │ │ +// │ └────────────────────────┘ │ +// │ │ │ +// │ ▼ │ +// │ ┌────────────────────────┐ │ +// │ │plugins/ │ │ +// │ │ unmanaged/ │ │ +// │ │ filestore/ │ │ ┌────────────────────────┐ +// │ │ .filestore │ │ │s3://bucket/plugins/ │ +// │ │ prepackaged/ │◀┼───│ filestore.tar.gz │ +// │ │ .filestore │ │ │ transitional.tar.gz │ +// │ │ transitional/ │ │ └────────────────────────┘ +// │ │ .filestore │ │ +// │ └────────────────────────┘ │ +// │ ┌────────┤ +// │ │ server │ +// └───────────────────┴────────┘ // -// Complicating this flow further are the various means of notifying. In addition to websocket events, there are cluster -// messages between peers. There is a cluster message when the config changes and a plugin is enabled or disabled. -// There is a cluster message when installing or uninstalling a plugin. There is a cluster message when peer's plugin change -// its status. And finally the act of notifying websocket clients is propagated itself via a cluster message. +// Prepackaged plugins are bundles shipped alongside the server to simplify installation and +// upgrade. This occurs automatically if configured (PluginSettings.AutomaticPrepackagedPlugins) +// and the plugin is enabled (PluginSettings.PluginStates[plugin_id].Enable), unless a matching or +// newer version of the plugin is already installed. // -// The key methods involved in handling these notifications are notifyPluginEnabled and notifyPluginStatusesChanged. -// Note that none of this complexity applies to single-server systems or to plugins without a webapp bundle. +// Transitionally prepackaged plugins are bundles that will stop being prepackaged in a future +// release. On first startup, they are unpacked just like prepackaged plugins, but also get copied +// to the filestore. On future startups, the server uses the version in the filestore. // -// Finally, in addition to managed plugins, note that there are unmanaged and prepackaged plugins. -// Unmanaged plugins are plugins installed manually to the configured local directory (PluginSettings.Directory). -// Prepackaged plugins are included with the server. They otherwise follow the above flow, except do not get uploaded -// to the filestore. Prepackaged plugins override all other plugins with the same plugin id, but only when the prepackaged -// plugin is newer. Managed plugins unconditionally override unmanaged plugins with the same plugin id. +// Plugins are installed to the filestore when the user installs via the marketplace or manually +// uploads a plugin bundle. (Or because the plugin is transitionally prepackaged). +// +// Unmanaged plugins were manually extracted by into the configured local directory. This legacy +// method of installing plugins is distinguished from other extracted plugins by the absence of a +// flag file (.filestore). Managed plugins unconditionally override unmanaged plugins. A future +// version of Mattermost will likely drop support for unmanaged plugins. +// +// ### Enabling a Plugin +// +// When a plugin is enabled, all connected websocket clients are notified so as to fetch any +// webapp bundle and load the client-side portion of the plugin. This works well in a +// single-server system, but requires careful coordination in a high-availability cluster with +// multiple servers. In particular, websocket clients must not be notified of the newly enabled +// plugin until all servers in the cluster have finished unpacking the plugin, otherwise the +// webapp bundle might not yet be available. Ideally, each server would just notify its own set of +// connected peers after it finishes this process, but nothing prevents those clients from +// re-connecting to a different server behind the load balancer that hasn't finished unpacking. +// +// To achieve this coordination, each server instead checks the status of its peers after +// unpacking. If it finds peers with differing versions of the plugin, it skips the notification. +// If it finds all peers with the same version of the plugin, it notifies all websocket clients +// connected to all peers. There's a small chance that this never occurs if the last server to +// finish unpacking dies before it can announce. There is also a chance that multiple servers +// decide to notify, but the webapp handles this idempotently. +// +// Complicating this flow further are the various means of notifying. In addition to websocket +// events, there are cluster messages between peers. There is a cluster message when the config +// changes and a plugin is enabled or disabled. There is a cluster message when installing or +// uninstalling a plugin. There is a cluster message when a peer's plugin changes its status. And +// finally the act of notifying websocket clients is itself propagated via a cluster message. +// +// The key methods involved in handling these notifications are notifyPluginEnabled and +// notifyPluginStatusesChanged. Note that none of this complexity applies to single-server +// systems or to plugins without a webapp bundle. package app import ( @@ -91,9 +138,14 @@ func (ch *Channels) installPluginFromClusterMessage(pluginID string) { return } defer signature.Close() + + if err := ch.verifyPlugin(bundle, signature); err != nil { + mlog.Error("Failed to validate plugin signature.", mlog.Err(appErr)) + return + } } - manifest, appErr := ch.installPluginLocally(bundle, signature, installPluginLocallyAlways) + manifest, appErr := ch.installPluginLocally(bundle, installPluginLocallyAlways) if appErr != nil { // A log line already appears if the plugin is on the blocklist or skipped if appErr.Id != "app.plugin.blocked.app_error" && appErr.Id != "app.plugin.skip_installation.app_error" { @@ -144,7 +196,7 @@ func (a *App) InstallPlugin(pluginFile io.ReadSeeker, replace bool) (*model.Mani // // The given installation strategy decides how to handle upgrade scenarios. func (ch *Channels) installPlugin(bundle, signature io.ReadSeeker, installationStrategy pluginInstallationStrategy) (*model.Manifest, *model.AppError) { - manifest, appErr := ch.installPluginLocally(bundle, signature, installationStrategy) + manifest, appErr := ch.installPluginLocally(bundle, installationStrategy) if appErr != nil { return nil, appErr } @@ -161,7 +213,7 @@ func (ch *Channels) installPlugin(bundle, signature io.ReadSeeker, installationS } if err := ch.notifyPluginEnabled(manifest); err != nil { - logger.Warn("Failed notify plugin enabled", mlog.Err(err)) + logger.Warn("Failed to notify plugin enabled", mlog.Err(err)) } if err := ch.notifyPluginStatusesChanged(); err != nil { @@ -174,20 +226,33 @@ func (ch *Channels) installPlugin(bundle, signature io.ReadSeeker, installationS // installPluginToFilestore saves the given plugin bundle (optionally signed) to the filestore, // notifying cluster peers accordingly. func (ch *Channels) installPluginToFilestore(manifest *model.Manifest, bundle, signature io.ReadSeeker) *model.AppError { - if signature != nil { + logger := ch.srv.Log().With(mlog.String("plugin_id", manifest.Id)) + logger.Info("Persisting plugin to filestore") + + if signature == nil { + logger.Warn("No signature when persisting plugin to filestore") + } else { + signatureStorePath := getSignatureStorePath(manifest.Id) _, err := signature.Seek(0, 0) if err != nil { return model.NewAppError("saveSignature", "app.plugin.store_signature.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - if _, appErr := ch.srv.writeFile(signature, getSignatureStorePath(manifest.Id)); appErr != nil { + logger.Debug("Persisting plugin signature to filestore", mlog.String("path", signatureStorePath)) + if _, appErr := ch.srv.writeFile(signature, signatureStorePath); appErr != nil { return model.NewAppError("saveSignature", "app.plugin.store_signature.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr) } } // Store bundle in the file store to allow access from other servers. - bundle.Seek(0, 0) - if _, appErr := ch.srv.writeFile(bundle, getBundleStorePath(manifest.Id)); appErr != nil { + bundleStorePath := getBundleStorePath(manifest.Id) + _, err := bundle.Seek(0, 0) + if err != nil { + return model.NewAppError("uploadPlugin", "app.plugin.store_bundle.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + logger.Debug("Persisting plugin bundle to filestore", mlog.String("path", bundleStorePath)) + if _, appErr := ch.srv.writeFile(bundle, bundleStorePath); appErr != nil { return model.NewAppError("uploadPlugin", "app.plugin.store_bundle.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr) } @@ -269,6 +334,11 @@ func (ch *Channels) InstallMarketplacePlugin(request *model.InstallMarketplacePl return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.marketplace_plugins.signature_not_found.app_error", nil, "", http.StatusInternalServerError) } + appErr = ch.verifyPlugin(pluginFile, signatureFile) + if appErr != nil { + return nil, appErr + } + manifest, appErr := ch.installPlugin(pluginFile, signatureFile, installPluginLocallyAlways) if appErr != nil { return nil, appErr @@ -288,23 +358,16 @@ const ( installPluginLocallyAlways ) -// installPluginLocally extracts and installs the given plugin bundle (optionally signed) for the -// current server, activating the plugin if already enabled. +// installPluginLocally extracts and installs the given plugin bundle for the current server, +// activating the plugin if already enabled. // // The given installation strategy decides how to handle upgrade scenarios. -func (ch *Channels) installPluginLocally(bundle, signature io.ReadSeeker, installationStrategy pluginInstallationStrategy) (*model.Manifest, *model.AppError) { +func (ch *Channels) installPluginLocally(bundle io.ReadSeeker, installationStrategy pluginInstallationStrategy) (*model.Manifest, *model.AppError) { pluginsEnvironment := ch.GetPluginsEnvironment() if pluginsEnvironment == nil { return nil, model.NewAppError("installPluginLocally", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } - // verify signature - if signature != nil { - if err := ch.verifyPlugin(bundle, signature); err != nil { - return nil, err - } - } - tmpDir, err := os.MkdirTemp("", "plugintmp") if err != nil { return nil, model.NewAppError("installPluginLocally", "app.plugin.filesystem.app_error", nil, "", http.StatusInternalServerError).Wrap(err) diff --git a/server/channels/app/plugin_install_test.go b/server/channels/app/plugin_install_test.go index c3e0b31c91..d25e41bd43 100644 --- a/server/channels/app/plugin_install_test.go +++ b/server/channels/app/plugin_install_test.go @@ -73,7 +73,7 @@ func TestInstallPluginLocally(t *testing.T) { th := Setup(t) defer th.TearDown() - actualManifest, appErr := th.App.ch.installPluginLocally(&nilReadSeeker{}, nil, installPluginLocallyOnlyIfNew) + actualManifest, appErr := th.App.ch.installPluginLocally(&nilReadSeeker{}, installPluginLocallyOnlyIfNew) require.NotNil(t, appErr) assert.Equal(t, "app.plugin.extract.app_error", appErr.Id, appErr.Error()) require.Nil(t, actualManifest) @@ -87,7 +87,7 @@ func TestInstallPluginLocally(t *testing.T) { {"test", "test file"}, }) - actualManifest, appErr := th.App.ch.installPluginLocally(reader, nil, installPluginLocallyOnlyIfNew) + actualManifest, appErr := th.App.ch.installPluginLocally(reader, installPluginLocallyOnlyIfNew) require.NotNil(t, appErr) assert.Equal(t, "app.plugin.manifest.app_error", appErr.Id, appErr.Error()) require.Nil(t, actualManifest) @@ -106,7 +106,7 @@ func TestInstallPluginLocally(t *testing.T) { {"plugin.json", string(manifestJSON)}, }) - actualManifest, appError := th.App.ch.installPluginLocally(reader, nil, installationStrategy) + actualManifest, appError := th.App.ch.installPluginLocally(reader, installationStrategy) if actualManifest != nil { require.Equal(t, manifest, actualManifest) } diff --git a/server/channels/app/plugin_test.go b/server/channels/app/plugin_test.go index 74be41170e..34aa0d06f6 100644 --- a/server/channels/app/plugin_test.go +++ b/server/channels/app/plugin_test.go @@ -4,15 +4,19 @@ package app import ( + "archive/tar" "bytes" + "compress/gzip" "crypto/sha256" "encoding/base64" + "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" + "sort" "testing" "time" @@ -805,202 +809,566 @@ func TestPluginStatusActivateError(t *testing.T) { }) } +type byId []*plugin.PrepackagedPlugin + +func (a byId) Len() int { return len(a) } +func (a byId) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a byId) Less(i, j int) bool { return a[i].Manifest.Id < a[j].Manifest.Id } + +type pluginStatusById model.PluginStatuses + +func (a pluginStatusById) Len() int { return len(a) } +func (a pluginStatusById) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a pluginStatusById) Less(i, j int) bool { return a[i].PluginId < a[j].PluginId } + func TestProcessPrepackagedPlugins(t *testing.T) { - th := Setup(t) - defer th.TearDown() - + // Find the tests folder before we change directories to the temporary workspace. testsPath, _ := fileutils.FindDir("tests") - prepackagedPluginsPath := filepath.Join(testsPath, prepackagedPluginsDir) - fileErr := os.Mkdir(prepackagedPluginsPath, os.ModePerm) - require.NoError(t, fileErr) - defer os.RemoveAll(prepackagedPluginsPath) - prepackagedPluginsDir, found := fileutils.FindDir(prepackagedPluginsPath) - require.True(t, found, "failed to find prepackaged plugins directory") + setup := func(t *testing.T) *TestHelper { + t.Helper() - testPluginPath := filepath.Join(testsPath, "testplugin.tar.gz") - fileErr = testlib.CopyFile(testPluginPath, filepath.Join(prepackagedPluginsDir, "testplugin.tar.gz")) - require.NoError(t, fileErr) + th := Setup(t) + t.Cleanup(th.TearDown) - t.Run("automatic, enabled plugin, no signature", func(t *testing.T) { - // Install the plugin and enable - pluginBytes, err := os.ReadFile(testPluginPath) + wd, err := os.Getwd() require.NoError(t, err) - require.NotNil(t, pluginBytes) + t.Cleanup(func() { + err = os.Chdir(wd) + require.NoError(t, err) + }) - manifest, appErr := th.App.ch.installPluginLocally(bytes.NewReader(pluginBytes), nil, installPluginLocallyAlways) - require.Nil(t, appErr) - require.Equal(t, "testplugin", manifest.Id) - - env := th.App.GetPluginsEnvironment() - - activatedManifest, activated, err := env.Activate(manifest.Id) + err = os.Chdir(th.tempWorkspace) + require.NoError(t, err) + + // Make a prepackaged_plugins directory for use with the tests. + err = os.Mkdir(prepackagedPluginsDir, os.ModePerm) require.NoError(t, err) - require.True(t, activated) - require.Equal(t, manifest, activatedManifest) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = true - *cfg.PluginSettings.AutomaticPrepackagedPlugins = true *cfg.PluginSettings.EnableRemoteMarketplace = false }) - plugins := th.App.ch.processPrepackagedPlugins(prepackagedPluginsDir) - require.Len(t, plugins, 1) - require.Equal(t, plugins[0].Manifest.Id, "testplugin") - require.Empty(t, plugins[0].Signature, 0) + return th + } + + initPlugins := func(t *testing.T, th *TestHelper) ([]*plugin.PrepackagedPlugin, []*plugin.PrepackagedPlugin) { + t.Helper() + + appErr := th.App.ch.syncPlugins() + require.Nil(t, appErr) + + err := th.App.ch.processPrepackagedPlugins(prepackagedPluginsDir) + require.NoError(t, err) + + env := th.App.GetPluginsEnvironment() + plugins, transitionalPlugins := env.PrepackagedPlugins(), env.TransitionallyPrepackagedPlugins() + th.App.ch.persistTransitionallyPrepackagedPlugins() + + return plugins, transitionalPlugins + } + + copyAsPrepackagedPlugin := func(t *testing.T, filename string) { + t.Helper() + + err := testlib.CopyFile(filepath.Join(testsPath, filename), filepath.Join(prepackagedPluginsDir, filename)) + require.NoError(t, err) + + err = testlib.CopyFile(filepath.Join(testsPath, fmt.Sprintf("%s.sig", filename)), filepath.Join(prepackagedPluginsDir, fmt.Sprintf("%s.sig", filename))) + require.NoError(t, err) + } + + copyAsFilestorePlugin := func(t *testing.T, bundleFilename, pluginID string) { + t.Helper() + + err := testlib.CopyFile(filepath.Join(testsPath, bundleFilename), filepath.Join(fmt.Sprintf("data/plugins/%s.tar.gz", pluginID))) + require.NoError(t, err) + err = testlib.CopyFile(filepath.Join(testsPath, fmt.Sprintf("%s.sig", bundleFilename)), filepath.Join(fmt.Sprintf("data/plugins/%s.tar.gz.sig", pluginID))) + require.NoError(t, err) + } + + expectPrepackagedPlugin := func(t *testing.T, pluginID, version string, actual *plugin.PrepackagedPlugin) { + t.Helper() + + require.Equal(t, pluginID, actual.Manifest.Id) + require.NotEmpty(t, actual.Signature, "testplugin has no signature") + require.Equal(t, version, actual.Manifest.Version) + } + + expectPluginInFilestore := func(t *testing.T, pluginID, version string) { + t.Helper() + + bundlePath := fmt.Sprintf("data/plugins/%s.tar.gz", pluginID) + + require.FileExists(t, bundlePath) + require.FileExists(t, fmt.Sprintf("data/plugins/%s.tar.gz.sig", pluginID)) + + // Verify the version recorded in the Manifest + f, err := os.Open(bundlePath) + require.NoError(t, err) + + uncompressedStream, err := gzip.NewReader(f) + require.NoError(t, err) + + tarReader := tar.NewReader(uncompressedStream) + + var manifest model.Manifest + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + require.NoError(t, err) + + t.Log(header.Name) + if header.Typeflag == tar.TypeReg && filepath.Base(header.Name) == "plugin.json" { + manifestReader := json.NewDecoder(tarReader) + err = manifestReader.Decode(&manifest) + require.NoError(t, err) + break + } + } + + require.Equal(t, pluginID, manifest.Id, "failed to find manifest") + require.Equal(t, version, manifest.Version) + } + + expectPluginNotInFilestore := func(t *testing.T, pluginID string) { + t.Helper() + + require.NoFileExists(t, fmt.Sprintf("data/plugins/%s.tar.gz", pluginID)) + require.NoFileExists(t, fmt.Sprintf("data/plugins/%s.tar.gz.sig", pluginID)) + } + + expectPluginStatus := func(t *testing.T, pluginID, version string, actual *model.PluginStatus) { + t.Helper() + + require.Equal(t, pluginID, actual.PluginId) + require.Equal(t, version, actual.Version) + } + + t.Run("single plugin automatically installed since enabled", func(t *testing.T) { + th := setup(t) + env := th.App.GetPluginsEnvironment() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.AutomaticPrepackagedPlugins = true + cfg.PluginSettings.PluginStates["testplugin"] = &model.PluginState{Enable: true} + }) + + copyAsPrepackagedPlugin(t, "testplugin.tar.gz") + + plugins, transitionalPlugins := initPlugins(t, th) + require.Len(t, plugins, 1, "expected one prepackaged plugin") + expectPrepackagedPlugin(t, "testplugin", "0.0.1", plugins[0]) + require.Empty(t, transitionalPlugins) pluginStatus, err := env.Statuses() require.NoError(t, err) require.Len(t, pluginStatus, 1) - require.Equal(t, pluginStatus[0].PluginId, "testplugin") + expectPluginStatus(t, "testplugin", "0.0.1", pluginStatus[0]) - appErr = th.App.ch.RemovePlugin("testplugin") - checkNoError(t, appErr) - - pluginStatus, err = env.Statuses() - require.NoError(t, err) - require.Len(t, pluginStatus, 0) + expectPluginNotInFilestore(t, "testplugin") }) - t.Run("automatic, not enabled plugin", func(t *testing.T) { - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.PluginSettings.Enable = true - *cfg.PluginSettings.AutomaticPrepackagedPlugins = true - *cfg.PluginSettings.EnableRemoteMarketplace = false - }) - + t.Run("single plugin, not automatically installed since not enabled", func(t *testing.T) { + th := setup(t) env := th.App.GetPluginsEnvironment() - plugins := th.App.ch.processPrepackagedPlugins(prepackagedPluginsDir) - require.Len(t, plugins, 1) - require.Equal(t, plugins[0].Manifest.Id, "testplugin") - require.Empty(t, plugins[0].Signature, 0) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.AutomaticPrepackagedPlugins = true + }) + + copyAsPrepackagedPlugin(t, "testplugin.tar.gz") + + plugins, transitionalPlugins := initPlugins(t, th) + require.Len(t, plugins, 1, "expected one prepackaged plugin") + expectPrepackagedPlugin(t, "testplugin", "0.0.1", plugins[0]) + require.Empty(t, transitionalPlugins) pluginStatus, err := env.Statuses() require.NoError(t, err) require.Empty(t, pluginStatus, 0) + + expectPluginNotInFilestore(t, "testplugin") }) - t.Run("automatic, multiple plugins with signatures, not enabled", func(t *testing.T) { - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.PluginSettings.Enable = true - *cfg.PluginSettings.AutomaticPrepackagedPlugins = true - *cfg.PluginSettings.EnableRemoteMarketplace = false - }) - + t.Run("single plugin, not automatically installed despite enabled since automatic prepackaged plugins disabled", func(t *testing.T) { + th := setup(t) env := th.App.GetPluginsEnvironment() - // Add signature - testPluginSignaturePath := filepath.Join(testsPath, "testplugin.tar.gz.sig") - err := testlib.CopyFile(testPluginSignaturePath, filepath.Join(prepackagedPluginsDir, "testplugin.tar.gz.sig")) - require.NoError(t, err) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.AutomaticPrepackagedPlugins = false + cfg.PluginSettings.PluginStates["testplugin"] = &model.PluginState{Enable: true} + }) - // Add second plugin - testPlugin2Path := filepath.Join(testsPath, "testplugin2.tar.gz") - err = testlib.CopyFile(testPlugin2Path, filepath.Join(prepackagedPluginsDir, "testplugin2.tar.gz")) - require.NoError(t, err) + copyAsPrepackagedPlugin(t, "testplugin.tar.gz") - testPlugin2SignaturePath := filepath.Join(testsPath, "testplugin2.tar.gz.sig") - err = testlib.CopyFile(testPlugin2SignaturePath, filepath.Join(prepackagedPluginsDir, "testplugin2.tar.gz.sig")) - require.NoError(t, err) - - plugins := th.App.ch.processPrepackagedPlugins(prepackagedPluginsDir) - require.Len(t, plugins, 2) - require.Contains(t, []string{"testplugin", "testplugin2"}, plugins[0].Manifest.Id) - require.NotEmpty(t, plugins[0].Signature) - require.Contains(t, []string{"testplugin", "testplugin2"}, plugins[1].Manifest.Id) - require.NotEmpty(t, plugins[1].Signature) + plugins, transitionalPlugins := initPlugins(t, th) + require.Len(t, plugins, 1, "expected one prepackaged plugin") + expectPrepackagedPlugin(t, "testplugin", "0.0.1", plugins[0]) + require.Empty(t, transitionalPlugins) pluginStatus, err := env.Statuses() require.NoError(t, err) - require.Len(t, pluginStatus, 0) + require.Empty(t, pluginStatus, 0) + + expectPluginNotInFilestore(t, "testplugin") }) - t.Run("automatic, multiple plugins with signatures, one enabled", func(t *testing.T) { - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.PluginSettings.Enable = true - *cfg.PluginSettings.AutomaticPrepackagedPlugins = true - *cfg.PluginSettings.EnableRemoteMarketplace = false - }) - + t.Run("multiple plugins, some automatically installed since enabled", func(t *testing.T) { + th := setup(t) env := th.App.GetPluginsEnvironment() - // Add signature - testPluginSignaturePath := filepath.Join(testsPath, "testplugin.tar.gz.sig") - err := testlib.CopyFile(testPluginSignaturePath, filepath.Join(prepackagedPluginsDir, "testplugin.tar.gz.sig")) - require.NoError(t, err) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.AutomaticPrepackagedPlugins = true + cfg.PluginSettings.PluginStates["testplugin2"] = &model.PluginState{Enable: true} + }) - // Install first plugin and enable - pluginBytes, err := os.ReadFile(testPluginPath) - require.NoError(t, err) - require.NotNil(t, pluginBytes) + copyAsPrepackagedPlugin(t, "testplugin.tar.gz") + copyAsPrepackagedPlugin(t, "testplugin2.tar.gz") - manifest, appErr := th.App.ch.installPluginLocally(bytes.NewReader(pluginBytes), nil, installPluginLocallyAlways) - require.Nil(t, appErr) - require.Equal(t, "testplugin", manifest.Id) - - activatedManifest, activated, err := env.Activate(manifest.Id) - require.NoError(t, err) - require.True(t, activated) - require.Equal(t, manifest, activatedManifest) - - // Add second plugin - testPlugin2Path := filepath.Join(testsPath, "testplugin2.tar.gz") - err = testlib.CopyFile(testPlugin2Path, filepath.Join(prepackagedPluginsDir, "testplugin2.tar.gz")) - require.NoError(t, err) - - testPlugin2SignaturePath := filepath.Join(testsPath, "testplugin2.tar.gz.sig") - err = testlib.CopyFile(testPlugin2SignaturePath, filepath.Join(prepackagedPluginsDir, "testplugin2.tar.gz.sig")) - require.NoError(t, err) - - plugins := th.App.ch.processPrepackagedPlugins(prepackagedPluginsDir) - require.Len(t, plugins, 2) - require.Contains(t, []string{"testplugin", "testplugin2"}, plugins[0].Manifest.Id) - require.NotEmpty(t, plugins[0].Signature) - require.Contains(t, []string{"testplugin", "testplugin2"}, plugins[1].Manifest.Id) - require.NotEmpty(t, plugins[1].Signature) + plugins, transitionalPlugins := initPlugins(t, th) + require.Len(t, plugins, 2, "expected two prepackaged plugins") + sort.Sort(byId(plugins)) + expectPrepackagedPlugin(t, "testplugin", "0.0.1", plugins[0]) + expectPrepackagedPlugin(t, "testplugin2", "1.2.3", plugins[1]) + require.Empty(t, transitionalPlugins) pluginStatus, err := env.Statuses() require.NoError(t, err) require.Len(t, pluginStatus, 1) - require.Equal(t, pluginStatus[0].PluginId, "testplugin") + expectPluginStatus(t, "testplugin2", "1.2.3", pluginStatus[0]) - appErr = th.App.ch.RemovePlugin("testplugin") - checkNoError(t, appErr) - - pluginStatus, err = env.Statuses() - require.NoError(t, err) - require.Len(t, pluginStatus, 0) + expectPluginNotInFilestore(t, "testplugin") + expectPluginNotInFilestore(t, "testplugin2") }) - t.Run("non-automatic, multiple plugins", func(t *testing.T) { - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.PluginSettings.Enable = true - *cfg.PluginSettings.AutomaticPrepackagedPlugins = false - *cfg.PluginSettings.EnableRemoteMarketplace = false - }) - + t.Run("multiple plugins, one previously installed, all now installed", func(t *testing.T) { + th := setup(t) env := th.App.GetPluginsEnvironment() - testPlugin2Path := filepath.Join(testsPath, "testplugin2.tar.gz") - err := testlib.CopyFile(testPlugin2Path, filepath.Join(prepackagedPluginsDir, "testplugin2.tar.gz")) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.AutomaticPrepackagedPlugins = true + }) + + copyAsFilestorePlugin(t, "testplugin.tar.gz", "testplugin") + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.PluginSettings.PluginStates["testplugin"] = &model.PluginState{Enable: true} + cfg.PluginSettings.PluginStates["testplugin2"] = &model.PluginState{Enable: true} + }) + + copyAsPrepackagedPlugin(t, "testplugin.tar.gz") + copyAsPrepackagedPlugin(t, "testplugin2.tar.gz") + + plugins, transitionalPlugins := initPlugins(t, th) + require.Len(t, plugins, 2, "expected two prepackaged plugins") + sort.Sort(byId(plugins)) + expectPrepackagedPlugin(t, "testplugin", "0.0.1", plugins[0]) + expectPrepackagedPlugin(t, "testplugin2", "1.2.3", plugins[1]) + require.Empty(t, transitionalPlugins) + + pluginStatus, err := env.Statuses() require.NoError(t, err) - testPlugin2SignaturePath := filepath.Join(testsPath, "testplugin2.tar.gz.sig") - err = testlib.CopyFile(testPlugin2SignaturePath, filepath.Join(prepackagedPluginsDir, "testplugin2.tar.gz.sig")) + sort.Sort(pluginStatusById(pluginStatus)) + + require.Len(t, pluginStatus, 2) + expectPluginStatus(t, "testplugin", "0.0.1", pluginStatus[0]) + expectPluginStatus(t, "testplugin2", "1.2.3", pluginStatus[1]) + + expectPluginInFilestore(t, "testplugin", "0.0.1") + expectPluginNotInFilestore(t, "testplugin2") + }) + + t.Run("multiple plugins, one previously installed and now upgraded, all now installed", func(t *testing.T) { + th := setup(t) + env := th.App.GetPluginsEnvironment() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.AutomaticPrepackagedPlugins = true + }) + + copyAsFilestorePlugin(t, "testplugin.tar.gz", "testplugin") + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.PluginSettings.PluginStates["testplugin"] = &model.PluginState{Enable: true} + cfg.PluginSettings.PluginStates["testplugin2"] = &model.PluginState{Enable: true} + }) + + copyAsPrepackagedPlugin(t, "testplugin-v0.0.2.tar.gz") + copyAsPrepackagedPlugin(t, "testplugin2.tar.gz") + + plugins, transitionalPlugins := initPlugins(t, th) + err := th.App.ch.processPrepackagedPlugins(prepackagedPluginsDir) require.NoError(t, err) - plugins := th.App.ch.processPrepackagedPlugins(prepackagedPluginsDir) - require.Len(t, plugins, 2) - require.Contains(t, []string{"testplugin", "testplugin2"}, plugins[0].Manifest.Id) - require.NotEmpty(t, plugins[0].Signature) - require.Contains(t, []string{"testplugin", "testplugin2"}, plugins[1].Manifest.Id) - require.NotEmpty(t, plugins[1].Signature) + require.Len(t, plugins, 2, "expected two prepackaged plugins") + sort.Sort(byId(plugins)) + expectPrepackagedPlugin(t, "testplugin", "0.0.2", plugins[0]) + expectPrepackagedPlugin(t, "testplugin2", "1.2.3", plugins[1]) + require.Empty(t, transitionalPlugins) + + pluginStatus, err := env.Statuses() + require.NoError(t, err) + + sort.Sort(pluginStatusById(pluginStatus)) + + require.Len(t, pluginStatus, 2) + expectPluginStatus(t, "testplugin", "0.0.2", pluginStatus[0]) + expectPluginStatus(t, "testplugin2", "1.2.3", pluginStatus[1]) + + expectPluginInFilestore(t, "testplugin", "0.0.1") + expectPluginNotInFilestore(t, "testplugin2") + }) + + t.Run("multiple plugins, one previously installed but prepackaged is older, all now installed", func(t *testing.T) { + th := setup(t) + env := th.App.GetPluginsEnvironment() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.AutomaticPrepackagedPlugins = true + }) + + copyAsFilestorePlugin(t, "testplugin-v0.0.2.tar.gz", "testplugin") + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.PluginSettings.PluginStates["testplugin"] = &model.PluginState{Enable: true} + cfg.PluginSettings.PluginStates["testplugin2"] = &model.PluginState{Enable: true} + }) + + copyAsPrepackagedPlugin(t, "testplugin.tar.gz") + copyAsPrepackagedPlugin(t, "testplugin2.tar.gz") + + plugins, transitionalPlugins := initPlugins(t, th) + require.Len(t, plugins, 2, "expected two prepackaged plugins") + sort.Sort(byId(plugins)) + expectPrepackagedPlugin(t, "testplugin", "0.0.1", plugins[0]) + expectPrepackagedPlugin(t, "testplugin2", "1.2.3", plugins[1]) + require.Empty(t, transitionalPlugins) + + pluginStatus, err := env.Statuses() + require.NoError(t, err) + + sort.Sort(pluginStatusById(pluginStatus)) + + require.Len(t, pluginStatus, 2) + expectPluginStatus(t, "testplugin", "0.0.2", pluginStatus[0]) + expectPluginStatus(t, "testplugin2", "1.2.3", pluginStatus[1]) + + expectPluginInFilestore(t, "testplugin", "0.0.2") + expectPluginNotInFilestore(t, "testplugin2") + }) + + t.Run("multiple plugins, not automatically installed despite enabled since automatic prepackaged plugins disabled", func(t *testing.T) { + th := setup(t) + env := th.App.GetPluginsEnvironment() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.AutomaticPrepackagedPlugins = false + cfg.PluginSettings.PluginStates["testplugin"] = &model.PluginState{Enable: true} + cfg.PluginSettings.PluginStates["testplugin2"] = &model.PluginState{Enable: true} + }) + + copyAsPrepackagedPlugin(t, "testplugin.tar.gz") + copyAsPrepackagedPlugin(t, "testplugin2.tar.gz") + + plugins, transitionalPlugins := initPlugins(t, th) + require.Len(t, plugins, 2, "expected two prepackaged plugins") + sort.Sort(byId(plugins)) + expectPrepackagedPlugin(t, "testplugin", "0.0.1", plugins[0]) + expectPrepackagedPlugin(t, "testplugin2", "1.2.3", plugins[1]) + require.Empty(t, transitionalPlugins) pluginStatus, err := env.Statuses() require.NoError(t, err) require.Len(t, pluginStatus, 0) + + expectPluginNotInFilestore(t, "testplugin") + expectPluginNotInFilestore(t, "testplugin2") + }) + + t.Run("removing a prepackaged plugin leaves it disabled", func(t *testing.T) { + th := setup(t) + env := th.App.GetPluginsEnvironment() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.AutomaticPrepackagedPlugins = true + cfg.PluginSettings.PluginStates["testplugin"] = &model.PluginState{Enable: true} + cfg.PluginSettings.PluginStates["testplugin2"] = &model.PluginState{Enable: true} + }) + + copyAsPrepackagedPlugin(t, "testplugin.tar.gz") + copyAsPrepackagedPlugin(t, "testplugin2.tar.gz") + + plugins, transitionalPlugins := initPlugins(t, th) + require.Len(t, plugins, 2, "expected two prepackaged plugins") + sort.Sort(byId(plugins)) + expectPrepackagedPlugin(t, "testplugin", "0.0.1", plugins[0]) + expectPrepackagedPlugin(t, "testplugin2", "1.2.3", plugins[1]) + require.Empty(t, transitionalPlugins) + + pluginStatus, err := env.Statuses() + require.NoError(t, err) + + sort.Sort(pluginStatusById(pluginStatus)) + + require.Len(t, pluginStatus, 2) + require.Equal(t, "testplugin", pluginStatus[0].PluginId) + require.Equal(t, "testplugin2", pluginStatus[1].PluginId) + + appErr := th.App.ch.RemovePlugin("testplugin") + checkNoError(t, appErr) + + pluginStatus, err = env.Statuses() + require.NoError(t, err) + require.Len(t, pluginStatus, 1) + require.Equal(t, "testplugin2", pluginStatus[0].PluginId) + + plugins, transitionalPlugins = initPlugins(t, th) + require.Len(t, plugins, 2, "expected two prepackaged plugins") + sort.Sort(byId(plugins)) + expectPrepackagedPlugin(t, "testplugin", "0.0.1", plugins[0]) + expectPrepackagedPlugin(t, "testplugin2", "1.2.3", plugins[1]) + require.Empty(t, transitionalPlugins) + + pluginStatus, err = env.Statuses() + require.NoError(t, err) + require.Len(t, pluginStatus, 1) + expectPluginStatus(t, "testplugin2", "1.2.3", pluginStatus[0]) + + expectPluginNotInFilestore(t, "testplugin") + expectPluginNotInFilestore(t, "testplugin2") + }) + + t.Run("single transitional plugin automatically installed and persisted since enabled", func(t *testing.T) { + th := setup(t) + env := th.App.GetPluginsEnvironment() + + oldTransitionallyPrepackagedPlugins := append([]string{}, transitionallyPrepackagedPlugins...) + transitionallyPrepackagedPlugins = append(transitionallyPrepackagedPlugins, "testplugin") + defer func() { + transitionallyPrepackagedPlugins = oldTransitionallyPrepackagedPlugins + }() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.AutomaticPrepackagedPlugins = true + cfg.PluginSettings.PluginStates["testplugin"] = &model.PluginState{Enable: true} + }) + + copyAsPrepackagedPlugin(t, "testplugin.tar.gz") + + plugins, transitionalPlugins := initPlugins(t, th) + require.Empty(t, plugins) + require.Len(t, transitionalPlugins, 1) + expectPrepackagedPlugin(t, "testplugin", "0.0.1", transitionalPlugins[0]) + + pluginStatus, err := env.Statuses() + require.NoError(t, err) + require.Len(t, pluginStatus, 1) + expectPluginStatus(t, "testplugin", "0.0.1", pluginStatus[0]) + + expectPluginInFilestore(t, "testplugin", "0.0.1") + expectPluginNotInFilestore(t, "testplugin2") + }) + + t.Run("single transitional plugin not persisted since already in filestore", func(t *testing.T) { + th := setup(t) + env := th.App.GetPluginsEnvironment() + + oldTransitionallyPrepackagedPlugins := append([]string{}, transitionallyPrepackagedPlugins...) + transitionallyPrepackagedPlugins = append(transitionallyPrepackagedPlugins, "testplugin") + defer func() { + transitionallyPrepackagedPlugins = oldTransitionallyPrepackagedPlugins + }() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.AutomaticPrepackagedPlugins = true + cfg.PluginSettings.PluginStates["testplugin"] = &model.PluginState{Enable: true} + }) + + copyAsFilestorePlugin(t, "testplugin.tar.gz", "testplugin") + copyAsPrepackagedPlugin(t, "testplugin.tar.gz") + + plugins, transitionalPlugins := initPlugins(t, th) + require.Empty(t, plugins) + require.Empty(t, transitionalPlugins) + + pluginStatus, err := env.Statuses() + require.NoError(t, err) + require.Len(t, pluginStatus, 1) + expectPluginStatus(t, "testplugin", "0.0.1", pluginStatus[0]) + + expectPluginInFilestore(t, "testplugin", "0.0.1") + expectPluginNotInFilestore(t, "testplugin2") + }) + + t.Run("single transitional plugin persisted since newer than filestore", func(t *testing.T) { + th := setup(t) + env := th.App.GetPluginsEnvironment() + + oldTransitionallyPrepackagedPlugins := append([]string{}, transitionallyPrepackagedPlugins...) + transitionallyPrepackagedPlugins = append(transitionallyPrepackagedPlugins, "testplugin") + defer func() { + transitionallyPrepackagedPlugins = oldTransitionallyPrepackagedPlugins + }() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.AutomaticPrepackagedPlugins = true + cfg.PluginSettings.PluginStates["testplugin"] = &model.PluginState{Enable: true} + }) + + copyAsFilestorePlugin(t, "testplugin.tar.gz", "testplugin") + copyAsPrepackagedPlugin(t, "testplugin-v0.0.2.tar.gz") + + plugins, transitionalPlugins := initPlugins(t, th) + require.Empty(t, plugins) + require.Len(t, transitionalPlugins, 1) + expectPrepackagedPlugin(t, "testplugin", "0.0.2", transitionalPlugins[0]) + + pluginStatus, err := env.Statuses() + require.NoError(t, err) + require.Len(t, pluginStatus, 1) + expectPluginStatus(t, "testplugin", "0.0.2", pluginStatus[0]) + + expectPluginInFilestore(t, "testplugin", "0.0.2") + expectPluginNotInFilestore(t, "testplugin2") + }) + + t.Run("transitional plugins persisted only once", func(t *testing.T) { + th := setup(t) + env := th.App.GetPluginsEnvironment() + + oldTransitionallyPrepackagedPlugins := append([]string{}, transitionallyPrepackagedPlugins...) + transitionallyPrepackagedPlugins = append(transitionallyPrepackagedPlugins, "testplugin") + defer func() { + transitionallyPrepackagedPlugins = oldTransitionallyPrepackagedPlugins + }() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.AutomaticPrepackagedPlugins = true + cfg.PluginSettings.PluginStates["testplugin"] = &model.PluginState{Enable: true} + }) + + copyAsPrepackagedPlugin(t, "testplugin.tar.gz") + + plugins, transitionalPlugins := initPlugins(t, th) + require.Empty(t, plugins) + require.Len(t, transitionalPlugins, 1) + expectPrepackagedPlugin(t, "testplugin", "0.0.1", transitionalPlugins[0]) + + th.App.ch.RemovePlugin("testplugin") + + pluginStatus, err := env.Statuses() + require.NoError(t, err) + require.Empty(t, pluginStatus, 0) + + th.App.ch.persistTransitionallyPrepackagedPlugins() + + expectPluginNotInFilestore(t, "testplugin") + expectPluginNotInFilestore(t, "testplugin2") }) } diff --git a/server/public/plugin/environment.go b/server/public/plugin/environment.go index 32fd819a55..acf808dea3 100644 --- a/server/public/plugin/environment.go +++ b/server/public/plugin/environment.go @@ -48,16 +48,17 @@ type PrepackagedPlugin struct { // It is meant for use by the Mattermost server to manipulate, interact with and report on the set // of active plugins. type Environment struct { - registeredPlugins sync.Map - pluginHealthCheckJob *PluginHealthCheckJob - logger *mlog.Logger - metrics metricsInterface - newAPIImpl apiImplCreatorFunc - dbDriver Driver - pluginDir string - webappPluginDir string - prepackagedPlugins []*PrepackagedPlugin - prepackagedPluginsLock sync.RWMutex + registeredPlugins sync.Map + pluginHealthCheckJob *PluginHealthCheckJob + logger *mlog.Logger + metrics metricsInterface + newAPIImpl apiImplCreatorFunc + dbDriver Driver + pluginDir string + webappPluginDir string + prepackagedPlugins []*PrepackagedPlugin + transitionallyPrepackagedPlugins []*PrepackagedPlugin + prepackagedPluginsLock sync.RWMutex } func NewEnvironment( @@ -108,7 +109,9 @@ func (env *Environment) Available() ([]*model.BundleInfo, error) { return scanSearchPath(env.pluginDir) } -// Returns a list of prepackaged plugins available in the local prepackaged_plugins folder. +// Returns a list of prepackaged plugins available in the local prepackaged_plugins folder, +// excluding those in transition out of being prepackaged. +// // The list content is immutable and should not be modified. func (env *Environment) PrepackagedPlugins() []*PrepackagedPlugin { env.prepackagedPluginsLock.RLock() @@ -117,6 +120,26 @@ func (env *Environment) PrepackagedPlugins() []*PrepackagedPlugin { return env.prepackagedPlugins } +// TransitionallyPrepackagedPlugins returns a list of plugins transitionally prepackaged in the +// local prepackaged_plugins folder. +// +// The list content is immutable and should not be modified. +func (env *Environment) TransitionallyPrepackagedPlugins() []*PrepackagedPlugin { + env.prepackagedPluginsLock.RLock() + defer env.prepackagedPluginsLock.RUnlock() + + return env.transitionallyPrepackagedPlugins +} + +// ClearTransitionallyPrepackagedPlugins clears the list of plugins transitionally prepackaged +// in the local prepackaged_plugins folder. +func (env *Environment) ClearTransitionallyPrepackagedPlugins() { + env.prepackagedPluginsLock.RLock() + defer env.prepackagedPluginsLock.RUnlock() + + env.transitionallyPrepackagedPlugins = nil +} + // Returns a list of all currently active plugins within the environment. // The returned list should not be modified. func (env *Environment) Active() []*model.BundleInfo { @@ -532,9 +555,10 @@ func (env *Environment) PerformHealthCheck(id string) error { } // SetPrepackagedPlugins saves prepackaged plugins in the environment. -func (env *Environment) SetPrepackagedPlugins(plugins []*PrepackagedPlugin) { +func (env *Environment) SetPrepackagedPlugins(plugins, transitionalPlugins []*PrepackagedPlugin) { env.prepackagedPluginsLock.Lock() env.prepackagedPlugins = plugins + env.transitionallyPrepackagedPlugins = transitionalPlugins env.prepackagedPluginsLock.Unlock() } diff --git a/server/tests/README.md b/server/tests/README.md index d259132da5..60481b4143 100644 --- a/server/tests/README.md +++ b/server/tests/README.md @@ -19,7 +19,7 @@ It is possible to manually test specific sections of any test, instead of using ## Test plugins -There are two test plugins: `testplugin.tar.gz` and `testplugin2.tar.gz`. These are use in some integration tests in the `api4` package. Any changes to the plugin bundles require updating the corresponding signatures. +There are three test plugins: `testplugin.tar.gz`, `testplugin-v0.0.2.tar.gz`, and `testplugin2.tar.gz`. These are use in some integration tests in the `api4` package. Any changes to the plugin bundles require updating the corresponding signatures. First, import the public and private development key: ```sh @@ -33,6 +33,8 @@ Then update the signatures: ```sh gpg -u F3FACE45E0DE642C8BD6A8E64C7C6562C192CC1F --verbose --personal-digest-preferences SHA256 --detach-sign testplugin.tar.gz gpg -u F3FACE45E0DE642C8BD6A8E64C7C6562C192CC1F --verbose --personal-digest-preferences SHA256 --detach-sign --armor testplugin.tar.gz +gpg -u F3FACE45E0DE642C8BD6A8E64C7C6562C192CC1F --verbose --personal-digest-preferences SHA256 --detach-sign testplugin-v0.0.2.tar.gz +gpg -u F3FACE45E0DE642C8BD6A8E64C7C6562C192CC1F --verbose --personal-digest-preferences SHA256 --detach-sign --armor testplugin-v0.0.2.tar.gz gpg -u F3FACE45E0DE642C8BD6A8E64C7C6562C192CC1F --verbose --personal-digest-preferences SHA256 --detach-sign testplugin2.tar.gz gpg -u F3FACE45E0DE642C8BD6A8E64C7C6562C192CC1F --verbose --personal-digest-preferences SHA256 --detach-sign --armor testplugin2.tar.gz ``` diff --git a/server/tests/testplugin-v0.0.2.tar.gz b/server/tests/testplugin-v0.0.2.tar.gz new file mode 100644 index 0000000000..5629ced271 Binary files /dev/null and b/server/tests/testplugin-v0.0.2.tar.gz differ diff --git a/server/tests/testplugin-v0.0.2.tar.gz.asc b/server/tests/testplugin-v0.0.2.tar.gz.asc new file mode 100644 index 0000000000..9f16407103 --- /dev/null +++ b/server/tests/testplugin-v0.0.2.tar.gz.asc @@ -0,0 +1,14 @@ +-----BEGIN PGP SIGNATURE----- + +iQGzBAABCAAdFiEE8/rOReDeZCyL1qjmTHxlYsGSzB8FAmTVE0YACgkQTHxlYsGS +zB/eiAv/V5PWAJesWktjFIWWviuiwnL3orGrlPVQ4c5d3pHslyTr7uyzAvyOhH9w +Eg7BZC3vObV61d2XAseU0+hCUjZ7JHjRwyhTxdVj0fsuaDgl1mBbHTtZXPCKL59P +RfXf8urkzzlSVYnMgeH158KTBtVyDaNENrhytIkNaxVy3k50tga6Z4GgN+wTfyQw +FdLIT2piYb9UZZQT8mtw8l/Z/kkV0E038GK3PI1lLlhPHrvIo9kYG/11R061kXgg +GxiuNEHdSftI/ZHF8f/QoPQosT5nZavhLsGE7eD+YBhDiL61cArcenGPKEsQSIIr +K+9su+u/7XMqFX4GgsMs7uvxWz4o1ihK9l4wQ8aLDcYolUHqzplwOcQHojGutmiF +XuqL17s7gxYTxRMHDmaePuI9mnti7cVFrSXLJwsy4qt7nAKcT2IP/qR8VBLE3R3f +Gm15AaivKON24Oj7URz4hQfQXbNj0Ve4Pmiq85hT0VXZZAVCpuxww0J7Q0EXvaBg +9dzylz8J +=L0I2 +-----END PGP SIGNATURE----- diff --git a/server/tests/testplugin-v0.0.2.tar.gz.sig b/server/tests/testplugin-v0.0.2.tar.gz.sig new file mode 100644 index 0000000000..47880e6a96 Binary files /dev/null and b/server/tests/testplugin-v0.0.2.tar.gz.sig differ diff --git a/server/tests/testplugin.tar.gz.asc b/server/tests/testplugin.tar.gz.asc index 7a7885f779..262af885b6 100644 --- a/server/tests/testplugin.tar.gz.asc +++ b/server/tests/testplugin.tar.gz.asc @@ -1,14 +1,14 @@ -----BEGIN PGP SIGNATURE----- -iQGzBAABCAAdFiEE8/rOReDeZCyL1qjmTHxlYsGSzB8FAmOaRXoACgkQTHxlYsGS -zB8H1Av+MuNxBuQFxNvORGcudExCAAgQZb3ykYNVxPT1CzwVdd16B+VvRyt3+PKz -nSsyYyrdvd2xpdaEXFHBA8RxS5ZWCz/hkrdxUhBUWV8O5OUMOHDYvetWc6/9GeuR -3dd4VElpLsEs6hIpwnejR1EouNr5OhxstpnB2AOz5N7LWlG5lTKhaHs1zN1uLc4f -GmdJZ+5+PYm1UUipFj4kolkI+44Ytl6mj+tTyC4VJAj0mwnXQtp/JdFcDmmeRrTI -AwmanJKQlK3yw331FYSd/CXuqCGOh157X7Z5P2Mtr3ZOaNj7qLY0mjweqxjj4fnN -YTUu22KhRLGzggEbTg+5huYhtvqa1b87EcH6ukxWoBYQpFK+TyyhuX3ZeT5x6lFi -8SP9o/9KcQxB5oD6X5FGMR4v5VDosNnNuqW8G7g4fkcjQKY65tnX75G5Ih156BAP -dfZ6+nKCQvXgv1XRymF3UrJeddXtOMQzh4aSvYwwy46qNMPYMeDq6PoMXGVNeylP -bTrjLEtE -=OvPw +iQGzBAABCAAdFiEE8/rOReDeZCyL1qjmTHxlYsGSzB8FAmTVE0YACgkQTHxlYsGS +zB/3YgwAlpt6B7w//bGGcbi1pGZU90Pz7qwDckvKQHAf1fh6BEKDb7lmFfw2okfI +0PIZ5tYiVrPI2FRoNMWUrJhqBvMJ22fi24ZXT4KHPAbm/tC9QmuxWA/sXHlx8EEM +7LhIBlBpaOd7F5Qu1PyLgfRWL/k/US4xm54yhpyDf61TiwiHuG/dBaTHTHoDhlKL +rTrkXngQ1DbKjOSj9XxVuMS7BMV5QMT0fex3BEA2hSPHdWqt209mgYalxzV6vGp3 +yjO1uGTfZi0P7S+F+QCZX4DYq5NMwlNEAt0OQbyE4rhI637nkh52mO9GUjezyPyw +eFeoUakwhbPMf6wdiBj378Vng+ja2HoN18ZGxmA4/2dF5w6ShlkPJuyjfEsqp8kr +A6wnNp+iSf2t2zam8AbN1cfiSYDJgJKcfecdO2gU/Bu6khqr3IPrjuFVR4ObSaV9 +kVoQo9rcu0gDybDjDHBpwgJwEIVwtEXwVyFe5fC7lK5Pc8oPz4pz6GtCo9OdnPr9 +dEFcQAtF +=w4gX -----END PGP SIGNATURE----- diff --git a/server/tests/testplugin.tar.gz.sig b/server/tests/testplugin.tar.gz.sig index 280f77dc12..eba987cdeb 100644 Binary files a/server/tests/testplugin.tar.gz.sig and b/server/tests/testplugin.tar.gz.sig differ diff --git a/server/tests/testplugin2.tar.gz.asc b/server/tests/testplugin2.tar.gz.asc index e460b3fd07..0f62d29801 100644 --- a/server/tests/testplugin2.tar.gz.asc +++ b/server/tests/testplugin2.tar.gz.asc @@ -1,14 +1,14 @@ -----BEGIN PGP SIGNATURE----- -iQGzBAABCAAdFiEE8/rOReDeZCyL1qjmTHxlYsGSzB8FAl4soo8ACgkQTHxlYsGS -zB+7RwwAmwLH64J1G9LUQRD1rJFlvlIGgEE6leaJO4bKkOACD4QQs70q+rLzNazA -maVKjOddKq5fslIsQxHHlIeIMfuTioQ12ZOU6pVY1o1gw5RPolCopLdgac1lA0Fg -dDMCtB62iRJqcsnUWFJwhf9fBCwNVGjG6+fPnLyHOXNmDGWDnxxPLNevSEya/Yzg -68C1ervBJ4AV6x6Wyc4ejaAliHOBMzv1Z8NclWisKYllaigUUtWauctiM00Ga7kM -ADOMVBljI8QauV7E39mxr89Z9ULo8moI2dIPwyrC2vWaW0RnK1XXO2+pDugrlSN7 -ZLGFMdcD6kjnwaZXfSNXeXh2PeERjhRk8Zw5mvrg51VgUaqv2pZjP4g2hA5D1D0U -BGgPLVrlkqEV9my8MNLMhBmj1WfBzDNMEcUu4UbeVpnkj3Px0cnWAAFQgSAMniON -c8ng0VEuEHo2z3kiQcBT//mHgFuzaX47OYgPsBPZzMpIAlQ+giryHT8byXcOLcfc -KuwzQRZj -=Z3mM +iQGzBAABCAAdFiEE8/rOReDeZCyL1qjmTHxlYsGSzB8FAmTVE0cACgkQTHxlYsGS +zB+ikwwAyBpTvmyUT+WEnK8H/Ppdk8bGoPH8CdygOwR3Ss57IEd7lJWSVT2Wqnde +3aINnwa5Q6vHPEOiNFA+FiHGOonLlB7a4tqb2/Je/cWYyGSz8Fq+UNp9qyo5s/0s +xoDQrraNJIXQ9e+G0IiyL/aN8A+DnJVp9wEFkHDnp0eqDyRwULlK/HWwjnnIOk0K +vSae7T610SCjYgLT+S+QZ8/hefzhEYq6S54qN+LL3TdGcveFQNUwgVKcHipCo1s1 +WffRcC24Rt9LiQd00CAdQCKGoTl3KjxXsU70oeDCXHGvzod0iH/OIww437ewz0EU +/f+HVHHJAopbp947KUv5yvlGfqMXjgtDPKDyu4WtiK/BRkX7Wd9+PMLCLjn5URLx +9s+c0+5/uBdIxPwBzvdlcogoR0pmGcR1MZwias83mLYuaABsZsDVdNxsRTSVtEzj +Ko+2rvw3Yp3xdxPa/lXYFpf74e/oqqcY3qM0BHc+cnWBnTiTc3RfhnyJI98U3RzV +G78oFS/3 +=Y1mv -----END PGP SIGNATURE----- diff --git a/server/tests/testplugin2.tar.gz.sig b/server/tests/testplugin2.tar.gz.sig index 90f6d24be4..52996bf0ce 100644 Binary files a/server/tests/testplugin2.tar.gz.sig and b/server/tests/testplugin2.tar.gz.sig differ