MM-53355: install transitionally prepackaged plugins to filestore (#24225)

* move plugin signature verification to caller

The semantics for when plugin signature validation is required are unique to the caller, so move this logic there instead of masking it, thus simplifying some of the downstream code.

* support transitionally prepacked plugins

Transitionally prepackaged plugins are prepackaged plugins slated for unpackaging in some future release. Like prepackaged plugins, they automatically install or upgrade if the server is configured to enable that plugin, but unlike prepackaged plugins they don't add to the marketplace to allow for offline installs. In fact, if unlisted from the marketplace and not already enabled via `config.json`, a transitionally prepackaged plugin is essentially hidden.

To ensure a smooth transition in the future release when this plugin is no longer prepackaged at all, transitionally prepackaged plugins are persisted to the filestore as if they had been installed by the enduser. On the next restart, even while the plugin is still transitionally prepackaged, the version in the filestore will take priority. It remains possible for a transitionally prepackaged plugin to upgrade (and once again persist) if we ship a newer version before dropping it altogether.

Some complexity arises in a multi-server cluster, primarily because we don't want to deal with multiple servers writing the same object to the filestore. This is probably fine for S3, but has undefined semantics for regular filesystems, especially with some customers backing their files on any number of different fileshare technologies. To simplify the complexity, only the cluster leader persists transitionally prepackaged plugins.

Unfortunately, this too is complicated, since on upgrade to the first version with the transitionally prepackaged plugin, there is no guarantee that server will be the leader. In fact, as all nodes restart, there is no guarantee that any newly started server will start as the leader. So the persistence has to happen in a job-like fashion. The migration system might work, except we want the ability to run this repeatedly as we add to (or update) these transitionally prepackaged plugins. We also want to minimize the overhead required from the server to juggle any of this.

As a consequence, the persistence of transitionally prepackaged plugins occurs on every cluster leader change. Each server will try at most once to persist its collection of transitionally prepackaged plugins, and newly started servers will see the plugins in the filestore and skip this step altogether.

The current set of transitionally prepackaged plugins include the following, but this is expected to change:
* focalboard

* complete list of transitionally prepackaged plugins

* update plugin_install.go docs

* updated test plugins

* unit test transitionally prepackged plugins

* try restoring original working directory

* Apply suggestions from code review

Co-authored-by: Michael Kochell <6913320+mickmister@users.noreply.github.com>

* clarify processPrepackagedPlugins comment

---------

Co-authored-by: Michael Kochell <6913320+mickmister@users.noreply.github.com>
This commit is contained in:
Jesse Hallam
2023-08-17 12:46:57 -03:00
committed by GitHub
parent 7db5b473bb
commit ad142c958e
15 changed files with 908 additions and 250 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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")
})
}

View File

@@ -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()
}

View File

@@ -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
```

Binary file not shown.

View File

@@ -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-----

Binary file not shown.

View File

@@ -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-----

Binary file not shown.

View File

@@ -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-----

Binary file not shown.