Files
mattermost/server/channels/app/plugin_install.go
T
Jesse HallamandAlejandro García Montoro 8a267c8aba MM-69104: Elide plugin_statuses_changed payload (#36966)
* MM-69104: signal plugin_statuses_changed without a payload, refetch on demand

The plugin_statuses_changed websocket event broadcast the entire cluster-wide
plugin status slice over the best-effort (UDP) cluster transport. With enough
installed plugins the serialized payload exceeded the gossip datagram size and
was silently dropped with EMSGSIZE ("message too long"), leaving admin clients
on peer nodes with stale plugin state during plugin reinstall/restart churn.

The event is admin-only and only feeds the System Console plugin views, so
rather than push the whole slice, publish it as a payload-free signal sent
reliably over the cluster. The Plugin Management and Marketplace views subscribe
via the useWebSocket hook and debounce a refetch of a full, cluster-wide status
from the existing REST endpoint, pulling the data on demand only while a relevant
view is mounted.

* MM-69104: keep plugin_statuses as an empty array for client compatibility

Some clients call array methods on plugin_statuses (e.g. the Boards/Focalboard
plugin does data.plugin_statuses.find(...)). Omitting the field entirely makes that
throw, so send a non-nil empty slice that serializes as [] rather than null. The
payload stays effectively empty, so the cluster-broadcast size fix is preserved.

* fixup! MM-69104: signal plugin_statuses_changed without a payload, refetch on demand

* Apply suggestions from code review

Co-authored-by: Alejandro García Montoro <alejandro.garciamontoro@gmail.com>

* fixup! MM-69104: signal plugin_statuses_changed without a payload, refetch on demand

use shared useDebounce hook in usePluginStatusesSync

* fixup! MM-69104: signal plugin_statuses_changed without a payload, refetch on demand

trim docstring

* fixup! MM-69104: signal plugin_statuses_changed without a payload, refetch on demand

return pluginStatuses from hook

* fixup! MM-69104: signal plugin_statuses_changed without a payload, refetch on demand

cover hook return value

---------

Co-authored-by: Alejandro García Montoro <alejandro.garciamontoro@gmail.com>
2026-06-10 13:07:53 -03:00

623 lines
26 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ### Plugin Bundles
//
// 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.
//
// 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)
// ┌────────────────────────────┐
// │ ┌────────────────────────┐ │
// │ │prepackaged_plugins/ │ │
// │ │ prepackaged.tar.gz │ │
// │ │ transitional.tar.gz │ │
// │ │ │ │
// │ └────────────────────────┘ │
// │ │ │
// │ ▼ │
// │ ┌────────────────────────┐ │
// │ │plugins/ │ │ ┌────────────────────────┐
// │ │ filestore/ │ │ │s3://bucket/plugins/ │
// │ │ prepackaged/ │◀┼───│ filestore.tar.gz │
// │ │ transitional/ │ │ │ transitional.tar.gz │
// │ │ │ │ └────────────────────────┘
// │ └────────────────────────┘ │
// │ ┌────────┤
// │ │ server │
// └───────────────────┴────────┘
//
// 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.
//
// 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.
//
// Plugins are installed to the filestore when the user installs via the marketplace or system
// console. (Or because the plugin is transitionally prepackaged).
//
// ### 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 (
"bytes"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"github.com/Masterminds/semver/v3"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/utils"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
)
// fileStorePluginFolder is the folder name in the file store of the plugin bundles installed.
const fileStorePluginFolder = "plugins"
// installPluginFromClusterMessage is called when a peer activates a plugin in the filestore,
// signalling all other servers to do the same.
func (ch *Channels) installPluginFromClusterMessage(pluginID string) {
logger := ch.srv.Log().With(mlog.String("plugin_id", pluginID))
logger.Info("Installing plugin as per cluster message")
pluginSignaturePathMap, appErr := ch.getPluginsFromFolder()
if appErr != nil {
logger.Error("Failed to get plugin signatures from filestore.", mlog.Err(appErr))
return
}
plugin, ok := pluginSignaturePathMap[pluginID]
if !ok {
logger.Error("Failed to get plugin signature from filestore.")
return
}
logger = logger.With(
mlog.String("bundle_path", plugin.bundlePath),
mlog.String("signature_path", plugin.signaturePath),
)
bundle, appErr := ch.srv.fileReader(plugin.bundlePath)
if appErr != nil {
logger.Error("Failed to open plugin bundle from file store.", mlog.Err(appErr))
return
}
defer bundle.Close()
var signature filestore.ReadCloseSeeker
if *ch.cfgSvc.Config().PluginSettings.RequirePluginSignature {
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 err := ch.verifyPlugin(logger, bundle, signature); err != nil {
logger.Error("Failed to validate plugin signature.", mlog.Err(appErr))
return
}
}
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" {
logger.Error("Failed to sync plugin from file store", mlog.Err(appErr))
}
return
}
if err := ch.notifyPluginEnabled(manifest); err != nil {
logger.Error("Failed notify plugin enabled", mlog.Err(err))
}
ch.notifyPluginStatusesChanged()
}
// removePluginFromClusterMessage is called when a peer removes a plugin, signalling all other
// servers to do the same.
func (ch *Channels) removePluginFromClusterMessage(pluginID string) {
logger := ch.srv.Log().With(mlog.String("plugin_id", pluginID))
logger.Info("Removing plugin as per cluster message")
if err := ch.removePluginLocally(pluginID); err != nil {
logger.Error("Failed to remove plugin locally", mlog.Err(err))
}
ch.notifyPluginStatusesChanged()
}
// InstallPlugin unpacks and installs a plugin but does not enable or activate it unless the
// plugin was already enabled.
func (a *App) InstallPlugin(pluginFile io.ReadSeeker, replace bool) (*model.Manifest, *model.AppError) {
installationStrategy := installPluginLocallyOnlyIfNew
if replace {
installationStrategy = installPluginLocallyAlways
}
return a.ch.installPlugin(pluginFile, nil, installationStrategy)
}
// installPlugin extracts and installs the given plugin bundle (optionally signed) for the
// current server, activating the plugin if already enabled, installs it to the filestore for
// cluster peers to use, and then broadcasts the change to connected websockets.
//
// 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, installationStrategy)
if appErr != nil {
return nil, appErr
}
if manifest == nil {
return nil, nil
}
logger := ch.srv.Log().With(mlog.String("plugin_id", manifest.Id))
appErr = ch.installPluginToFilestore(manifest, bundle, signature)
if appErr != nil {
return nil, appErr
}
if err := ch.notifyPluginEnabled(manifest); err != nil {
logger.Warn("Failed to notify plugin enabled", mlog.Err(err))
}
ch.notifyPluginStatusesChanged()
return manifest, nil
}
// 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 {
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)
}
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.
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)
}
ch.notifyClusterPluginEvent(
model.ClusterEventInstallPlugin,
model.PluginEventData{
Id: manifest.Id,
},
)
return nil
}
// InstallMarketplacePlugin installs a plugin listed in the marketplace server. It will get the
// plugin bundle from the prepackaged folder, if available, or remotely if EnableRemoteMarketplace
// is true.
func (ch *Channels) InstallMarketplacePlugin(request *model.InstallMarketplacePluginRequest) (*model.Manifest, *model.AppError) {
logger := ch.srv.Log().With(
mlog.String("plugin_id", request.Id),
mlog.String("requested_version", request.Version),
)
logger.Info("Installing plugin from marketplace")
var pluginFile, signatureFile io.ReadSeeker
prepackagedPlugin, appErr := ch.getPrepackagedPlugin(request.Id, request.Version)
if appErr != nil && appErr.Id != "app.plugin.marketplace_plugins.not_found.app_error" {
return nil, appErr
}
if prepackagedPlugin != nil {
fileReader, err := os.Open(prepackagedPlugin.Path)
if err != nil {
return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.install_marketplace_plugin.app_error", nil, fmt.Sprintf("failed to open prepackaged plugin %s", prepackagedPlugin.Path), http.StatusInternalServerError).Wrap(err)
}
defer fileReader.Close()
signatureReader, err := os.Open(prepackagedPlugin.SignaturePath)
if err != nil {
return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.install_marketplace_plugin.app_error", nil, fmt.Sprintf("failed to open prepackaged plugin signature %s", prepackagedPlugin.SignaturePath), http.StatusInternalServerError).Wrap(err)
}
defer signatureReader.Close()
pluginFile = fileReader
signatureFile = signatureReader
logger.Debug("Found matching pre-packaged plugin", mlog.String("bundle_path", prepackagedPlugin.Path), mlog.String("signature_path", prepackagedPlugin.SignaturePath))
}
if *ch.cfgSvc.Config().PluginSettings.EnableRemoteMarketplace {
var plugin *model.BaseMarketplacePlugin
plugin, appErr = ch.getRemoteMarketplacePlugin(request.Id, request.Version)
// The plugin might only be prepackaged and not on the Marketplace.
if appErr != nil && appErr.Id != "app.plugin.marketplace_plugins.not_found.app_error" {
logger.Warn("Failed to reach Marketplace to install plugin", mlog.Err(appErr))
}
if plugin != nil {
prepackagedVersion, _ := semver.StrictNewVersion("0.0.0")
if prepackagedPlugin != nil {
var err error
prepackagedVersion, err = semver.StrictNewVersion(prepackagedPlugin.Manifest.Version)
if err != nil {
return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.invalid_version.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
}
marketplaceVersion, err := semver.StrictNewVersion(plugin.Manifest.Version)
if err != nil {
return nil, model.NewAppError("InstallMarketplacePlugin", "app.prepackged-plugin.invalid_version.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
if prepackagedVersion.LessThan(marketplaceVersion) { // Always true if no prepackaged plugin was found
logger.Debug("Found upgraded plugin from remote marketplace", mlog.String("version", plugin.Manifest.Version), mlog.String("download_url", plugin.DownloadURL))
downloadedPluginBytes, err := ch.srv.downloadFromURL(plugin.DownloadURL)
if err != nil {
return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.install_marketplace_plugin.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
signature, err := plugin.DecodeSignature()
if err != nil {
return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.signature_decode.app_error", nil, "", http.StatusNotImplemented).Wrap(err)
}
pluginFile = bytes.NewReader(downloadedPluginBytes)
signatureFile = signature
} else {
logger.Debug("Preferring pre-packaged plugin over version in remote marketplace", mlog.String("version", plugin.Manifest.Version), mlog.String("download_url", plugin.DownloadURL))
}
}
}
if pluginFile == nil {
return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.marketplace_plugins.not_found.app_error", nil, "", http.StatusInternalServerError)
}
if signatureFile == nil {
return nil, model.NewAppError("InstallMarketplacePlugin", "app.plugin.marketplace_plugins.signature_not_found.app_error", nil, "", http.StatusInternalServerError)
}
appErr = ch.verifyPlugin(logger, pluginFile, signatureFile)
if appErr != nil {
return nil, appErr
}
manifest, appErr := ch.installPlugin(pluginFile, signatureFile, installPluginLocallyAlways)
if appErr != nil {
return nil, appErr
}
return manifest, nil
}
type pluginInstallationStrategy int
const (
// installPluginLocallyOnlyIfNew installs the given plugin locally only if no plugin with the same id has been unpacked.
installPluginLocallyOnlyIfNew pluginInstallationStrategy = iota
// installPluginLocallyOnlyIfNewOrUpgrade installs the given plugin locally only if no plugin with the same id has been unpacked, or if such a plugin is older.
installPluginLocallyOnlyIfNewOrUpgrade
// installPluginLocallyAlways unconditionally installs the given plugin locally only, clobbering any existing plugin with the same id.
installPluginLocallyAlways
)
// 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 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)
}
tmpDir, err := os.MkdirTemp("", "plugintmp")
if err != nil {
return nil, model.NewAppError("installPluginLocally", "app.plugin.filesystem.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
defer os.RemoveAll(tmpDir)
manifest, pluginDir, appErr := extractPlugin(bundle, tmpDir)
if appErr != nil {
return nil, appErr
}
manifest, appErr = ch.installExtractedPlugin(manifest, pluginDir, installationStrategy)
if appErr != nil {
return nil, appErr
}
return manifest, nil
}
// extractPlugin unpacks the given plugin bundle into the specified directory.
func extractPlugin(bundle io.ReadSeeker, extractDir string) (*model.Manifest, string, *model.AppError) {
if _, err := bundle.Seek(0, 0); err != nil {
return nil, "", model.NewAppError("extractPlugin", "app.plugin.seek.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if err := extractTarGz(bundle, extractDir); err != nil {
return nil, "", model.NewAppError("extractPlugin", "app.plugin.extract.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
dir, err := os.ReadDir(extractDir)
if err != nil {
return nil, "", model.NewAppError("extractPlugin", "app.plugin.filesystem.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// If the root of the plugin bundle consists of exactly one directory, assume the plugin
// is contained therein. Otherwise the root directory is expected to contain the plugin.
if len(dir) == 1 && dir[0].IsDir() {
extractDir = filepath.Join(extractDir, dir[0].Name())
}
manifest, _, err := model.FindManifest(extractDir)
if err != nil {
return nil, "", model.NewAppError("extractPlugin", "app.plugin.manifest.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
if !model.IsValidPluginId(manifest.Id) {
return nil, "", model.NewAppError("extractPlugin", "app.plugin.invalid_id.app_error", map[string]any{"Min": model.MinIdLength, "Max": model.MaxIdLength, "Regex": model.ValidIdRegex}, "", http.StatusBadRequest)
}
return manifest, extractDir, nil
}
// installExtractedPlugin installs a plugin previously extracted to a temporary directory,
// activating the plugin automatically if already enabled by the server configuration.
//
// The given installation strategy decides how to handle upgrade scenarios.
func (ch *Channels) installExtractedPlugin(manifest *model.Manifest, fromPluginDir string, installationStrategy pluginInstallationStrategy) (*model.Manifest, *model.AppError) {
logger := ch.srv.Log().With(mlog.String("plugin_id", manifest.Id))
logger.Info("Installing extracted plugin", mlog.String("version", manifest.Version))
pluginsEnvironment := ch.GetPluginsEnvironment()
if pluginsEnvironment == nil {
return nil, model.NewAppError("installExtractedPlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented)
}
bundles, err := pluginsEnvironment.Available()
if err != nil {
return nil, model.NewAppError("installExtractedPlugin", "app.plugin.install.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Check for plugins installed with the same ID.
var existingManifest *model.Manifest
for _, bundle := range bundles {
if bundle.Manifest != nil && bundle.Manifest.Id == manifest.Id {
existingManifest = bundle.Manifest
break
}
}
if existingManifest != nil {
// Return an error if already installed and strategy disallows installation.
if installationStrategy == installPluginLocallyOnlyIfNew {
return nil, model.NewAppError("installExtractedPlugin", "app.plugin.install_id.app_error", nil, "", http.StatusBadRequest)
}
// Skip installation if already installed and newer.
if installationStrategy == installPluginLocallyOnlyIfNewOrUpgrade {
version, vErr := semver.StrictNewVersion(manifest.Version)
if vErr != nil {
return nil, model.NewAppError("installExtractedPlugin", "app.plugin.invalid_version.app_error", nil, "", http.StatusBadRequest).Wrap(vErr)
}
existingVersion, vErr := semver.StrictNewVersion(existingManifest.Version)
if vErr != nil {
return nil, model.NewAppError("installExtractedPlugin", "app.plugin.invalid_version.app_error", nil, "", http.StatusInternalServerError).Wrap(vErr)
}
if version.LessThanEqual(existingVersion) {
logger.Warn("Skipping local installation of plugin since not a newer version", mlog.String("version", version.String()), mlog.String("existing_version", existingVersion.String()))
return nil, model.NewAppError("installExtractedPlugin", "app.plugin.skip_installation.app_error", map[string]any{"Id": manifest.Id}, "", http.StatusInternalServerError)
}
}
// Otherwise remove the existing installation prior to installing below.
logger.Info("Removing existing installation of plugin before local install", mlog.String("existing_version", existingManifest.Version))
if err := ch.removePluginLocally(existingManifest.Id); err != nil {
return nil, model.NewAppError("installExtractedPlugin", "app.plugin.install_id_failed_remove.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
bundlePath := filepath.Join(*ch.cfgSvc.Config().PluginSettings.Directory, manifest.Id)
err = utils.CopyDir(fromPluginDir, bundlePath)
if err != nil {
return nil, model.NewAppError("installExtractedPlugin", "app.plugin.mvdir.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if manifest.HasWebapp() {
updatedManifest, err := pluginsEnvironment.UnpackWebappBundle(manifest.Id)
if err != nil {
return nil, model.NewAppError("installExtractedPlugin", "app.plugin.webapp_bundle.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
manifest = updatedManifest
}
// Activate the plugin if enabled.
pluginState := ch.cfgSvc.Config().PluginSettings.PluginStates[manifest.Id]
if pluginState != nil && pluginState.Enable {
if hasOverride, enabled := ch.getPluginStateOverride(manifest.Id); hasOverride && !enabled {
return manifest, nil
}
updatedManifest, _, err := pluginsEnvironment.Activate(manifest.Id)
if err != nil {
return nil, model.NewAppError("installExtractedPlugin", "app.plugin.restart.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
} else if updatedManifest == nil {
return nil, model.NewAppError("installExtractedPlugin", "app.plugin.restart.app_error", nil, "failed to activate plugin: plugin already active", http.StatusInternalServerError)
}
manifest = updatedManifest
}
return manifest, nil
}
// RemovePlugin removes a plugin from all servers.
func (ch *Channels) RemovePlugin(id string) *model.AppError {
logger := ch.srv.Log().With(mlog.String("plugin_id", id))
// Disable plugin before removal to make sure this
// plugin remains disabled on re-install.
if err := ch.disablePlugin(id); err != nil {
return err
}
if err := ch.removePluginLocally(id); err != nil {
return err
}
// Remove bundle from the file store.
bundlePath := getBundleStorePath(id)
bundleExists, err := ch.srv.fileExists(bundlePath)
if err != nil {
return model.NewAppError("removePlugin", "app.plugin.remove_bundle.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if !bundleExists {
return nil
}
if err := ch.srv.removeFile(bundlePath); err != nil {
return model.NewAppError("removePlugin", "app.plugin.remove_bundle.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if err := ch.removeSignature(id); err != nil {
logger.Warn("Can't remove signature", mlog.Err(err))
}
ch.notifyClusterPluginEvent(
model.ClusterEventRemovePlugin,
model.PluginEventData{
Id: id,
},
)
ch.notifyPluginStatusesChanged()
return nil
}
// removePluginLocally removes the given plugin from the current server.
func (ch *Channels) removePluginLocally(id string) *model.AppError {
pluginsEnvironment := ch.GetPluginsEnvironment()
if pluginsEnvironment == nil {
return model.NewAppError("removePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented)
}
plugins, err := pluginsEnvironment.Available()
if err != nil {
return model.NewAppError("removePlugin", "app.plugin.deactivate.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
var manifest *model.Manifest
var unpackedBundlePath string
for _, p := range plugins {
if p.Manifest != nil && p.Manifest.Id == id {
manifest = p.Manifest
unpackedBundlePath = filepath.Dir(p.ManifestPath)
break
}
}
if manifest == nil {
return model.NewAppError("removePlugin", "app.plugin.not_installed.app_error", nil, "", http.StatusNotFound)
}
pluginsEnvironment.Deactivate(id)
pluginsEnvironment.RemovePlugin(id)
ch.unregisterPluginCommands(id)
if err := os.RemoveAll(unpackedBundlePath); err != nil {
return model.NewAppError("removePlugin", "app.plugin.remove.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
// removeSignature removes the signature file installed alongside the plugin.
func (ch *Channels) removeSignature(pluginID string) *model.AppError {
logger := ch.srv.Log().With(mlog.String("plugin_id", pluginID))
signaturePath := getSignatureStorePath(pluginID)
exists, err := ch.srv.fileExists(signaturePath)
if err != nil {
return model.NewAppError("removeSignature", "app.plugin.remove_bundle.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if !exists {
logger.Debug("no plugin signature to remove")
return nil
}
if err = ch.srv.removeFile(signaturePath); err != nil {
return model.NewAppError("removeSignature", "app.plugin.remove_bundle.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
// getBundleStorePath maps the given plugin id to the file path of the corresponding plugin bundle.
func getBundleStorePath(id string) string {
return filepath.Join(fileStorePluginFolder, fmt.Sprintf("%s.tar.gz", id))
}
// getSignatureStorePath maps the given plugin id to the file path of the corresponding plugin
// signature, if one exists.
func getSignatureStorePath(id string) string {
return filepath.Join(fileStorePluginFolder, fmt.Sprintf("%s.tar.gz.sig", id))
}