Unverified Commit 847c181e authored by Jesse Hallam's avatar Jesse Hallam Committed by GitHub

MM-8622: Improved plugin error reporting (#8737)

* allow `Wait()`ing on the supervisor

In the event the plugin supervisor shuts down a plugin for crashing too
many times, the new `Wait()` interface allows the `ActivatePlugin` to
accept a callback function to trigger when `supervisor.Wait()` returns.
If the supervisor shuts down normally, this callback is invoked with
a nil error, otherwise any error reported by the supervisor is passed
along.

* improve plugin activation/deactivation logic

Avoid triggering activation of previously failed-to-start plugins just
becase something in the configuration changed. Now, intelligently
compare the global enable bit as well as the each individual plugin's
enabled bit.

* expose store to manipulate PluginStatuses

* expose API to fetch plugin statuses

* keep track of whether or not plugin sandboxing is supported

* transition plugin statuses

* restore error on plugin activation if already active

* don't initialize test plugins until successfully loaded

* emit websocket events when plugin statuses change

* skip pruning if already initialized

* MM-8622: maintain plugin statuses in memory

Switch away from persisting plugin statuses to the database, and
maintain in memory instead. This will be followed by a cluster interface
to query the in-memory status of plugin statuses from all cluster nodes.

At the same time, rename `cluster_discovery_id` on the `PluginStatus`
model object to `cluster_id`.

* MM-8622: aggregate plugin statuses across cluster

* fetch cluster plugin statuses when emitting websocket notification

* address unit test fixes after rebasing

* relax (poor) racey unit test re: supervisor.Wait()

* make store-mocks
parent 5c21bdc1
......@@ -23,6 +23,7 @@ func (api *API) InitPlugin() {
api.BaseRoutes.Plugins.Handle("", api.ApiSessionRequired(getPlugins)).Methods("GET")
api.BaseRoutes.Plugin.Handle("", api.ApiSessionRequired(removePlugin)).Methods("DELETE")
api.BaseRoutes.Plugins.Handle("/statuses", api.ApiSessionRequired(getPluginStatuses)).Methods("GET")
api.BaseRoutes.Plugin.Handle("/activate", api.ApiSessionRequired(activatePlugin)).Methods("POST")
api.BaseRoutes.Plugin.Handle("/deactivate", api.ApiSessionRequired(deactivatePlugin)).Methods("POST")
......@@ -97,6 +98,26 @@ func getPlugins(c *Context, w http.ResponseWriter, r *http.Request) {
w.Write([]byte(response.ToJson()))
}
func getPluginStatuses(c *Context, w http.ResponseWriter, r *http.Request) {
if !*c.App.Config().PluginSettings.Enable {
c.Err = model.NewAppError("getPluginStatuses", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
if !c.App.SessionHasPermissionTo(c.Session, model.PERMISSION_MANAGE_SYSTEM) {
c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM)
return
}
response, err := c.App.GetClusterPluginStatuses()
if err != nil {
c.Err = err
return
}
w.Write([]byte(response.ToJson()))
}
func removePlugin(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePluginId()
if c.Err != nil {
......@@ -104,7 +125,7 @@ func removePlugin(c *Context, w http.ResponseWriter, r *http.Request) {
}
if !*c.App.Config().PluginSettings.Enable {
c.Err = model.NewAppError("getPlugins", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented)
c.Err = model.NewAppError("removePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
......
......@@ -38,8 +38,10 @@ type App struct {
Log *mlog.Logger
PluginEnv *pluginenv.Environment
PluginConfigListenerId string
PluginEnv *pluginenv.Environment
PluginConfigListenerId string
IsPluginSandboxSupported bool
pluginStatuses map[string]*model.PluginStatus
EmailBatching *EmailBatchingJob
......
......@@ -336,6 +336,10 @@ func (s *mockPluginSupervisor) Start(api plugin.API) error {
return s.hooks.OnActivate(api)
}
func (s *mockPluginSupervisor) Wait() error {
return nil
}
func (s *mockPluginSupervisor) Stop() error {
return nil
}
......@@ -353,17 +357,6 @@ func (me *TestHelper) InstallPlugin(manifest *model.Manifest, hooks plugin.Hooks
me.tempWorkspace = dir
}
pluginDir := filepath.Join(me.tempWorkspace, "plugins")
webappDir := filepath.Join(me.tempWorkspace, "webapp")
me.App.InitPlugins(pluginDir, webappDir, func(bundle *model.BundleInfo) (plugin.Supervisor, error) {
if hooks, ok := me.pluginHooks[bundle.Manifest.Id]; ok {
return &mockPluginSupervisor{hooks}, nil
}
return pluginenv.DefaultSupervisorProvider(bundle)
})
me.pluginHooks[manifest.Id] = hooks
manifestCopy := *manifest
if manifestCopy.Backend == nil {
manifestCopy.Backend = &model.ManifestBackend{}
......@@ -373,6 +366,9 @@ func (me *TestHelper) InstallPlugin(manifest *model.Manifest, hooks plugin.Hooks
panic(err)
}
pluginDir := filepath.Join(me.tempWorkspace, "plugins")
webappDir := filepath.Join(me.tempWorkspace, "webapp")
if err := os.MkdirAll(filepath.Join(pluginDir, manifest.Id), 0700); err != nil {
panic(err)
}
......@@ -380,6 +376,15 @@ func (me *TestHelper) InstallPlugin(manifest *model.Manifest, hooks plugin.Hooks
if err := ioutil.WriteFile(filepath.Join(pluginDir, manifest.Id, "plugin.json"), manifestBytes, 0600); err != nil {
panic(err)
}
me.App.InitPlugins(pluginDir, webappDir, func(bundle *model.BundleInfo) (plugin.Supervisor, error) {
if hooks, ok := me.pluginHooks[bundle.Manifest.Id]; ok {
return &mockPluginSupervisor{hooks}, nil
}
return pluginenv.DefaultSupervisorProvider(bundle)
})
me.pluginHooks[manifest.Id] = hooks
}
func (me *TestHelper) ResetRoleMigration() {
......@@ -415,6 +420,9 @@ func (me *FakeClusterInterface) GetClusterStats() ([]*model.ClusterStats, *model
func (me *FakeClusterInterface) GetLogs(page, perPage int) ([]string, *model.AppError) {
return []string{}, nil
}
func (me *FakeClusterInterface) GetPluginStatuses() (model.PluginStatuses, *model.AppError) {
return nil, nil
}
func (me *FakeClusterInterface) ConfigChanged(previousConfig *model.Config, newConfig *model.Config, sendToOtherServer bool) *model.AppError {
return nil
}
......
......@@ -85,3 +85,11 @@ func (a *App) IsLeader() bool {
return true
}
}
func (a *App) GetClusterId() string {
if a.Cluster == nil {
return ""
}
return a.Cluster.GetClusterId()
}
......@@ -37,6 +37,31 @@ var prepackagedPlugins map[string]func(string) ([]byte, error) = map[string]func
"zoom": zoom.Asset,
}
func (a *App) notifyPluginStatusesChanged() error {
pluginStatuses, err := a.GetClusterPluginStatuses()
if err != nil {
return err
}
// Notify any system admins.
message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PLUGIN_STATUSES_CHANGED, "", "", "", nil)
message.Add("plugin_statuses", pluginStatuses)
message.Broadcast.ContainsSensitiveData = true
a.Publish(message)
return nil
}
func (a *App) setPluginStatusState(id string, state int) error {
if _, ok := a.pluginStatuses[id]; !ok {
return nil
}
a.pluginStatuses[id].State = state
return a.notifyPluginStatusesChanged()
}
func (a *App) initBuiltInPlugins() {
plugins := map[string]builtinplugin.Plugin{
"ldapextras": &ldapextras.Plugin{},
......@@ -77,30 +102,100 @@ func (a *App) setPluginsActive(activate bool) {
continue
}
id := plugin.Manifest.Id
enabled := false
if state, ok := a.Config().PluginSettings.PluginStates[plugin.Manifest.Id]; ok {
enabled = state.Enable
}
a.pluginStatuses[plugin.Manifest.Id] = &model.PluginStatus{
ClusterId: a.GetClusterId(),
PluginId: plugin.Manifest.Id,
PluginPath: filepath.Dir(plugin.ManifestPath),
IsSandboxed: a.IsPluginSandboxSupported,
Name: plugin.Manifest.Name,
Description: plugin.Manifest.Description,
Version: plugin.Manifest.Version,
}
if activate && enabled {
a.setPluginActive(plugin, activate)
} else if !activate {
a.setPluginActive(plugin, activate)
}
}
if err := a.notifyPluginStatusesChanged(); err != nil {
mlog.Error("failed to notify plugin status changed", mlog.Err(err))
}
}
func (a *App) setPluginActiveById(id string, activate bool) {
plugins, err := a.PluginEnv.Plugins()
if err != nil {
mlog.Error(fmt.Sprintf("Cannot setPluginActiveById(%t)", activate), mlog.String("plugin_id", id), mlog.Err(err))
return
}
pluginState := &model.PluginState{Enable: false}
if state, ok := a.Config().PluginSettings.PluginStates[id]; ok {
pluginState = state
for _, plugin := range plugins {
if plugin.Manifest != nil && plugin.Manifest.Id == id {
a.setPluginActive(plugin, activate)
}
}
}
func (a *App) setPluginActive(plugin *model.BundleInfo, activate bool) {
if plugin.Manifest == nil {
return
}
active := a.PluginEnv.IsPluginActive(id)
id := plugin.Manifest.Id
if activate && pluginState.Enable && !active {
active := a.PluginEnv.IsPluginActive(id)
if activate {
if !active {
if err := a.activatePlugin(plugin.Manifest); err != nil {
mlog.Error("Plugin failed to activate", mlog.String("plugin_id", plugin.Manifest.Id), mlog.String("err", err.DetailedError))
}
}
} else if (!activate || !pluginState.Enable) && active {
} else if !activate {
if active {
if err := a.deactivatePlugin(plugin.Manifest); err != nil {
mlog.Error("Plugin failed to deactivate", mlog.String("plugin_id", plugin.Manifest.Id), mlog.String("err", err.DetailedError))
}
} else {
if err := a.setPluginStatusState(plugin.Manifest.Id, model.PluginStateNotRunning); err != nil {
mlog.Error("Plugin status state failed to update", mlog.String("plugin_id", plugin.Manifest.Id), mlog.String("err", err.Error()))
}
}
}
}
func (a *App) activatePlugin(manifest *model.Manifest) *model.AppError {
if err := a.PluginEnv.ActivatePlugin(manifest.Id); err != nil {
mlog.Debug("Activating plugin", mlog.String("plugin_id", manifest.Id))
if err := a.setPluginStatusState(manifest.Id, model.PluginStateStarting); err != nil {
return model.NewAppError("activatePlugin", "app.plugin.set_plugin_status_state.app_error", nil, err.Error(), http.StatusInternalServerError)
}
onError := func(err error) {
mlog.Debug("Plugin failed to stay running", mlog.String("plugin_id", manifest.Id), mlog.Err(err))
if err := a.setPluginStatusState(manifest.Id, model.PluginStateFailedToStayRunning); err != nil {
mlog.Error("Failed to record plugin status", mlog.String("plugin_id", manifest.Id), mlog.Err(err))
}
}
if err := a.PluginEnv.ActivatePlugin(manifest.Id, onError); err != nil {
if err := a.setPluginStatusState(manifest.Id, model.PluginStateFailedToStart); err != nil {
return model.NewAppError("activatePlugin", "app.plugin.activate.app_error", nil, err.Error(), http.StatusInternalServerError)
}
return model.NewAppError("activatePlugin", "app.plugin.activate.app_error", nil, err.Error(), http.StatusBadRequest)
}
if err := a.setPluginStatusState(manifest.Id, model.PluginStateRunning); err != nil {
return model.NewAppError("activatePlugin", "app.plugin.activate.app_error", nil, err.Error(), http.StatusBadRequest)
}
......@@ -115,6 +210,12 @@ func (a *App) activatePlugin(manifest *model.Manifest) *model.AppError {
}
func (a *App) deactivatePlugin(manifest *model.Manifest) *model.AppError {
mlog.Debug("Deactivating plugin", mlog.String("plugin_id", manifest.Id))
if err := a.setPluginStatusState(manifest.Id, model.PluginStateStopping); err != nil {
return model.NewAppError("EnablePlugin", "app.plugin.deactivate.app_error", nil, err.Error(), http.StatusInternalServerError)
}
if err := a.PluginEnv.DeactivatePlugin(manifest.Id); err != nil {
return model.NewAppError("deactivatePlugin", "app.plugin.deactivate.app_error", nil, err.Error(), http.StatusBadRequest)
}
......@@ -127,6 +228,10 @@ func (a *App) deactivatePlugin(manifest *model.Manifest) *model.AppError {
a.Publish(message)
}
if err := a.setPluginStatusState(manifest.Id, model.PluginStateNotRunning); err != nil {
return model.NewAppError("deactivatePlugin", "app.plugin.deactivate.app_error", nil, err.Error(), http.StatusBadRequest)
}
mlog.Info("Deactivated plugin", mlog.String("plugin_id", manifest.Id))
return nil
}
......@@ -166,7 +271,8 @@ func (a *App) installPlugin(pluginFile io.Reader, allowPrepackaged bool) (*model
return nil, model.NewAppError("installPlugin", "app.plugin.manifest.app_error", nil, err.Error(), http.StatusBadRequest)
}
if _, ok := prepackagedPlugins[manifest.Id]; ok && !allowPrepackaged {
_, isPrepackaged := prepackagedPlugins[manifest.Id]
if isPrepackaged && !allowPrepackaged {
return nil, model.NewAppError("installPlugin", "app.plugin.prepackaged.app_error", nil, "", http.StatusBadRequest)
}
......@@ -185,16 +291,33 @@ func (a *App) installPlugin(pluginFile io.Reader, allowPrepackaged bool) (*model
}
}
err = utils.CopyDir(tmpPluginDir, filepath.Join(a.PluginEnv.SearchPath(), manifest.Id))
pluginPath := filepath.Join(a.PluginEnv.SearchPath(), manifest.Id)
err = utils.CopyDir(tmpPluginDir, pluginPath)
if err != nil {
return nil, model.NewAppError("installPlugin", "app.plugin.mvdir.app_error", nil, err.Error(), http.StatusInternalServerError)
}
// Should add manifest validation and error handling here
a.pluginStatuses[manifest.Id] = &model.PluginStatus{
ClusterId: a.GetClusterId(),
PluginId: manifest.Id,
PluginPath: pluginPath,
State: model.PluginStateNotRunning,
IsSandboxed: a.IsPluginSandboxSupported,
IsPrepackaged: isPrepackaged,
Name: manifest.Name,
Description: manifest.Description,
Version: manifest.Version,
}
if err := a.notifyPluginStatusesChanged(); err != nil {
mlog.Error("failed to notify plugin status changed", mlog.Err(err))
}
return manifest, nil
}
// GetPlugins returned the plugins installed on this server, including the manifests needed to
// enable plugins with web functionality.
func (a *App) GetPlugins() (*model.PluginsResponse, *model.AppError) {
if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable {
return nil, model.NewAppError("GetPlugins", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented)
......@@ -240,6 +363,39 @@ func (a *App) GetActivePluginManifests() ([]*model.Manifest, *model.AppError) {
return manifests, nil
}
// GetPluginStatuses returns the status for plugins installed on this server.
func (a *App) GetPluginStatuses() (model.PluginStatuses, *model.AppError) {
if !*a.Config().PluginSettings.Enable {
return nil, model.NewAppError("GetPluginStatuses", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented)
}
pluginStatuses := make([]*model.PluginStatus, 0, len(a.pluginStatuses))
for _, pluginStatus := range a.pluginStatuses {
pluginStatuses = append(pluginStatuses, pluginStatus)
}
return pluginStatuses, nil
}
// GetClusterPluginStatuses returns the status for plugins installed anywhere in the cluster.
func (a *App) GetClusterPluginStatuses() (model.PluginStatuses, *model.AppError) {
pluginStatuses, err := a.GetPluginStatuses()
if err != nil {
return nil, err
}
if a.Cluster != nil && *a.Config().ClusterSettings.Enable {
clusterPluginStatuses, err := a.Cluster.GetPluginStatuses()
if err != nil {
return nil, model.NewAppError("GetClusterPluginStatuses", "app.plugin.get_cluster_plugin_statuses.app_error", nil, err.Error(), http.StatusInternalServerError)
}
pluginStatuses = append(pluginStatuses, clusterPluginStatuses...)
}
return pluginStatuses, nil
}
func (a *App) RemovePlugin(id string) *model.AppError {
return a.removePlugin(id, false)
}
......@@ -284,10 +440,16 @@ func (a *App) removePlugin(id string, allowPrepackaged bool) *model.AppError {
return model.NewAppError("removePlugin", "app.plugin.remove.app_error", nil, err.Error(), http.StatusInternalServerError)
}
delete(a.pluginStatuses, manifest.Id)
if err := a.notifyPluginStatusesChanged(); err != nil {
mlog.Error("failed to notify plugin status changed", mlog.Err(err))
}
return nil
}
// EnablePlugin will set the config for an installed plugin to enabled, triggering activation if inactive.
// EnablePlugin will set the config for an installed plugin to enabled, triggering asynchronous
// activation if inactive anywhere in the cluster.
func (a *App) EnablePlugin(id string) *model.AppError {
if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable {
return model.NewAppError("EnablePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented)
......@@ -310,8 +472,8 @@ func (a *App) EnablePlugin(id string) *model.AppError {
return model.NewAppError("EnablePlugin", "app.plugin.not_installed.app_error", nil, "", http.StatusBadRequest)
}
if err := a.activatePlugin(manifest); err != nil {
return err
if err := a.setPluginStatusState(manifest.Id, model.PluginStateStarting); err != nil {
return model.NewAppError("EnablePlugin", "app.plugin.set_plugin_status_state.app_error", nil, err.Error(), http.StatusInternalServerError)
}
a.UpdateConfig(func(cfg *model.Config) {
......@@ -351,6 +513,10 @@ func (a *App) DisablePlugin(id string) *model.AppError {
return model.NewAppError("DisablePlugin", "app.plugin.not_installed.app_error", nil, "", http.StatusBadRequest)
}
if err := a.setPluginStatusState(manifest.Id, model.PluginStateStopping); err != nil {
return model.NewAppError("EnablePlugin", "app.plugin.set_plugin_status_state.app_error", nil, err.Error(), http.StatusInternalServerError)
}
a.UpdateConfig(func(cfg *model.Config) {
cfg.PluginSettings.PluginStates[id] = &model.PluginState{Enable: false}
})
......@@ -363,16 +529,18 @@ func (a *App) DisablePlugin(id string) *model.AppError {
}
func (a *App) InitPlugins(pluginPath, webappPath string, supervisorOverride pluginenv.SupervisorProviderFunc) {
if !*a.Config().PluginSettings.Enable {
if a.PluginEnv != nil {
return
}
if a.PluginEnv != nil {
if !*a.Config().PluginSettings.Enable {
return
}
mlog.Info("Starting up plugins")
a.pluginStatuses = make(map[string]*model.PluginStatus)
if err := os.Mkdir(pluginPath, 0744); err != nil && !os.IsExist(err) {
mlog.Error("Failed to start up plugins", mlog.Err(err))
return
......@@ -398,13 +566,19 @@ func (a *App) InitPlugins(pluginPath, webappPath string, supervisorOverride plug
}),
}
if supervisorOverride != nil {
options = append(options, pluginenv.SupervisorProvider(supervisorOverride))
} else if err := sandbox.CheckSupport(); err != nil {
if err := sandbox.CheckSupport(); err != nil {
a.IsPluginSandboxSupported = false
mlog.Warn("plugin sandboxing is not supported. plugins will run with the same access level as the server. See documentation to learn more: https://developers.mattermost.com/extend/plugins/security/", mlog.Err(err))
options = append(options, pluginenv.SupervisorProvider(rpcplugin.SupervisorProvider))
} else {
a.IsPluginSandboxSupported = true
}
if supervisorOverride != nil {
options = append(options, pluginenv.SupervisorProvider(supervisorOverride))
} else if a.IsPluginSandboxSupported {
options = append(options, pluginenv.SupervisorProvider(sandbox.SupervisorProvider))
} else {
options = append(options, pluginenv.SupervisorProvider(rpcplugin.SupervisorProvider))
}
if env, err := pluginenv.New(options...); err != nil {
......@@ -431,12 +605,34 @@ func (a *App) InitPlugins(pluginPath, webappPath string, supervisorOverride plug
}
a.RemoveConfigListener(a.PluginConfigListenerId)
a.PluginConfigListenerId = a.AddConfigListener(func(_, cfg *model.Config) {
a.PluginConfigListenerId = a.AddConfigListener(func(oldCfg *model.Config, cfg *model.Config) {
if a.PluginEnv == nil {
return
}
a.setPluginsActive(*cfg.PluginSettings.Enable)
if *oldCfg.PluginSettings.Enable != *cfg.PluginSettings.Enable {
a.setPluginsActive(*cfg.PluginSettings.Enable)
} else {
plugins := map[string]bool{}
for id := range oldCfg.PluginSettings.PluginStates {
plugins[id] = true
}
for id := range cfg.PluginSettings.PluginStates {
plugins[id] = true
}
for id := range plugins {
oldPluginState := oldCfg.PluginSettings.PluginStates[id]
pluginState := cfg.PluginSettings.PluginStates[id]
wasEnabled := oldPluginState != nil && oldPluginState.Enable
isEnabled := pluginState != nil && pluginState.Enable
if wasEnabled != isEnabled {
a.setPluginActiveById(id, isEnabled)
}
}
}
for _, err := range a.PluginEnv.Hooks().OnConfigurationChange() {
mlog.Error(err.Error())
......
......@@ -7,8 +7,8 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
......@@ -158,6 +158,20 @@ func TestPluginCommands(t *testing.T) {
require.Nil(t, th.App.EnablePlugin("foo"))
// Ideally, we would wait for the websocket activation event instead of just sleeping.
time.Sleep(500 * time.Millisecond)
pluginStatuses, err := th.App.GetPluginStatuses()
require.Nil(t, err)
found := false
for _, pluginStatus := range pluginStatuses {
if pluginStatus.PluginId == "foo" {
require.Equal(t, model.PluginStateRunning, pluginStatus.State)
found = true
}
}
require.True(t, found, "failed to find plugin foo in plugin statuses")
resp, err := th.App.ExecuteCommand(&model.CommandArgs{
Command: "/foo2",
TeamId: th.BasicTeam.Id,
......@@ -216,7 +230,46 @@ func TestPluginBadActivation(t *testing.T) {
t.Run("EnablePlugin bad activation", func(t *testing.T) {
err := th.App.EnablePlugin("foo")
assert.NotNil(t, err)
assert.True(t, strings.Contains(err.DetailedError, "won't activate for some reason"))
assert.Nil(t, err)
// Ideally, we would wait for the websocket activation event instead of just
// sleeping.
time.Sleep(500 * time.Millisecond)
pluginStatuses, err := th.App.GetPluginStatuses()
require.Nil(t, err)
found := false
for _, pluginStatus := range pluginStatuses {
if pluginStatus.PluginId == "foo" {
require.Equal(t, model.PluginStateFailedToStart, pluginStatus.State)
found = true
}
}
require.True(t, found, "failed to find plugin foo in plugin statuses")
})
}
func TestGetPluginStatusesDisabled(t *testing.T) {
th := Setup().InitBasic()
defer th.TearDown()
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = false
})
_, err := th.App.GetPluginStatuses()
require.EqualError(t, err, "GetPluginStatuses: Plugins have been disabled. Please check your logs for details., ")
}
func TestGetPluginStatuses(t *testing.T) {
th := Setup().InitBasic()
defer th.TearDown()
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.Enable = true
})
pluginStatuses, err := th.App.GetPluginStatuses()
require.Nil(t, err)
require.NotNil(t, pluginStatuses)
}
......@@ -21,5 +21,6 @@ type ClusterInterface interface {
NotifyMsg(buf []byte)
GetClusterStats() ([]*model.ClusterStats, *model.AppError)
GetLogs(page, perPage int) ([]string, *model.AppError)
GetPluginStatuses() (model.PluginStatuses, *model.AppError)
ConfigChanged(previousConfig *model.Config, newConfig *model.Config, sendToOtherServer bool) *model.AppError
}
......@@ -3854,6 +3854,10 @@
"id": "app.plugin.deactivate.app_error",
"translation": "Unable to deactivate plugin"
},
{
"id": "app.plugin.delete_plugin_status_state.app_error",
"translation": "Unable to delete plugin status state."
},
{
"id": "app.plugin.disabled.app_error",
"translation": "Plugins have been disabled. Please check your logs for details."
......@@ -3898,10 +3902,18 @@
"id": "app.plugin.not_installed.app_error",
"translation": "Plugin is not installed"
},
{
"id": "app.plugin.prepackaged.app_error",
"translation": "Cannot install prepackaged plugin"
},
{
"id": "app.plugin.remove.app_error",
"translation": "Unable to delete plugin"
},
{
"id": "app.plugin.set_plugin_status_state.app_error",
"translation": "Unable to set plugin status state."
},
{
"id": "app.plugin.upload_disabled.app_error",
"translation": "Plugins and/or plugin uploads have been disabled."
......@@ -4798,6 +4810,10 @@
"id": "model.client.writer.app_error",
"translation": "Unable to build multipart request"
},
{
"id": "model.cluster.is_valid.id.app_error",
"translation": "Invalid Id"
},
{
"id": "model.command.is_valid.create_at.app_error",
"translation": "Create at must be a valid time"
......
......@@ -3534,6 +3534,18 @@ func (c *Client4) GetPlugins() (*PluginsResponse, *Response) {
}
}