snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
656
server/channels/app/plugin_install.go
Normal file
656
server/channels/app/plugin_install.go
Normal file
@@ -0,0 +1,656 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
func newPluginInstallConflictAppError(existingManifest, uploadedManifest *model.Manifest) *model.AppError {
|
||||
// Installing only validates the plugin id, so the uploaded name and homepage may be missing.
|
||||
// Fall back to the installed plugin's metadata to keep the plugin identifiable.
|
||||
name := uploadedManifest.Name
|
||||
if name == "" {
|
||||
name = existingManifest.Name
|
||||
}
|
||||
homepageURL := uploadedManifest.HomepageURL
|
||||
if homepageURL == "" {
|
||||
homepageURL = existingManifest.HomepageURL
|
||||
}
|
||||
|
||||
direction := model.PluginInstallConflictVersionDirection(existingManifest, uploadedManifest)
|
||||
|
||||
// The confirmation dialog reads this from AppError.Props, which is returned to clients even when
|
||||
// developer mode is off. Keep it to plugin metadata that is safe for any admin client to see.
|
||||
props := model.StringMap{
|
||||
model.PluginInstallConflictPropPluginID: uploadedManifest.Id,
|
||||
model.PluginInstallConflictPropPluginName: name,
|
||||
model.PluginInstallConflictPropHomepageURL: homepageURL,
|
||||
model.PluginInstallConflictPropExistingVersion: existingManifest.Version,
|
||||
model.PluginInstallConflictPropUploadedVersion: uploadedManifest.Version,
|
||||
model.PluginInstallConflictPropVersionDirection: direction,
|
||||
}
|
||||
|
||||
// DetailedError is only for the server log; the client reads Props instead.
|
||||
details := fmt.Sprintf("plugin %q conflict: existing version %q, uploaded version %q (%s)", uploadedManifest.Id, existingManifest.Version, uploadedManifest.Version, direction)
|
||||
|
||||
appErr := model.NewAppError("installExtractedPlugin", "app.plugin.install_id.app_error", nil, details, http.StatusBadRequest)
|
||||
appErr.Props = props
|
||||
|
||||
return appErr
|
||||
}
|
||||
|
||||
// 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, newPluginInstallConflictAppError(existingManifest, manifest)
|
||||
}
|
||||
|
||||
// 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); 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))
|
||||
}
|
||||
Reference in New Issue
Block a user