From 16b845c0d77535ea306339f7a8bd22fc72f8a3c5 Mon Sep 17 00:00:00 2001 From: Joram Wilander Date: Wed, 25 Oct 2017 08:17:17 -0400 Subject: Differentiate between installed and activated states for plugins (#7706) --- api4/plugin.go | 57 ++++++++++- api4/plugin_test.go | 73 ++++++++++++-- app/plugins.go | 180 ++++++++++++++++++++++++++++++----- cmd/platform/server.go | 8 -- i18n/en.json | 12 +++ model/client4.go | 26 ++++- model/config.go | 13 ++- model/plugins_response.go | 31 ++++++ model/plugins_response_test.go | 31 ++++++ plugin/pluginenv/environment.go | 14 +++ plugin/pluginenv/environment_test.go | 2 + 11 files changed, 402 insertions(+), 45 deletions(-) create mode 100644 model/plugins_response.go create mode 100644 model/plugins_response_test.go diff --git a/api4/plugin.go b/api4/plugin.go index 2045a35a8..c1ee986da 100644 --- a/api4/plugin.go +++ b/api4/plugin.go @@ -24,6 +24,9 @@ func (api *API) InitPlugin() { api.BaseRoutes.Plugins.Handle("", api.ApiSessionRequired(getPlugins)).Methods("GET") api.BaseRoutes.Plugin.Handle("", api.ApiSessionRequired(removePlugin)).Methods("DELETE") + api.BaseRoutes.Plugin.Handle("/activate", api.ApiSessionRequired(activatePlugin)).Methods("POST") + api.BaseRoutes.Plugin.Handle("/deactivate", api.ApiSessionRequired(deactivatePlugin)).Methods("POST") + api.BaseRoutes.Plugins.Handle("/webapp", api.ApiHandler(getWebappPlugins)).Methods("GET") } @@ -64,7 +67,7 @@ func uploadPlugin(c *Context, w http.ResponseWriter, r *http.Request) { } defer file.Close() - manifest, unpackErr := c.App.UnpackAndActivatePlugin(file) + manifest, unpackErr := c.App.InstallPlugin(file) if unpackErr != nil { c.Err = unpackErr @@ -86,13 +89,13 @@ func getPlugins(c *Context, w http.ResponseWriter, r *http.Request) { return } - manifests, err := c.App.GetActivePluginManifests() + response, err := c.App.GetPluginManifests() if err != nil { c.Err = err return } - w.Write([]byte(model.ManifestListToJson(manifests))) + w.Write([]byte(response.ToJson())) } func removePlugin(c *Context, w http.ResponseWriter, r *http.Request) { @@ -141,3 +144,51 @@ func getWebappPlugins(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.ManifestListToJson(clientManifests))) } + +func activatePlugin(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequirePluginId() + if c.Err != nil { + return + } + + if !*c.App.Config().PluginSettings.Enable { + c.Err = model.NewAppError("activatePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + if !app.SessionHasPermissionTo(c.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + if err := c.App.EnablePlugin(c.Params.PluginId); err != nil { + c.Err = err + return + } + + ReturnStatusOK(w) +} + +func deactivatePlugin(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequirePluginId() + if c.Err != nil { + return + } + + if !*c.App.Config().PluginSettings.Enable { + c.Err = model.NewAppError("deactivatePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + if !app.SessionHasPermissionTo(c.Session, model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + if err := c.App.DisablePlugin(c.Params.PluginId); err != nil { + c.Err = err + return + } + + ReturnStatusOK(w) +} diff --git a/api4/plugin_test.go b/api4/plugin_test.go index 1feb1b06a..a3f3bd49a 100644 --- a/api4/plugin_test.go +++ b/api4/plugin_test.go @@ -65,12 +65,43 @@ func TestPlugin(t *testing.T) { _, resp = th.Client.UploadPlugin(file) CheckForbiddenStatus(t, resp) - // Successful get - manifests, resp := th.SystemAdminClient.GetPlugins() + // Successful gets + pluginsResp, resp := th.SystemAdminClient.GetPlugins() CheckNoError(t, resp) found := false - for _, m := range manifests { + for _, m := range pluginsResp.Inactive { + if m.Id == manifest.Id { + found = true + } + } + + assert.True(t, found) + + found = false + for _, m := range pluginsResp.Active { + if m.Id == manifest.Id { + found = true + } + } + + assert.False(t, found) + + states := th.App.Config().PluginSettings.PluginStates + defer func() { + th.App.UpdateConfig(func(cfg *model.Config) { cfg.PluginSettings.PluginStates = states }) + }() + + // Successful activate + ok, resp := th.SystemAdminClient.ActivatePlugin(manifest.Id) + CheckNoError(t, resp) + assert.True(t, ok) + + pluginsResp, resp = th.SystemAdminClient.GetPlugins() + CheckNoError(t, resp) + + found = false + for _, m := range pluginsResp.Active { if m.Id == manifest.Id { found = true } @@ -78,6 +109,33 @@ func TestPlugin(t *testing.T) { assert.True(t, found) + // Activate error case + ok, resp = th.SystemAdminClient.ActivatePlugin("junk") + CheckBadRequestStatus(t, resp) + assert.False(t, ok) + + // Successful deactivate + ok, resp = th.SystemAdminClient.DeactivatePlugin(manifest.Id) + CheckNoError(t, resp) + assert.True(t, ok) + + pluginsResp, resp = th.SystemAdminClient.GetPlugins() + CheckNoError(t, resp) + + found = false + for _, m := range pluginsResp.Inactive { + if m.Id == manifest.Id { + found = true + } + } + + assert.True(t, found) + + // Deactivate error case + ok, resp = th.SystemAdminClient.DeactivatePlugin("junk") + CheckBadRequestStatus(t, resp) + assert.False(t, ok) + // Get error cases th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = false }) _, resp = th.SystemAdminClient.GetPlugins() @@ -88,7 +146,10 @@ func TestPlugin(t *testing.T) { CheckForbiddenStatus(t, resp) // Successful webapp get - manifests, resp = th.Client.GetWebappPlugins() + _, resp = th.SystemAdminClient.ActivatePlugin(manifest.Id) + CheckNoError(t, resp) + + manifests, resp := th.Client.GetWebappPlugins() CheckNoError(t, resp) found = false @@ -101,15 +162,13 @@ func TestPlugin(t *testing.T) { assert.True(t, found) // Successful remove - ok, resp := th.SystemAdminClient.RemovePlugin(manifest.Id) + ok, resp = th.SystemAdminClient.RemovePlugin(manifest.Id) CheckNoError(t, resp) - assert.True(t, ok) // Remove error cases ok, resp = th.SystemAdminClient.RemovePlugin(manifest.Id) CheckBadRequestStatus(t, resp) - assert.False(t, ok) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = false }) diff --git a/app/plugins.go b/app/plugins.go index ca3fe610c..59c9e4c25 100644 --- a/app/plugins.go +++ b/app/plugins.go @@ -273,6 +273,8 @@ func (a *App) InitBuiltInPlugins() { } } +// ActivatePlugins will activate any plugins enabled in the config +// and deactivate all other plugins. func (a *App) ActivatePlugins() { if a.PluginEnv == nil { l4g.Error("plugin env not initialized") @@ -281,20 +283,52 @@ func (a *App) ActivatePlugins() { plugins, err := a.PluginEnv.Plugins() if err != nil { - l4g.Error("failed to start up plugins: " + err.Error()) + l4g.Error("failed to activate plugins: " + err.Error()) return } for _, plugin := range plugins { - err := a.PluginEnv.ActivatePlugin(plugin.Manifest.Id) - if err != nil { - l4g.Error(err.Error()) + id := plugin.Manifest.Id + + pluginState := &model.PluginState{Enable: false} + if state, ok := a.Config().PluginSettings.PluginStates[id]; ok { + pluginState = state + } + + active := a.PluginEnv.IsPluginActive(id) + + if pluginState.Enable && !active { + if err := a.PluginEnv.ActivatePlugin(id); err != nil { + l4g.Error(err.Error()) + continue + } + + if plugin.Manifest.HasClient() { + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PLUGIN_ACTIVATED, "", "", "", nil) + message.Add("manifest", plugin.Manifest.ClientManifest()) + a.Publish(message) + } + + l4g.Info("Activated %v plugin", id) + } else if !pluginState.Enable && active { + if err := a.PluginEnv.DeactivatePlugin(id); err != nil { + l4g.Error(err.Error()) + continue + } + + if plugin.Manifest.HasClient() { + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PLUGIN_DEACTIVATED, "", "", "", nil) + message.Add("manifest", plugin.Manifest.ClientManifest()) + a.Publish(message) + } + + l4g.Info("Deactivated %v plugin", id) } - l4g.Info("Activated %v plugin", plugin.Manifest.Id) } } -func (a *App) UnpackAndActivatePlugin(pluginFile io.Reader) (*model.Manifest, *model.AppError) { +// InstallPlugin unpacks and installs a plugin but does not activate it. +func (a *App) InstallPlugin(pluginFile io.Reader) (*model.Manifest, *model.AppError) { if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable { return nil, model.NewAppError("UnpackAndActivatePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } @@ -331,18 +365,29 @@ func (a *App) UnpackAndActivatePlugin(pluginFile io.Reader) (*model.Manifest, *m // Should add manifest validation and error handling here - err = a.PluginEnv.ActivatePlugin(manifest.Id) + return manifest, nil +} + +func (a *App) GetPluginManifests() (*model.PluginsResponse, *model.AppError) { + if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable { + return nil, model.NewAppError("GetPluginManifests", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) + } + + plugins, err := a.PluginEnv.Plugins() if err != nil { - return nil, model.NewAppError("UnpackAndActivatePlugin", "app.plugin.activate.app_error", nil, err.Error(), http.StatusBadRequest) + return nil, model.NewAppError("GetPluginManifests", "app.plugin.get_plugins.app_error", nil, err.Error(), http.StatusInternalServerError) } - if manifest.HasClient() { - message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PLUGIN_ACTIVATED, "", "", "", nil) - message.Add("manifest", manifest.ClientManifest()) - a.Publish(message) + resp := &model.PluginsResponse{Active: []*model.Manifest{}, Inactive: []*model.Manifest{}} + for _, plugin := range plugins { + if a.PluginEnv.IsPluginActive(plugin.Manifest.Id) { + resp.Active = append(resp.Active, plugin.Manifest) + } else { + resp.Inactive = append(resp.Inactive, plugin.Manifest) + } } - return manifest, nil + return resp, nil } func (a *App) GetActivePluginManifests() ([]*model.Manifest, *model.AppError) { @@ -365,8 +410,12 @@ func (a *App) RemovePlugin(id string) *model.AppError { return model.NewAppError("RemovePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) } - plugins := a.PluginEnv.ActivePlugins() - manifest := &model.Manifest{} + plugins, err := a.PluginEnv.Plugins() + if err != nil { + return model.NewAppError("RemovePlugin", "app.plugin.deactivate.app_error", nil, err.Error(), http.StatusBadRequest) + } + + var manifest *model.Manifest for _, p := range plugins { if p.Manifest.Id == id { manifest = p.Manifest @@ -374,9 +423,21 @@ func (a *App) RemovePlugin(id string) *model.AppError { } } - err := a.PluginEnv.DeactivatePlugin(id) - if err != nil { - return model.NewAppError("RemovePlugin", "app.plugin.deactivate.app_error", nil, err.Error(), http.StatusBadRequest) + if manifest == nil { + return model.NewAppError("RemovePlugin", "app.plugin.not_installed.app_error", nil, "", http.StatusBadRequest) + } + + if a.PluginEnv.IsPluginActive(id) { + err := a.PluginEnv.DeactivatePlugin(id) + if err != nil { + return model.NewAppError("RemovePlugin", "app.plugin.deactivate.app_error", nil, err.Error(), http.StatusBadRequest) + } + + if manifest.HasClient() { + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PLUGIN_DEACTIVATED, "", "", "", nil) + message.Add("manifest", manifest.ClientManifest()) + a.Publish(message) + } } err = os.RemoveAll(filepath.Join(a.PluginEnv.SearchPath(), id)) @@ -384,10 +445,70 @@ func (a *App) RemovePlugin(id string) *model.AppError { return model.NewAppError("RemovePlugin", "app.plugin.remove.app_error", nil, err.Error(), http.StatusInternalServerError) } - if manifest.HasClient() { - message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_PLUGIN_DEACTIVATED, "", "", "", nil) - message.Add("manifest", manifest.ClientManifest()) - a.Publish(message) + return nil +} + +// EnablePlugin will set the config for an installed plugin to enabled, triggering activation if inactive. +func (a *App) EnablePlugin(id string) *model.AppError { + if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable { + return model.NewAppError("RemovePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) + } + + plugins, err := a.PluginEnv.Plugins() + if err != nil { + return model.NewAppError("EnablePlugin", "app.plugin.config.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + var manifest *model.Manifest + for _, p := range plugins { + if p.Manifest.Id == id { + manifest = p.Manifest + break + } + } + + if manifest == nil { + return model.NewAppError("EnablePlugin", "app.plugin.not_installed.app_error", nil, "", http.StatusBadRequest) + } + + cfg := a.Config() + cfg.PluginSettings.PluginStates[id] = &model.PluginState{Enable: true} + + if err := a.SaveConfig(cfg, true); err != nil { + return model.NewAppError("EnablePlugin", "app.plugin.config.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + return nil +} + +// DisablePlugin will set the config for an installed plugin to disabled, triggering deactivation if active. +func (a *App) DisablePlugin(id string) *model.AppError { + if a.PluginEnv == nil || !*a.Config().PluginSettings.Enable { + return model.NewAppError("RemovePlugin", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) + } + + plugins, err := a.PluginEnv.Plugins() + if err != nil { + return model.NewAppError("DisablePlugin", "app.plugin.config.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + var manifest *model.Manifest + for _, p := range plugins { + if p.Manifest.Id == id { + manifest = p.Manifest + break + } + } + + if manifest == nil { + return model.NewAppError("DisablePlugin", "app.plugin.not_installed.app_error", nil, "", http.StatusBadRequest) + } + + cfg := a.Config() + cfg.PluginSettings.PluginStates[id] = &model.PluginState{Enable: false} + + if err := a.SaveConfig(cfg, true); err != nil { + return model.NewAppError("DisablePlugin", "app.plugin.config.app_error", nil, err.Error(), http.StatusInternalServerError) } return nil @@ -432,7 +553,20 @@ func (a *App) InitPlugins(pluginPath, webappPath string) { return } - a.PluginConfigListenerId = utils.AddConfigListener(func(_, _ *model.Config) { + utils.RemoveConfigListener(a.PluginConfigListenerId) + a.PluginConfigListenerId = utils.AddConfigListener(func(prevCfg, cfg *model.Config) { + if !*prevCfg.PluginSettings.Enable && *cfg.PluginSettings.Enable { + a.InitPlugins(pluginPath, webappPath) + } else if *prevCfg.PluginSettings.Enable && !*cfg.PluginSettings.Enable { + a.ShutDownPlugins() + } else if *prevCfg.PluginSettings.Enable && *cfg.PluginSettings.Enable { + a.ActivatePlugins() + } + + if a.PluginEnv == nil { + return + } + for _, err := range a.PluginEnv.Hooks().OnConfigurationChange() { l4g.Error(err.Error()) } diff --git a/cmd/platform/server.go b/cmd/platform/server.go index 591a27457..d0065245f 100644 --- a/cmd/platform/server.go +++ b/cmd/platform/server.go @@ -75,14 +75,6 @@ func runServer(configFileLocation string) { if webappDir, ok := utils.FindDir(model.CLIENT_DIR); ok { a.InitPlugins("plugins", webappDir+"/plugins") - - utils.AddConfigListener(func(prevCfg *model.Config, cfg *model.Config) { - if !*prevCfg.PluginSettings.Enable && *cfg.PluginSettings.Enable { - a.InitPlugins("plugins", webappDir+"/plugins") - } else if *prevCfg.PluginSettings.Enable && !*cfg.PluginSettings.Enable { - a.ShutDownPlugins() - } - }) } else { l4g.Error("Unable to find webapp directory, could not initialize plugins") } diff --git a/i18n/en.json b/i18n/en.json index e20edf2f2..4cf406411 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3471,6 +3471,18 @@ "id": "app.plugin.activate.app_error", "translation": "Unable to activate extracted plugin. Plugin may already exist and be activated." }, + { + "id": "app.plugin.get_plugins.app_error", + "translation": "Unable to get plugins" + }, + { + "id": "app.plugin.not_installed.app_error", + "translation": "Plugin is not installed" + }, + { + "id": "app.plugin.config.app_error", + "translation": "Error saving plugin state in config" + }, { "id": "app.plugin.deactivate.app_error", "translation": "Unable to deactivate plugin" diff --git a/model/client4.go b/model/client4.go index dc5a25bec..1c20954d8 100644 --- a/model/client4.go +++ b/model/client4.go @@ -3150,12 +3150,12 @@ func (c *Client4) UploadPlugin(file io.Reader) (*Manifest, *Response) { // GetPlugins will return a list of plugin manifests for currently active plugins. // WARNING: PLUGINS ARE STILL EXPERIMENTAL. THIS FUNCTION IS SUBJECT TO CHANGE. -func (c *Client4) GetPlugins() ([]*Manifest, *Response) { +func (c *Client4) GetPlugins() (*PluginsResponse, *Response) { if r, err := c.DoApiGet(c.GetPluginsRoute(), ""); err != nil { return nil, BuildErrorResponse(r, err) } else { defer closeBody(r) - return ManifestListFromJson(r.Body), BuildResponse(r) + return PluginsResponseFromJson(r.Body), BuildResponse(r) } } @@ -3180,3 +3180,25 @@ func (c *Client4) GetWebappPlugins() ([]*Manifest, *Response) { return ManifestListFromJson(r.Body), BuildResponse(r) } } + +// ActivatePlugin will activate an plugin installed. +// WARNING: PLUGINS ARE STILL EXPERIMENTAL. THIS FUNCTION IS SUBJECT TO CHANGE. +func (c *Client4) ActivatePlugin(id string) (bool, *Response) { + if r, err := c.DoApiPost(c.GetPluginRoute(id)+"/activate", ""); err != nil { + return false, BuildErrorResponse(r, err) + } else { + defer closeBody(r) + return CheckStatusOK(r), BuildResponse(r) + } +} + +// DeactivatePlugin will deactivate an active plugin. +// WARNING: PLUGINS ARE STILL EXPERIMENTAL. THIS FUNCTION IS SUBJECT TO CHANGE. +func (c *Client4) DeactivatePlugin(id string) (bool, *Response) { + if r, err := c.DoApiPost(c.GetPluginRoute(id)+"/deactivate", ""); err != nil { + return false, BuildErrorResponse(r, err) + } else { + defer closeBody(r) + return CheckStatusOK(r), BuildResponse(r) + } +} diff --git a/model/config.go b/model/config.go index d3446f533..a93defa7f 100644 --- a/model/config.go +++ b/model/config.go @@ -504,9 +504,14 @@ type JobSettings struct { RunScheduler *bool } +type PluginState struct { + Enable bool +} + type PluginSettings struct { - Enable *bool - Plugins map[string]interface{} + Enable *bool + Plugins map[string]interface{} + PluginStates map[string]*PluginState } type Config struct { @@ -1454,6 +1459,10 @@ func (o *Config) SetDefaults() { o.PluginSettings.Plugins = make(map[string]interface{}) } + if o.PluginSettings.PluginStates == nil { + o.PluginSettings.PluginStates = make(map[string]*PluginState) + } + o.defaultWebrtcSettings() } diff --git a/model/plugins_response.go b/model/plugins_response.go new file mode 100644 index 000000000..d726e491e --- /dev/null +++ b/model/plugins_response.go @@ -0,0 +1,31 @@ +package model + +import ( + "encoding/json" + "io" +) + +type PluginsResponse struct { + Active []*Manifest `json:"active"` + Inactive []*Manifest `json:"inactive"` +} + +func (m *PluginsResponse) ToJson() string { + b, err := json.Marshal(m) + if err != nil { + return "" + } else { + return string(b) + } +} + +func PluginsResponseFromJson(data io.Reader) *PluginsResponse { + decoder := json.NewDecoder(data) + var m PluginsResponse + err := decoder.Decode(&m) + if err == nil { + return &m + } else { + return nil + } +} diff --git a/model/plugins_response_test.go b/model/plugins_response_test.go new file mode 100644 index 000000000..9129c68f7 --- /dev/null +++ b/model/plugins_response_test.go @@ -0,0 +1,31 @@ +package model + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPluginsResponseJson(t *testing.T) { + manifest := &Manifest{ + Id: "theid", + Backend: &ManifestBackend{ + Executable: "theexecutable", + }, + Webapp: &ManifestWebapp{ + BundlePath: "thebundlepath", + }, + } + + response := &PluginsResponse{ + Active: []*Manifest{manifest}, + Inactive: []*Manifest{}, + } + + json := response.ToJson() + newResponse := PluginsResponseFromJson(strings.NewReader(json)) + assert.Equal(t, newResponse, response) + assert.Equal(t, newResponse.ToJson(), json) + assert.Equal(t, PluginsResponseFromJson(strings.NewReader("junk")), (*PluginsResponse)(nil)) +} diff --git a/plugin/pluginenv/environment.go b/plugin/pluginenv/environment.go index 805b33e66..37bd1a482 100644 --- a/plugin/pluginenv/environment.go +++ b/plugin/pluginenv/environment.go @@ -89,6 +89,20 @@ func (env *Environment) ActivePluginIds() (ids []string) { return } +// Returns true if the plugin is active, false otherwise. +func (env *Environment) IsPluginActive(pluginId string) bool { + env.mutex.RLock() + defer env.mutex.RUnlock() + + for id := range env.activePlugins { + if id == pluginId { + return true + } + } + + return false +} + // Activates the plugin with the given id. func (env *Environment) ActivatePlugin(id string) error { env.mutex.Lock() diff --git a/plugin/pluginenv/environment_test.go b/plugin/pluginenv/environment_test.go index c11644b35..e4f63cf70 100644 --- a/plugin/pluginenv/environment_test.go +++ b/plugin/pluginenv/environment_test.go @@ -152,10 +152,12 @@ func TestEnvironment(t *testing.T) { activePlugins = env.ActivePlugins() assert.Len(t, activePlugins, 1) assert.Error(t, env.ActivatePlugin("foo")) + assert.True(t, env.IsPluginActive("foo")) hooks.On("OnDeactivate").Return(nil) assert.NoError(t, env.DeactivatePlugin("foo")) assert.Error(t, env.DeactivatePlugin("foo")) + assert.False(t, env.IsPluginActive("foo")) assert.NoError(t, env.ActivatePlugin("foo")) assert.Equal(t, env.ActivePluginIds(), []string{"foo"}) -- cgit v1.2.3-1-g7c22