mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-28 04:35:04 -04:00
Cloud Freemium - Integrations (#20185)
Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
This commit is contained in:
parent
54a88fb532
commit
ea5bfe3fd9
22 changed files with 634 additions and 22 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
110
app/integrations.go
Normal file
110
app/integrations.go
Normal file
|
|
@ -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
|
||||
}
|
||||
74
app/integrations_test.go
Normal file
74
app/integrations_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
35
app/usage.go
35
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})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
13
model/plugin_constants.go
Normal file
13
model/plugin_constants.go
Normal file
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue