From ea5bfe3fd95697d8c816bfeae494fc3fab19a1c3 Mon Sep 17 00:00:00 2001 From: Michael Kochell <6913320+mickmister@users.noreply.github.com> Date: Wed, 25 May 2022 12:36:08 -0400 Subject: [PATCH] Cloud Freemium - Integrations (#20185) Co-authored-by: Mattermod --- api4/config.go | 10 +++ api4/config_test.go | 110 +++++++++++++++++++++++++++ api4/usage.go | 30 ++++++++ api4/usage_test.go | 25 ++++++ app/app_iface.go | 4 + app/integration_action.go | 6 +- app/integrations.go | 110 +++++++++++++++++++++++++++ app/integrations_test.go | 74 ++++++++++++++++++ app/onboarding.go | 2 +- app/opentracing/opentracing_layer.go | 44 +++++++++++ app/plugin.go | 34 ++++++++- app/plugin_install.go | 16 ++++ app/plugin_test.go | 66 ++++++++++++++++ app/usage.go | 35 +++++++++ app/web_hub.go | 4 + i18n/en.json | 4 + model/client4.go | 21 ++++- model/config.go | 24 +++--- model/plugin_constants.go | 13 ++++ model/usage.go | 21 +++++ model/websocket_message.go | 1 + services/telemetry/telemetry.go | 2 +- 22 files changed, 634 insertions(+), 22 deletions(-) create mode 100644 app/integrations.go create mode 100644 app/integrations_test.go create mode 100644 model/plugin_constants.go diff --git a/api4/config.go b/api4/config.go index cd009c46a16..e32125efe5c 100644 --- a/api4/config.go +++ b/api4/config.go @@ -152,6 +152,11 @@ func updateConfig(c *Context, w http.ResponseWriter, r *http.Request) { *cfg.PluginSettings.MarketplaceURL = *appCfg.PluginSettings.MarketplaceURL } + if err := c.App.CheckFreemiumLimitsForConfigSave(appCfg, cfg); err != nil { + c.Err = err + return + } + // There are some settings that cannot be changed in a cloud env if c.App.Channels().License() != nil && *c.App.Channels().License().Features.Cloud { // Both of them cannot be nil since cfg.SetDefaults is called earlier for cfg, @@ -286,6 +291,11 @@ func patchConfig(c *Context, w http.ResponseWriter, r *http.Request) { } } + if err := c.App.CheckFreemiumLimitsForConfigSave(appCfg, cfg); err != nil { + c.Err = err + return + } + // There are some settings that cannot be changed in a cloud env if c.App.Channels().License() != nil && *c.App.Channels().License().Features.Cloud { if cfg.ComplianceSettings.Directory != nil && *appCfg.ComplianceSettings.Directory != *cfg.ComplianceSettings.Directory { diff --git a/api4/config_test.go b/api4/config_test.go index b1f59d1eb13..d511e17afd8 100644 --- a/api4/config_test.go +++ b/api4/config_test.go @@ -17,7 +17,9 @@ import ( "github.com/mattermost/mattermost-server/v6/app" "github.com/mattermost/mattermost-server/v6/config" + "github.com/mattermost/mattermost-server/v6/einterfaces/mocks" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock" ) func TestGetConfig(t *testing.T) { @@ -247,6 +249,60 @@ func TestUpdateConfig(t *testing.T) { assert.Equal(t, newURL, *cfg2.PluginSettings.MarketplaceURL) }) + t.Run("Should not be able to save config if the new config exceeds Freemium limits", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CLOUDFREE", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDFREE") + th.App.ReloadConfig() + + cloud := &mocks.CloudInterface{} + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = cloud + + cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ + Integrations: &model.IntegrationsLimits{ + Enabled: model.NewInt(0), + }, + }, nil).Once() + + // Exceed freemium limit. Should throw error. + cfg1 := th.App.Config().Clone() + cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: true} + _, _, err1 := th.SystemAdminClient.UpdateConfig(cfg1) + require.Error(t, err1) + + // No attempt to enable a plugin. Should not throw error. + cfg1 = th.App.Config().Clone() + cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: false} + _, _, err1 = th.SystemAdminClient.UpdateConfig(cfg1) + require.NoError(t, err1) + + cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ + Integrations: &model.IntegrationsLimits{ + Enabled: model.NewInt(1), + }, + }, nil).Twice() + + // Exceed freemium limit while enabling more than one plugin. Should throw error. + cfg1 = th.App.Config().Clone() + cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: true} + cfg1.PluginSettings.PluginStates["new-plugin2"] = &model.PluginState{Enable: true} + _, _, err1 = th.SystemAdminClient.PatchConfig(cfg1) + require.Error(t, err1) + + // Match freemium limit. Should not throw error. + cfg1 = th.App.Config().Clone() + cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: true} + _, _, err1 = th.SystemAdminClient.UpdateConfig(cfg1) + require.NoError(t, err1) + + // Save same config with same plugin enabled. Should not throw error. + _, _, err1 = th.SystemAdminClient.UpdateConfig(cfg1) + require.NoError(t, err1) + }) + t.Run("Should not be able to modify ComplianceSettings.Directory in cloud", func(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicense("cloud")) defer th.App.Srv().RemoveLicense() @@ -796,6 +852,60 @@ func TestPatchConfig(t *testing.T) { assert.Equal(t, newURL, *cfg.PluginSettings.MarketplaceURL) }) + t.Run("Should not be able to save config if the new config exceeds Freemium limits", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_CLOUDFREE", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDFREE") + th.App.ReloadConfig() + + cloud := &mocks.CloudInterface{} + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = cloud + + cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ + Integrations: &model.IntegrationsLimits{ + Enabled: model.NewInt(0), + }, + }, nil).Once() + + // Exceed freemium limit. Should throw error. + cfg1 := th.App.Config().Clone() + cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: true} + _, _, err1 := th.SystemAdminClient.PatchConfig(cfg1) + require.Error(t, err1) + + // No attempt to enable a plugin. Should not throw error. + cfg1 = th.App.Config().Clone() + cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: false} + _, _, err1 = th.SystemAdminClient.PatchConfig(cfg1) + require.NoError(t, err1) + + cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ + Integrations: &model.IntegrationsLimits{ + Enabled: model.NewInt(1), + }, + }, nil).Twice() + + // Exceed freemium limit while enabling more than one plugin. Should throw error. + cfg1 = th.App.Config().Clone() + cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: true} + cfg1.PluginSettings.PluginStates["new-plugin2"] = &model.PluginState{Enable: true} + _, _, err1 = th.SystemAdminClient.PatchConfig(cfg1) + require.Error(t, err1) + + // Match freemium limit. Should not throw error. + cfg1 = th.App.Config().Clone() + cfg1.PluginSettings.PluginStates["new-plugin"] = &model.PluginState{Enable: true} + _, _, err1 = th.SystemAdminClient.PatchConfig(cfg1) + require.NoError(t, err1) + + // Save same config with same plugin enabled. Should not throw error. + _, _, err1 = th.SystemAdminClient.PatchConfig(cfg1) + require.NoError(t, err1) + }) + t.Run("System Admin should not be able to clear Site URL", func(t *testing.T) { cfg, _, err := th.SystemAdminClient.GetConfig() require.NoError(t, err) diff --git a/api4/usage.go b/api4/usage.go index 475812538e3..583ab03b9de 100644 --- a/api4/usage.go +++ b/api4/usage.go @@ -13,6 +13,9 @@ import ( func (api *API) InitUsage() { // GET /api/v4/usage/posts api.BaseRoutes.Usage.Handle("/posts", api.APISessionRequired(getPostsUsage)).Methods("GET") + + // GET /api/v4/usage/integrations + api.BaseRoutes.Usage.Handle("/integrations", api.APISessionRequired(getIntegrationsUsage)).Methods("GET") } func getPostsUsage(c *Context, w http.ResponseWriter, r *http.Request) { @@ -30,3 +33,30 @@ func getPostsUsage(c *Context, w http.ResponseWriter, r *http.Request) { w.Write(json) } + +func getIntegrationsUsage(c *Context, w http.ResponseWriter, r *http.Request) { + if !*c.App.Config().PluginSettings.Enable { + json, err := json.Marshal(&model.IntegrationsUsage{}) + if err != nil { + c.Err = model.NewAppError("Api4.getIntegrationsUsage", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + w.Write(json) + return + } + + usage, appErr := c.App.GetIntegrationsUsage() + if appErr != nil { + c.Err = appErr + return + } + + json, err := json.Marshal(usage) + if err != nil { + c.Err = model.NewAppError("Api4.getIntegrationsUsage", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + w.Write(json) +} diff --git a/api4/usage_test.go b/api4/usage_test.go index c1f28f4fb07..754d3b1a188 100644 --- a/api4/usage_test.go +++ b/api4/usage_test.go @@ -37,3 +37,28 @@ func TestGetPostsUsage(t *testing.T) { assert.Equal(t, int64(10), usage.Count) }) } + +func TestGetIntegrationsUsage(t *testing.T) { + t.Run("unauthenticated users can not access", func(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + th.Client.Logout() + + usage, r, err := th.Client.GetIntegrationsUsage() + assert.Error(t, err) + assert.Nil(t, usage) + assert.Equal(t, http.StatusUnauthorized, r.StatusCode) + }) + + t.Run("good request returns response", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + usage, r, err := th.Client.GetIntegrationsUsage() + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, r.StatusCode) + assert.NotNil(t, usage) + assert.Equal(t, 0, usage.Enabled) + }) +} diff --git a/app/app_iface.go b/app/app_iface.go index 587d65b3fff..61c54e9ccce 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -68,6 +68,8 @@ type AppIface interface { // If includeRemovedMembers is true, then channel members who left or were removed from the channel will // be included; otherwise, they will be excluded. ChannelMembersToAdd(since int64, channelID *string, includeRemovedMembers bool) ([]*model.UserChannelIDPair, *model.AppError) + // CheckFreemiumLimitsForConfigSave returns an error if the configuration being saved violates the Cloud Freemium limits + CheckFreemiumLimitsForConfigSave(oldConfig, newConfig *model.Config) *model.AppError // CheckProviderAttributes returns the empty string if the patch can be applied without // overriding attributes set by the user's login provider; otherwise, the name of the offending // field is returned. @@ -168,6 +170,8 @@ type AppIface interface { GetFilteredUsersStats(options *model.UserCountOptions) (*model.UsersStats, *model.AppError) // GetGroupsByTeam returns the paged list and the total count of group associated to the given team. GetGroupsByTeam(teamID string, opts model.GroupSearchOpts) ([]*model.GroupWithSchemeAdmin, int, *model.AppError) + // GetIntegrationsUsage returns usage information on enabled integrations + GetIntegrationsUsage() (*model.IntegrationsUsage, *model.AppError) // GetKnownUsers returns the list of user ids of users with any direct // relationship with a user. That means any user sharing any channel, including // direct and group channels. diff --git a/app/integration_action.go b/app/integration_action.go index e0ebfbb282d..869c0dd4e87 100644 --- a/app/integration_action.go +++ b/app/integration_action.go @@ -375,6 +375,10 @@ func (w *LocalResponseWriter) WriteHeader(statusCode int) { } func (a *App) doPluginRequest(c *request.Context, method, rawURL string, values url.Values, body []byte) (*http.Response, *model.AppError) { + return a.ch.doPluginRequest(c, method, rawURL, values, body) +} + +func (ch *Channels) doPluginRequest(c *request.Context, method, rawURL string, values url.Values, body []byte) (*http.Response, *model.AppError) { rawURL = strings.TrimPrefix(rawURL, "/") inURL, err := url.Parse(rawURL) if err != nil { @@ -423,7 +427,7 @@ func (a *App) doPluginRequest(c *request.Context, method, rawURL string, values params["plugin_id"] = pluginID r = mux.SetURLVars(r, params) - a.ch.ServePluginRequest(w, r) + ch.ServePluginRequest(w, r) resp := &http.Response{ StatusCode: w.status, diff --git a/app/integrations.go b/app/integrations.go new file mode 100644 index 00000000000..7cb28b4afda --- /dev/null +++ b/app/integrations.go @@ -0,0 +1,110 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + "sort" + "strings" + + "github.com/mattermost/mattermost-server/v6/model" +) + +func (a *App) checkIntegrationLimitsForConfigSave(oldConfig, newConfig *model.Config) *model.AppError { + pluginIds := []string{} + for pluginId, newState := range newConfig.PluginSettings.PluginStates { + oldState, ok := oldConfig.PluginSettings.PluginStates[pluginId] + if newState.Enable && !(ok && oldState.Enable) { + pluginIds = append(pluginIds, pluginId) + } + } + + if len(pluginIds) > 0 { + return a.checkIfIntegrationsMeetFreemiumLimits(pluginIds) + } + + return nil +} + +func (ch *Channels) getInstalledIntegrations() ([]*model.InstalledIntegration, *model.AppError) { + out := []*model.InstalledIntegration{} + + pluginsEnvironment := ch.GetPluginsEnvironment() + if pluginsEnvironment == nil { + return out, nil + } + + plugins, err := pluginsEnvironment.Available() + if err != nil { + return nil, model.NewAppError("getInstalledIntegrations", "app.plugin.sync.read_local_folder.app_error", nil, err.Error(), 0) + } + + pluginStates := ch.cfgSvc.Config().PluginSettings.PluginStates + for _, p := range plugins { + if _, ok := model.InstalledIntegrationsIgnoredPlugins[p.Manifest.Id]; !ok { + enabled := false + if state, ok := pluginStates[p.Manifest.Id]; ok { + enabled = state.Enable + } + + integration := &model.InstalledIntegration{ + Type: "plugin", + ID: p.Manifest.Id, + Name: p.Manifest.Name, + Version: p.Manifest.Version, + Enabled: enabled, + } + + out = append(out, integration) + } + } + + // Sort result alphabetically, by display name. + sort.SliceStable(out, func(i, j int) bool { + return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) + }) + + return out, nil +} + +func (a *App) checkIfIntegrationsMeetFreemiumLimits(originalPluginIds []string) *model.AppError { + if !a.Config().FeatureFlags.CloudFree { + return nil + } + + pluginIds := map[string]bool{} + for _, pluginId := range originalPluginIds { + if _, ok := model.InstalledIntegrationsIgnoredPlugins[pluginId]; !ok { + pluginIds[pluginId] = true + } + } + + limits, err := a.Cloud().GetCloudLimits("") + if err != nil { + return model.NewAppError("checkIfIntegrationMeetsFreemiumLimits", "api.cloud.request_error", nil, err.Error(), http.StatusInternalServerError) + } + + if limits == nil || limits.Integrations == nil || limits.Integrations.Enabled == nil { + return nil + } + + installed, appErr := a.ch.getInstalledIntegrations() + if appErr != nil { + return appErr + } + + enableCount := len(pluginIds) + for _, integration := range installed { + if _, ok := pluginIds[integration.ID]; !ok && integration.Enabled { + enableCount++ + } + } + + limit := *limits.Integrations.Enabled + if enableCount > limit { + return model.NewAppError("checkIfIntegrationMeetsFreemiumLimits", "app.install_integration.reached_max_limit.error", map[string]interface{}{"NumIntegrations": limit}, "", http.StatusBadRequest) + } + + return nil +} diff --git a/app/integrations_test.go b/app/integrations_test.go new file mode 100644 index 00000000000..3a3b6b6c6a6 --- /dev/null +++ b/app/integrations_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/stretchr/testify/require" +) + +func TestGetIntegrationsUsage(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + samplePluginCode := ` + package main + + import ( + "github.com/mattermost/mattermost-server/v6/plugin" + ) + + type MyPlugin struct { + plugin.MattermostPlugin + } + + func main() { + plugin.ClientMain(&MyPlugin{}) + } + ` + + setupMultiPluginAPITest(t, + []string{samplePluginCode, samplePluginCode, samplePluginCode, samplePluginCode, samplePluginCode, samplePluginCode, samplePluginCode}, []string{ + `{"id": "otherplugin", "name": "Other Plugin", "version": "1.2.0", "server": {"executable": "backend.exe"}}`, + `{"id": "mattermost-autolink", "name": "Autolink", "version": "1.2.0", "server": {"executable": "backend.exe"}}`, + `{"id": "playbooks", "name": "Playbooks", "version": "1.2.0", "server": {"executable": "backend.exe"}}`, + `{"id": "focalboard", "name": "Mattermost Boards", "version": "1.2.0", "server": {"executable": "backend.exe"}}`, + `{"id": "com.mattermost.calls", "name": "Calls", "version": "1.2.0", "server": {"executable": "backend.exe"}}`, + `{"id": "com.mattermost.nps", "name": "User Satisfaction Surveys", "version": "1.2.0", "server": {"executable": "backend.exe"}}`, + `{"id": "com.mattermost.apps", "server": {"executable": "backend.exe"}}`, + }, []string{"otherplugin", "mattermost-autolink", "playbooks", "focalboard", "com.mattermost.calls", "com.mattermost.nps", "com.mattermost.apps"}, + true, th.App, th.Context) + + integrations, appErr := th.App.ch.getInstalledIntegrations() + require.Nil(t, appErr) + + expected := []*model.InstalledIntegration{ + { + Type: "plugin", + ID: "mattermost-autolink", + Name: "Autolink", + Version: "1.2.0", + Enabled: true, + }, + { + Type: "plugin", + ID: "otherplugin", + Name: "Other Plugin", + Version: "1.2.0", + Enabled: true, + }, + } + require.Equal(t, expected, integrations) + + usage, appErr := th.App.GetIntegrationsUsage() + require.Nil(t, appErr) + + // 2 enabled integrations + expectedUsage := &model.IntegrationsUsage{ + Enabled: 2, + } + require.Equal(t, expectedUsage, usage) +} diff --git a/app/onboarding.go b/app/onboarding.go index 6c4f315c7bd..31715507f3c 100644 --- a/app/onboarding.go +++ b/app/onboarding.go @@ -47,7 +47,7 @@ func (a *App) CompleteOnboarding(c *request.Context, request *model.CompleteOnbo return } - appErr = a.Channels().enablePlugin(id) + appErr = a.EnablePlugin(id) if appErr != nil { mlog.Error("Failed to enable plugin for onboarding", mlog.String("id", id), mlog.Err(appErr)) return diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index d36dde4a5a7..6a9b6a0ec26 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -1227,6 +1227,28 @@ func (a *OpenTracingAppLayer) CheckForClientSideCert(r *http.Request) (string, s return resultVar0, resultVar1, resultVar2 } +func (a *OpenTracingAppLayer) CheckFreemiumLimitsForConfigSave(oldConfig *model.Config, newConfig *model.Config) *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CheckFreemiumLimitsForConfigSave") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.CheckFreemiumLimitsForConfigSave(oldConfig, newConfig) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) CheckIntegrity() <-chan model.IntegrityCheckResult { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CheckIntegrity") @@ -6537,6 +6559,28 @@ func (a *OpenTracingAppLayer) GetIncomingWebhooksPageByUser(userID string, page return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetIntegrationsUsage() (*model.IntegrationsUsage, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetIntegrationsUsage") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetIntegrationsUsage() + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetJob(id string) (*model.Job, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetJob") diff --git a/app/plugin.go b/app/plugin.go index be30cb4ed7e..dbe0e617041 100644 --- a/app/plugin.go +++ b/app/plugin.go @@ -156,6 +156,10 @@ func (ch *Channels) syncPluginsActiveState() { if err := ch.notifyPluginStatusesChanged(); err != nil { mlog.Warn("failed to notify plugin status changed", mlog.Err(err)) } + + if err := ch.notifyIntegrationsUsageChanged(); err != nil { + mlog.Warn("Failed to notify integrations usage changed", mlog.Err(err)) + } } func (a *App) NewPluginAPI(c *request.Context, manifest *model.Manifest) plugin.API { @@ -370,6 +374,11 @@ func (a *App) GetActivePluginManifests() ([]*model.Manifest, *model.AppError) { // activation if inactive anywhere in the cluster. // Notifies cluster peers through config change. func (a *App) EnablePlugin(id string) *model.AppError { + appErr := a.checkIfIntegrationsMeetFreemiumLimits([]string{id}) + if appErr != nil { + return appErr + } + return a.ch.enablePlugin(id) } @@ -416,7 +425,12 @@ func (ch *Channels) enablePlugin(id string) *model.AppError { // DisablePlugin will set the config for an installed plugin to disabled, triggering deactivation if active. // Notifies cluster peers through config change. func (a *App) DisablePlugin(id string) *model.AppError { - return a.ch.disablePlugin(id) + appErr := a.ch.disablePlugin(id) + if appErr != nil { + return appErr + } + + return nil } func (ch *Channels) disablePlugin(id string) *model.AppError { @@ -457,6 +471,20 @@ func (ch *Channels) disablePlugin(id string) *model.AppError { return nil } +func (ch *Channels) notifyIntegrationsUsageChanged() *model.AppError { + usage, appErr := ch.getIntegrationsUsage() + if appErr != nil { + return appErr + } + + message := model.NewWebSocketEvent(model.WebsocketEventIntegrationsUsageChanged, "", "", "", nil) + message.Add("usage", usage) + message.GetBroadcast().ContainsSensitiveData = true + ch.Publish(message) + + return nil +} + func (a *App) GetPlugins() (*model.PluginsResponse, *model.AppError) { pluginsEnvironment := a.GetPluginsEnvironment() if pluginsEnvironment == nil { @@ -1064,12 +1092,12 @@ func getIcon(iconPath string) (string, error) { func (ch *Channels) getPluginStateOverride(pluginID string) (bool, bool) { switch pluginID { - case "com.mattermost.apps": + case model.PluginIdApps: // Tie Apps proxy disabled status to the feature flag. if !ch.cfgSvc.Config().FeatureFlags.AppsEnabled { return true, false } - case "com.mattermost.calls": + case model.PluginIdCalls: if model.IsCloud() { return true, ch.cfgSvc.Config().FeatureFlags.CallsEnabled } diff --git a/app/plugin_install.go b/app/plugin_install.go index 448c1505a2b..64df1590987 100644 --- a/app/plugin_install.go +++ b/app/plugin_install.go @@ -105,6 +105,10 @@ func (ch *Channels) installPluginFromData(data model.PluginEventData) { if err := ch.notifyPluginStatusesChanged(); err != nil { mlog.Error("Failed to notify plugin status changed", mlog.Err(err)) } + + if err := ch.notifyIntegrationsUsageChanged(); err != nil { + mlog.Warn("Failed to notify integrations usage changed", mlog.Err(err)) + } } func (ch *Channels) removePluginFromData(data model.PluginEventData) { @@ -117,6 +121,10 @@ func (ch *Channels) removePluginFromData(data model.PluginEventData) { if err := ch.notifyPluginStatusesChanged(); err != nil { mlog.Warn("failed to notify plugin status changed", mlog.Err(err)) } + + if err := ch.notifyIntegrationsUsageChanged(); err != nil { + mlog.Warn("Failed to notify integrations usage changed", mlog.Err(err)) + } } // InstallPluginWithSignature verifies and installs plugin. @@ -172,6 +180,10 @@ func (ch *Channels) installPlugin(pluginFile, signature io.ReadSeeker, installat mlog.Warn("Failed to notify plugin status changed", mlog.Err(err)) } + if err := ch.notifyIntegrationsUsageChanged(); err != nil { + mlog.Warn("Failed to notify integrations usage changed", mlog.Err(err)) + } + return manifest, nil } @@ -447,6 +459,10 @@ func (ch *Channels) RemovePlugin(id string) *model.AppError { mlog.Warn("Failed to notify plugin status changed", mlog.Err(err)) } + if err := ch.notifyIntegrationsUsageChanged(); err != nil { + mlog.Warn("Failed to notify integrations usage changed", mlog.Err(err)) + } + return nil } diff --git a/app/plugin_test.go b/app/plugin_test.go index 887538016d6..1633d48bc61 100644 --- a/app/plugin_test.go +++ b/app/plugin_test.go @@ -18,9 +18,11 @@ import ( "github.com/gorilla/mux" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost-server/v6/app/request" + "github.com/mattermost/mattermost-server/v6/einterfaces/mocks" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin" "github.com/mattermost/mattermost-server/v6/shared/mlog" @@ -966,6 +968,70 @@ func TestProcessPrepackagedPlugins(t *testing.T) { }) } +func TestEnablePluginWithCloudLimits(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + os.Setenv("MM_FEATUREFLAGS_CLOUDFREE", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_CLOUDFREE") + th.App.ReloadConfig() + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = true + *cfg.PluginSettings.RequirePluginSignature = false + cfg.PluginSettings.PluginStates["testplugin"] = &model.PluginState{Enable: false} + cfg.PluginSettings.PluginStates["testplugin2"] = &model.PluginState{Enable: false} + }) + + cloud := &mocks.CloudInterface{} + cloud.Mock.On("GetCloudLimits", mock.Anything).Return(&model.ProductLimits{ + Integrations: &model.IntegrationsLimits{ + Enabled: model.NewInt(1), + }, + }, nil) + + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = cloud + + env := th.App.GetPluginsEnvironment() + require.NotNil(t, env) + + path, _ := fileutils.FindDir("tests") + fileReader, err := os.Open(filepath.Join(path, "testplugin.tar.gz")) + require.NoError(t, err) + defer fileReader.Close() + + _, appErr := th.App.WriteFile(fileReader, getBundleStorePath("testplugin")) + checkNoError(t, appErr) + + fileReader, err = os.Open(filepath.Join(path, "testplugin2.tar.gz")) + require.NoError(t, err) + defer fileReader.Close() + + _, appErr = th.App.WriteFile(fileReader, getBundleStorePath("testplugin2")) + checkNoError(t, appErr) + + appErr = th.App.SyncPlugins() + checkNoError(t, appErr) + + appErr = th.App.EnablePlugin("testplugin") + checkNoError(t, appErr) + + appErr = th.App.EnablePlugin("testplugin2") + checkError(t, appErr) + require.Equal(t, "app.install_integration.reached_max_limit.error", appErr.Id) + + os.Unsetenv("MM_FEATUREFLAGS_CLOUDFREE") + th.App.ReloadConfig() + + appErr = th.App.EnablePlugin("testplugin2") + checkNoError(t, appErr) +} + func TestGetPluginStateOverride(t *testing.T) { th := Setup(t) defer th.TearDown() diff --git a/app/usage.go b/app/usage.go index ee1985a5898..de7f383e4fa 100644 --- a/app/usage.go +++ b/app/usage.go @@ -10,6 +10,41 @@ import ( "github.com/mattermost/mattermost-server/v6/utils" ) +// CheckFreemiumLimitsForConfigSave returns an error if the configuration being saved violates the Cloud Freemium limits +func (a *App) CheckFreemiumLimitsForConfigSave(oldConfig, newConfig *model.Config) *model.AppError { + if !a.Config().FeatureFlags.CloudFree { + return nil + } + + appErr := a.checkIntegrationLimitsForConfigSave(oldConfig, newConfig) + if appErr != nil { + return appErr + } + + return nil +} + +// GetIntegrationsUsage returns usage information on enabled integrations +func (a *App) GetIntegrationsUsage() (*model.IntegrationsUsage, *model.AppError) { + return a.ch.getIntegrationsUsage() +} + +func (ch *Channels) getIntegrationsUsage() (*model.IntegrationsUsage, *model.AppError) { + installed, appErr := ch.getInstalledIntegrations() + if appErr != nil { + return nil, appErr + } + + var count = 0 + for _, i := range installed { + if i.Enabled { + count++ + } + } + + return &model.IntegrationsUsage{Enabled: count}, nil +} + // GetPostsUsage returns "rounded off" total posts count like returns 900 instead of 987 func (a *App) GetPostsUsage() (int64, *model.AppError) { count, err := a.Srv().Store.Post().AnalyticsPostCount(&model.PostCountOptions{ExcludeDeleted: true}) diff --git a/app/web_hub.go b/app/web_hub.go index 0537e7af498..703a27b278c 100644 --- a/app/web_hub.go +++ b/app/web_hub.go @@ -191,6 +191,10 @@ func (a *App) Publish(message *model.WebSocketEvent) { a.Srv().Publish(message) } +func (ch *Channels) Publish(message *model.WebSocketEvent) { + ch.srv.Publish(message) +} + func (s *Server) PublishSkipClusterSend(event *model.WebSocketEvent) { if event.GetBroadcast().UserId != "" { hub := s.GetHubForUserId(event.GetBroadcast().UserId) diff --git a/i18n/en.json b/i18n/en.json index 834219bc966..18c67187c4e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -5443,6 +5443,10 @@ "id": "app.insert_error", "translation": "insert error" }, + { + "id": "app.install_integration.reached_max_limit.error", + "translation": "You've reached the max limit of {{.NumIntegrations}} enabled integrations. To install unlimited integrations, upgrade to one of our paid plans." + }, { "id": "app.job.download_export_results_not_enabled", "translation": "DownloadExportResults in config.json is false. Please set this to true to download the results of this job." diff --git a/model/client4.go b/model/client4.go index e1fed3d4bb2..fd3aeeff148 100644 --- a/model/client4.go +++ b/model/client4.go @@ -324,14 +324,14 @@ func (c *Client4) cloudRoute() string { return "/cloud" } -func (c *Client4) usageRoute() string { - return "/usage" -} - func (c *Client4) testEmailRoute() string { return "/email/test" } +func (c *Client4) usageRoute() string { + return "/usage" +} + func (c *Client4) testSiteURLRoute() string { return "/site_url/test" } @@ -8110,3 +8110,16 @@ func (c *Client4) GetPostsUsage() (*PostsUsage, *Response, error) { err = json.NewDecoder(r.Body).Decode(&usage) return usage, BuildResponse(r), err } + +// GetIntegrationsUsage returns usage information on integrations, including the count of enabled integrations +func (c *Client4) GetIntegrationsUsage() (*IntegrationsUsage, *Response, error) { + r, err := c.DoAPIGet(c.usageRoute()+"/integrations", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var usage *IntegrationsUsage + err = json.NewDecoder(r.Body).Decode(&usage) + return usage, BuildResponse(r), err +} diff --git a/model/config.go b/model/config.go index 037fe9abd2b..90b963b996a 100644 --- a/model/config.go +++ b/model/config.go @@ -2780,34 +2780,34 @@ func (s *PluginSettings) SetDefaults(ls LogSettings) { s.PluginStates = make(map[string]*PluginState) } - if s.PluginStates["com.mattermost.nps"] == nil { + if s.PluginStates[PluginIdNPS] == nil { // Enable the NPS plugin by default if diagnostics are enabled - s.PluginStates["com.mattermost.nps"] = &PluginState{Enable: ls.EnableDiagnostics == nil || *ls.EnableDiagnostics} + s.PluginStates[PluginIdNPS] = &PluginState{Enable: ls.EnableDiagnostics == nil || *ls.EnableDiagnostics} } - if s.PluginStates["playbooks"] == nil { + if s.PluginStates[PluginIdPlaybooks] == nil { // Enable the playbooks plugin by default - s.PluginStates["playbooks"] = &PluginState{Enable: true} + s.PluginStates[PluginIdPlaybooks] = &PluginState{Enable: true} } - if s.PluginStates["com.mattermost.plugin-channel-export"] == nil && BuildEnterpriseReady == "true" { + if s.PluginStates[PluginIdChannelExport] == nil && BuildEnterpriseReady == "true" { // Enable the channel export plugin by default - s.PluginStates["com.mattermost.plugin-channel-export"] = &PluginState{Enable: true} + s.PluginStates[PluginIdChannelExport] = &PluginState{Enable: true} } - if s.PluginStates["focalboard"] == nil { + if s.PluginStates[PluginIdFocalboard] == nil { // Enable the focalboard plugin by default - s.PluginStates["focalboard"] = &PluginState{Enable: true} + s.PluginStates[PluginIdFocalboard] = &PluginState{Enable: true} } - if s.PluginStates["com.mattermost.apps"] == nil { + if s.PluginStates[PluginIdApps] == nil { // Enable the Apps plugin by default - s.PluginStates["com.mattermost.apps"] = &PluginState{Enable: true} + s.PluginStates[PluginIdApps] = &PluginState{Enable: true} } - if s.PluginStates["com.mattermost.calls"] == nil && IsCloud() { + if s.PluginStates[PluginIdCalls] == nil && IsCloud() { // Enable the calls plugin by default on Cloud only - s.PluginStates["com.mattermost.calls"] = &PluginState{Enable: true} + s.PluginStates[PluginIdCalls] = &PluginState{Enable: true} } if s.EnableMarketplace == nil { diff --git a/model/plugin_constants.go b/model/plugin_constants.go new file mode 100644 index 00000000000..fa7006745f3 --- /dev/null +++ b/model/plugin_constants.go @@ -0,0 +1,13 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +const ( + PluginIdPlaybooks = "playbooks" + PluginIdFocalboard = "focalboard" + PluginIdApps = "com.mattermost.apps" + PluginIdCalls = "com.mattermost.calls" + PluginIdNPS = "com.mattermost.nps" + PluginIdChannelExport = "com.mattermost.plugin-channel-export" +) diff --git a/model/usage.go b/model/usage.go index 09fd566cbf7..4dfcabd14ea 100644 --- a/model/usage.go +++ b/model/usage.go @@ -6,3 +6,24 @@ package model type PostsUsage struct { Count int64 `json:"count"` } + +type IntegrationsUsage struct { + Enabled int `json:"enabled"` +} + +var InstalledIntegrationsIgnoredPlugins = map[string]struct{}{ + PluginIdPlaybooks: {}, + PluginIdFocalboard: {}, + PluginIdApps: {}, + PluginIdCalls: {}, + PluginIdNPS: {}, + PluginIdChannelExport: {}, +} + +type InstalledIntegration struct { + Type string `json:"type"` // "plugin" or "app" + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Enabled bool `json:"enabled"` +} diff --git a/model/websocket_message.go b/model/websocket_message.go index baff25456bf..1c7236d28ba 100644 --- a/model/websocket_message.go +++ b/model/websocket_message.go @@ -76,6 +76,7 @@ const ( WebsocketEventThreadFollowChanged = "thread_follow_changed" WebsocketEventThreadReadChanged = "thread_read_changed" WebsocketFirstAdminVisitMarketplaceStatusReceived = "first_admin_visit_marketplace_status_received" + WebsocketEventIntegrationsUsageChanged = "integrations_usage_changed" ) type WebSocketMessage interface { diff --git a/services/telemetry/telemetry.go b/services/telemetry/telemetry.go index 1b05c2d827a..06f3142409c 100644 --- a/services/telemetry/telemetry.go +++ b/services/telemetry/telemetry.go @@ -1303,7 +1303,7 @@ func (ts *TelemetryService) trackWarnMetrics() { func (ts *TelemetryService) trackPluginConfig(cfg *model.Config, marketplaceURL string) { pluginConfigData := map[string]interface{}{ - "enable_nps_survey": pluginSetting(&cfg.PluginSettings, "com.mattermost.nps", "enablesurvey", true), + "enable_nps_survey": pluginSetting(&cfg.PluginSettings, model.PluginIdNPS, "enablesurvey", true), "enable": *cfg.PluginSettings.Enable, "enable_uploads": *cfg.PluginSettings.EnableUploads, "allow_insecure_download_url": *cfg.PluginSettings.AllowInsecureDownloadURL,