From 8d4bf4bae02c8e820de901cca8ca36e368bbc653 Mon Sep 17 00:00:00 2001 From: Ben Schumacher Date: Mon, 13 Jan 2025 20:23:09 +0100 Subject: [PATCH] [MM-54288] Support Packet V2 (#29403) --- server/build/docker-compose-generator/main.go | 2 +- server/channels/api4/system.go | 14 +- server/channels/api4/system_test.go | 48 +- server/channels/app/app.go | 3 + server/channels/app/app_iface.go | 4 +- server/channels/app/ldap.go | 2 +- .../app/opentracing/opentracing_layer.go | 43 +- server/channels/app/platform/config.go | 18 + server/channels/app/platform/enterprise.go | 6 + server/channels/app/platform/helper_test.go | 6 +- server/channels/app/platform/log.go | 40 + server/channels/app/platform/log_test.go | 91 ++ server/channels/app/platform/metrics.go | 52 -- server/channels/app/platform/service.go | 45 + server/channels/app/platform/service_test.go | 19 + .../channels/app/platform/support_packet.go | 259 ++++++ .../app/platform/support_packet_test.go | 398 +++++++++ server/channels/app/role.go | 14 + server/channels/app/server.go | 7 - server/channels/app/server_test.go | 28 - server/channels/app/support_packet.go | 405 +++++---- server/channels/app/support_packet_test.go | 793 ++++++++++++------ server/cmd/mmctl/client/client.go | 2 +- server/cmd/mmctl/commands/system.go | 16 +- server/cmd/mmctl/commands/system_e2e_test.go | 19 +- server/cmd/mmctl/commands/system_test.go | 71 +- .../mmctl/docs/mmctl_system_supportpacket.rst | 2 +- server/cmd/mmctl/mocks/client_mock.go | 11 +- server/einterfaces/ldap.go | 5 +- .../mocks/LdapDiagnosticInterface.go | 85 ++ server/einterfaces/mocks/LdapInterface.go | 55 -- server/go.mod | 4 +- server/i18n/en.json | 4 + server/public/go.mod | 4 +- server/public/model/client4.go | 14 +- server/public/model/job.go | 71 ++ server/public/model/manifest.go | 2 +- server/public/model/manifest_test.go | 2 +- server/public/model/packet_metadata.go | 2 +- server/public/model/packet_metadata_test.go | 2 +- server/public/model/role.go | 80 ++ server/public/model/scheme.go | 113 +++ server/public/model/support_packet.go | 138 ++- server/public/pluginapi/system.go | 2 +- server/public/pluginapi/system_test.go | 2 +- server/public/utils/fileutils.go | 24 + server/public/utils/fileutils_test.go | 57 ++ server/public/utils/timeutils/time.go | 29 + server/public/utils/timeutils/time_test.go | 57 ++ .../commercial_support_modal.tsx | 4 +- 50 files changed, 2429 insertions(+), 745 deletions(-) create mode 100644 server/channels/app/platform/support_packet.go create mode 100644 server/channels/app/platform/support_packet_test.go create mode 100644 server/einterfaces/mocks/LdapDiagnosticInterface.go create mode 100644 server/public/utils/timeutils/time.go create mode 100644 server/public/utils/timeutils/time_test.go diff --git a/server/build/docker-compose-generator/main.go b/server/build/docker-compose-generator/main.go index b23d94ad1a6..c6fb322076f 100644 --- a/server/build/docker-compose-generator/main.go +++ b/server/build/docker-compose-generator/main.go @@ -8,7 +8,7 @@ import ( "os" "strings" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) type DockerCompose struct { diff --git a/server/channels/api4/system.go b/server/channels/api4/system.go index 0ff3b39a440..dc57191582a 100644 --- a/server/channels/api4/system.go +++ b/server/channels/api4/system.go @@ -20,6 +20,7 @@ import ( "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/utils" "github.com/mattermost/mattermost/server/v8/channels/audit" "github.com/mattermost/mattermost/server/v8/config" "github.com/mattermost/mattermost/server/v8/platform/services/cache" @@ -112,14 +113,10 @@ func generateSupportPacket(c *Context, w http.ResponseWriter, r *http.Request) { fileDatas := c.App.GenerateSupportPacket(c.AppContext, supportPacketOptions) - // Constructing the ZIP file name as per spec (mattermost_support_packet_YYYY-MM-DD-HH-MM.zip) - // Note that this filename is also being checked at the webapp, please update the - // regex within the commercial_support_modal.tsx file if the naming convention ever changes. now := time.Now() - outputZipFilename := fmt.Sprintf("mattermost_support_packet_%s.zip", now.Format("2006-01-02-03-04")) + outputZipFilename := supportPacketFileName(now, c.App.License().Customer.Company) fileStorageBackend := c.App.FileBackend() - // We do this incase we get concurrent requests, we will always have a unique directory. // This is to avoid the situation where we try to write to the same directory while we are trying to delete it (further down) outputDirectoryToUse := OutputDirectory + "_" + model.NewId() @@ -147,6 +144,13 @@ func generateSupportPacket(c *Context, w http.ResponseWriter, r *http.Request) { web.WriteFileResponse(outputZipFilename, FileMime, 0, now, *c.App.Config().ServiceSettings.WebserverMode, fileBytesReader, true, w, r) } +// supportPacketFileName returns the ZIP file name in the format mm_support_packet_$CUSTOMER_NAME_YYYY-MM-DDTHH-MM.zip. +// Note that this filename is also being checked at the webapp, please update the +// regex within the commercial_support_modal.tsx file if the naming convention ever changes. +func supportPacketFileName(now time.Time, customerName string) string { + return fmt.Sprintf("mm_support_packet_%s_%s.zip", utils.SanitizeFileName(customerName), now.Format("2006-01-02T15-04")) +} + func getSystemPing(c *Context, w http.ResponseWriter, r *http.Request) { reqs := c.App.Config().ClientRequirements diff --git a/server/channels/api4/system_test.go b/server/channels/api4/system_test.go index c2224adac11..90ac06dcc6f 100644 --- a/server/channels/api4/system_test.go +++ b/server/channels/api4/system_test.go @@ -233,9 +233,14 @@ func TestGenerateSupportPacket(t *testing.T) { th.App.Srv().SetLicense(l) th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) { - file, _, err := th.SystemAdminClient.GenerateSupportPacket(context.Background()) + file, filename, _, err := th.SystemAdminClient.GenerateSupportPacket(context.Background()) require.NoError(t, err) - require.NotZero(t, len(file)) + + assert.Contains(t, filename, "mm_support_packet_My_awesome_Company_") + + d, err := io.ReadAll(file) + require.NoError(t, err) + assert.NotZero(t, len(d)) }) }) @@ -249,20 +254,20 @@ func TestGenerateSupportPacket(t *testing.T) { }() th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) { - _, resp, err := th.SystemAdminClient.GenerateSupportPacket(context.Background()) + _, _, resp, err := th.SystemAdminClient.GenerateSupportPacket(context.Background()) require.Error(t, err) CheckForbiddenStatus(t, resp) }) }) t.Run("As a system role, not system admin", func(t *testing.T) { - _, resp, err := th.SystemManagerClient.GenerateSupportPacket(context.Background()) + _, _, resp, err := th.SystemManagerClient.GenerateSupportPacket(context.Background()) require.Error(t, err) CheckForbiddenStatus(t, resp) }) t.Run("As a Regular User", func(t *testing.T) { - _, resp, err := th.Client.GenerateSupportPacket(context.Background()) + _, _, resp, err := th.Client.GenerateSupportPacket(context.Background()) require.Error(t, err) CheckForbiddenStatus(t, resp) }) @@ -271,12 +276,43 @@ func TestGenerateSupportPacket(t *testing.T) { _, err := th.SystemAdminClient.RemoveLicenseFile(context.Background()) require.NoError(t, err) - _, resp, err := th.SystemAdminClient.GenerateSupportPacket(context.Background()) + _, _, resp, err := th.SystemAdminClient.GenerateSupportPacket(context.Background()) require.Error(t, err) CheckForbiddenStatus(t, resp) }) } +func TestSupportPacketFileName(t *testing.T) { + tests := map[string]struct { + now time.Time + customerName string + expected string + }{ + "standard case": { + now: time.Date(2023, 11, 12, 13, 14, 15, 0, time.UTC), + customerName: "TestCustomer", + expected: "mm_support_packet_TestCustomer_2023-11-12T13-14.zip", + }, + "customer name with special characters": { + now: time.Date(2023, 11, 12, 13, 14, 15, 0, time.UTC), + customerName: "Test/Customer:Name", + expected: "mm_support_packet_Test_Customer_Name_2023-11-12T13-14.zip", + }, + "empty customer name": { + now: time.Date(2023, 10, 10, 10, 10, 10, 0, time.UTC), + customerName: "", + expected: "mm_support_packet__2023-10-10T10-10.zip", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + result := supportPacketFileName(tt.now, tt.customerName) + assert.Equal(t, tt.expected, result) + }) + } +} + func TestSiteURLTest(t *testing.T) { th := Setup(t) defer th.TearDown() diff --git a/server/channels/app/app.go b/server/channels/app/app.go index c8bf07bbbe4..316429a505a 100644 --- a/server/channels/app/app.go +++ b/server/channels/app/app.go @@ -87,6 +87,9 @@ func (a *App) SearchEngine() *searchengine.Broker { func (a *App) Ldap() einterfaces.LdapInterface { return a.ch.Ldap } +func (a *App) LdapDiagnostic() einterfaces.LdapDiagnosticInterface { + return a.ch.srv.platform.LdapDiagnostic() +} func (a *App) MessageExport() einterfaces.MessageExportInterface { return a.ch.MessageExport } diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index 19414cd3c04..46a38aa1f67 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -582,6 +582,7 @@ type AppIface interface { DeleteReactionForPost(c request.CTX, reaction *model.Reaction) *model.AppError DeleteRemoteCluster(remoteClusterId string) (bool, *model.AppError) DeleteRetentionPolicy(policyID string) *model.AppError + DeleteRole(id string) (*model.Role, *model.AppError) DeleteScheduledPost(rctx request.CTX, userId, scheduledPostId, connectionId string) (*model.ScheduledPost, *model.AppError) DeleteScheme(schemeId string) (*model.Scheme, *model.AppError) DeleteSharedChannelRemote(id string) (bool, error) @@ -622,7 +623,7 @@ type AppIface interface { GenerateMfaSecret(userID string) (*model.MfaSecret, *model.AppError) GeneratePresignURLForExport(name string) (*model.PresignURLResponse, *model.AppError) GeneratePublicLink(siteURL string, info *model.FileInfo) string - GenerateSupportPacket(c request.CTX, options *model.SupportPacketOptions) []model.FileData + GenerateSupportPacket(rctx request.CTX, options *model.SupportPacketOptions) []model.FileData GetAcknowledgementsForPost(postID string) ([]*model.PostAcknowledgement, *model.AppError) GetAcknowledgementsForPostList(postList *model.PostList) (map[string][]*model.PostAcknowledgement, *model.AppError) GetActivePluginManifests() ([]*model.Manifest, *model.AppError) @@ -947,6 +948,7 @@ type AppIface interface { JoinDefaultChannels(c request.CTX, teamID string, user *model.User, shouldBeAdmin bool, userRequestorId string) *model.AppError JoinUserToTeam(c request.CTX, team *model.Team, user *model.User, userRequestorId string) (*model.TeamMember, *model.AppError) Ldap() einterfaces.LdapInterface + LdapDiagnostic() einterfaces.LdapDiagnosticInterface LeaveChannel(c request.CTX, channelID string, userID string) *model.AppError LeaveTeam(c request.CTX, team *model.Team, user *model.User, requestorId string) *model.AppError License() *model.License diff --git a/server/channels/app/ldap.go b/server/channels/app/ldap.go index 41557a133cf..25a09d9bec8 100644 --- a/server/channels/app/ldap.go +++ b/server/channels/app/ldap.go @@ -39,7 +39,7 @@ func (a *App) SyncLdap(c request.CTX, includeRemovedMembers bool) { func (a *App) TestLdap(rctx request.CTX) *model.AppError { license := a.Srv().License() - if ldapI := a.Ldap(); ldapI != nil && license != nil && *license.Features.LDAP && (*a.Config().LdapSettings.Enable || *a.Config().LdapSettings.EnableSync) { + if ldapI := a.LdapDiagnostic(); ldapI != nil && license != nil && *license.Features.LDAP && (*a.Config().LdapSettings.Enable || *a.Config().LdapSettings.EnableSync) { if err := ldapI.RunTest(rctx); err != nil { err.StatusCode = 500 return err diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index c805588df10..7329ed4837e 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -3771,6 +3771,28 @@ func (a *OpenTracingAppLayer) DeleteRetentionPolicy(policyID string) *model.AppE return resultVar0 } +func (a *OpenTracingAppLayer) DeleteRole(id string) (*model.Role, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteRole") + + 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.DeleteRole(id) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) DeleteScheduledPost(rctx request.CTX, userId string, scheduledPostId string, connectionId string) (*model.ScheduledPost, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DeleteScheduledPost") @@ -4961,7 +4983,7 @@ func (a *OpenTracingAppLayer) GeneratePublicLink(siteURL string, info *model.Fil return resultVar0 } -func (a *OpenTracingAppLayer) GenerateSupportPacket(c request.CTX, options *model.SupportPacketOptions) []model.FileData { +func (a *OpenTracingAppLayer) GenerateSupportPacket(rctx request.CTX, options *model.SupportPacketOptions) []model.FileData { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GenerateSupportPacket") @@ -4973,7 +4995,7 @@ func (a *OpenTracingAppLayer) GenerateSupportPacket(c request.CTX, options *mode }() defer span.Finish() - resultVar0 := a.app.GenerateSupportPacket(c, options) + resultVar0 := a.app.GenerateSupportPacket(rctx, options) return resultVar0 } @@ -12641,6 +12663,23 @@ func (a *OpenTracingAppLayer) JoinUserToTeam(c request.CTX, team *model.Team, us return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) LdapDiagnostic() einterfaces.LdapDiagnosticInterface { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.LdapDiagnostic") + + 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.LdapDiagnostic() + + return resultVar0 +} + func (a *OpenTracingAppLayer) LeaveChannel(c request.CTX, channelID string, userID string) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.LeaveChannel") diff --git a/server/channels/app/platform/config.go b/server/channels/app/platform/config.go index d852d6b35ca..0b499c7f1e5 100644 --- a/server/channels/app/platform/config.go +++ b/server/channels/app/platform/config.go @@ -21,6 +21,7 @@ import ( "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/public/utils" "github.com/mattermost/mattermost/server/v8/channels/store" "github.com/mattermost/mattermost/server/v8/config" @@ -40,6 +41,23 @@ func (ps *PlatformService) Config() *model.Config { return ps.configStore.Get() } +// getSanitizedConfig gets the configuration without any secrets. +func (ps *PlatformService) getSanitizedConfig(rctx request.CTX) *model.Config { + cfg := ps.Config().Clone() + + manifests, err := ps.getPluginManifests() + if err != nil { + // getPluginManifests might error, e.g. when plugins are disabled. + // Sanitize all plugin settings in this case. + rctx.Logger().Warn("Failed to get plugin manifests for config sanitization. Will sanitize all plugin settings.", mlog.Err(err)) + cfg.Sanitize(nil) + } else { + cfg.Sanitize(manifests) + } + + return cfg +} + // Registers a function with a given listener to be called when the config is reloaded and may have changed. The function // will be called with two arguments: the old config and the new config. AddConfigListener returns a unique ID // for the listener that can later be used to remove it. diff --git a/server/channels/app/platform/enterprise.go b/server/channels/app/platform/enterprise.go index f928b04ede7..b66a458d352 100644 --- a/server/channels/app/platform/enterprise.go +++ b/server/channels/app/platform/enterprise.go @@ -20,6 +20,12 @@ func RegisterElasticsearchInterface(f func(*PlatformService) searchengine.Search elasticsearchInterface = f } +var ldapDiagnosticInterface func(*PlatformService) einterfaces.LdapDiagnosticInterface + +func RegisterLdapDiagnosticInterface(f func(*PlatformService) einterfaces.LdapDiagnosticInterface) { + ldapDiagnosticInterface = f +} + var licenseInterface func(*PlatformService) einterfaces.LicenseInterface func RegisterLicenseInterface(f func(*PlatformService) einterfaces.LicenseInterface) { diff --git a/server/channels/app/platform/helper_test.go b/server/channels/app/platform/helper_test.go index cc1b7ba22b2..826a34fca6f 100644 --- a/server/channels/app/platform/helper_test.go +++ b/server/channels/app/platform/helper_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/request" @@ -143,7 +144,8 @@ func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer boo *memoryConfig.MetricsSettings.Enable = true *memoryConfig.ServiceSettings.ListenAddress = "localhost:0" *memoryConfig.MetricsSettings.ListenAddress = "localhost:0" - configStore.Set(memoryConfig) + _, _, err = configStore.Set(memoryConfig) + require.NoError(tb, err) options = append(options, ConfigStore(configStore)) @@ -152,7 +154,7 @@ func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer boo Store: dbStore, }, options...) if err != nil { - panic(err) + require.NoError(tb, err) } th := &TestHelper{ diff --git a/server/channels/app/platform/log.go b/server/channels/app/platform/log.go index 080250e26cb..98c93b961b9 100644 --- a/server/channels/app/platform/log.go +++ b/server/channels/app/platform/log.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "os" + "path" "time" "github.com/pkg/errors" @@ -16,6 +17,7 @@ import ( "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/public/utils" "github.com/mattermost/mattermost/server/v8/config" ) @@ -246,6 +248,44 @@ func (ps *PlatformService) GetNotificationLogFile(_ request.CTX) (*model.FileDat }, nil } +func (ps *PlatformService) GetAdvancedLogs(_ request.CTX) ([]*model.FileData, error) { + advancedLoggingJSON := ps.Config().LogSettings.AdvancedLoggingJSON + if utils.IsEmptyJSON(advancedLoggingJSON) { + return nil, nil + } + + cfg := make(mlog.LoggerConfiguration) + err := json.Unmarshal(advancedLoggingJSON, &cfg) + if err != nil { + return nil, errors.Wrap(err, "invalid advanced logging configuration") + } + + var ret []*model.FileData + for _, t := range cfg { + if t.Type != "file" { + continue + } + var fileOption struct { + Filename string `json:"filename"` + } + if err := json.Unmarshal(t.Options, &fileOption); err != nil { + return nil, errors.Wrap(err, "error decoding file target options") + } + data, err := os.ReadFile(fileOption.Filename) + if err != nil { + return nil, errors.Wrapf(err, "failed to read notifcation log file at path %s", fileOption.Filename) + } + + fileName := path.Base(fileOption.Filename) + ret = append(ret, &model.FileData{ + Filename: fileName, + Body: data, + }) + } + + return ret, nil +} + func isLogFilteredByLevel(logFilter *model.LogFilter, entry *model.LogEntry) bool { logLevels := logFilter.LogLevels if len(logLevels) == 0 { diff --git a/server/channels/app/platform/log_test.go b/server/channels/app/platform/log_test.go index fed038c9778..3819070212f 100644 --- a/server/channels/app/platform/log_test.go +++ b/server/channels/app/platform/log_test.go @@ -4,10 +4,15 @@ package platform import ( + "bytes" + "encoding/json" "os" + "path" "testing" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/v8/channels/testlib" "github.com/mattermost/mattermost/server/v8/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -104,3 +109,89 @@ func TestGetNotificationLogFile(t *testing.T) { assert.Equal(t, "notifications.log", fileData.Filename) assert.Positive(t, len(fileData.Body)) } + +func TestGetAdvancedLogs(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + t.Run("log messanges from std and LDAP level get returned", func(t *testing.T) { + dir, err := os.MkdirTemp("", "logs") + require.NoError(t, err) + t.Cleanup(func() { + err = os.RemoveAll(dir) + require.NoError(t, err) + }) + + optLDAP := map[string]string{ + "filename": path.Join(dir, "ldap.log"), + } + dataLDAP, err := json.Marshal(optLDAP) + require.NoError(t, err) + + optStd := map[string]string{ + "filename": path.Join(dir, "std.log"), + } + dataStd, err := json.Marshal(optStd) + require.NoError(t, err) + + cfg := mlog.LoggerConfiguration{ + "ldap-file": mlog.TargetCfg{ + Type: "file", + Format: "json", + Levels: []mlog.Level{ + mlog.LvlLDAPError, + mlog.LvlLDAPWarn, + mlog.LvlLDAPInfo, + mlog.LvlLDAPDebug, + }, + Options: dataLDAP, + }, + "std": mlog.TargetCfg{ + Type: "file", + Format: "json", + Levels: []mlog.Level{ + mlog.LvlError, + }, + Options: dataStd, + }, + } + cfgData, err := json.Marshal(cfg) + require.NoError(t, err) + + th.Service.UpdateConfig(func(c *model.Config) { + c.LogSettings.AdvancedLoggingJSON = cfgData + }) + th.Service.Logger().LogM([]mlog.Level{mlog.LvlLDAPInfo}, "Some LDAP info") + th.Service.Logger().Error("Some Error") + err = th.Service.Logger().Flush() + require.NoError(t, err) + + fileDatas, err := th.Service.GetAdvancedLogs(th.Context) + require.NoError(t, err) + require.Len(t, fileDatas, 2) + + // Check the order of the log files + var ldapIndex = 0 + var stdIndex = 1 + if fileDatas[1].Filename == "ldap.log" { + ldapIndex = 1 + stdIndex = 0 + } + + assert.Equal(t, "ldap.log", fileDatas[ldapIndex].Filename) + testlib.AssertLog(t, bytes.NewBuffer(fileDatas[ldapIndex].Body), mlog.LvlLDAPInfo.Name, "Some LDAP info") + + assert.Equal(t, "std.log", fileDatas[stdIndex].Filename) + testlib.AssertLog(t, bytes.NewBuffer(fileDatas[stdIndex].Body), mlog.LvlError.Name, "Some Error") + }) + // Disable AdvancedLoggingJSON + th.Service.UpdateConfig(func(c *model.Config) { + c.LogSettings.AdvancedLoggingJSON = nil + }) + t.Run("No logs returned when AdvancedLoggingJSON is empty", func(t *testing.T) { + // Confirm no logs get returned + fileDatas, err := th.Service.GetAdvancedLogs(th.Context) + require.NoError(t, err) + require.Len(t, fileDatas, 0) + }) +} diff --git a/server/channels/app/platform/metrics.go b/server/channels/app/platform/metrics.go index 6dec8d10826..1ca1dc8efc9 100644 --- a/server/channels/app/platform/metrics.go +++ b/server/channels/app/platform/metrics.go @@ -4,7 +4,6 @@ package platform import ( - "bytes" "context" "fmt" "net" @@ -12,7 +11,6 @@ import ( "net/http/pprof" "path" "runtime" - rpprof "runtime/pprof" "strings" "sync" "text/template" @@ -25,7 +23,6 @@ import ( "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" "github.com/mattermost/mattermost/server/public/shared/mlog" - "github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/v8/channels/utils" "github.com/mattermost/mattermost/server/v8/einterfaces" ) @@ -259,52 +256,3 @@ func (ps *PlatformService) Metrics() einterfaces.MetricsInterface { return ps.metricsIFace } - -func (ps *PlatformService) CreateCPUProfile(_ request.CTX) (*model.FileData, error) { - var b bytes.Buffer - - err := rpprof.StartCPUProfile(&b) - if err != nil { - return nil, errors.Wrap(err, "failed to start CPU profile") - } - - time.Sleep(cpuProfileDuration) - - rpprof.StopCPUProfile() - - fileData := &model.FileData{ - Filename: "cpu.prof", - Body: b.Bytes(), - } - return fileData, nil -} - -func (ps *PlatformService) CreateHeapProfile(_ request.CTX) (*model.FileData, error) { - var b bytes.Buffer - - err := rpprof.Lookup("heap").WriteTo(&b, 0) - if err != nil { - return nil, errors.Wrap(err, "failed to lookup heap profile") - } - - fileData := &model.FileData{ - Filename: "heap.prof", - Body: b.Bytes(), - } - return fileData, nil -} - -func (ps *PlatformService) CreateGoroutineProfile(_ request.CTX) (*model.FileData, error) { - var b bytes.Buffer - - err := rpprof.Lookup("goroutine").WriteTo(&b, 2) - if err != nil { - return nil, errors.Wrap(err, "failed to lookup goroutine profile") - } - - fileData := &model.FileData{ - Filename: "goroutines", - Body: b.Bytes(), - } - return fileData, nil -} diff --git a/server/channels/app/platform/service.go b/server/channels/app/platform/service.go index 91314d7eca6..0b1cad3fa12 100644 --- a/server/channels/app/platform/service.go +++ b/server/channels/app/platform/service.go @@ -5,10 +5,12 @@ package platform import ( "crypto/ecdsa" + "errors" "fmt" "hash/maphash" "net/http" "runtime" + "strconv" "sync" "sync/atomic" "time" @@ -88,6 +90,8 @@ type PlatformService struct { searchConfigListenerId string searchLicenseListenerId string + ldapDiagnostic einterfaces.LdapDiagnosticInterface + Jobs *jobs.JobServer hubs []*Hub @@ -459,6 +463,10 @@ func (ps *PlatformService) initEnterprise() { ps.SearchEngine.RegisterElasticsearchEngine(elasticsearchInterface(ps)) } + if ldapDiagnosticInterface != nil { + ps.ldapDiagnostic = ldapDiagnosticInterface(ps) + } + if licenseInterface != nil { ps.licenseManager = licenseInterface(ps) } @@ -547,6 +555,29 @@ func (ps *PlatformService) GetPluginStatuses() (model.PluginStatuses, *model.App return pluginStatuses, nil } +func (ps *PlatformService) getPluginManifests() ([]*model.Manifest, error) { + if ps.pluginEnv == nil { + return nil, errors.New("plugin environment not initialized") + } + + pluginsEnvironment := ps.pluginEnv.GetPluginsEnvironment() + if pluginsEnvironment == nil { + return nil, model.NewAppError("getPluginManifests", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented) + } + + plugins, err := pluginsEnvironment.Available() + if err != nil { + return nil, fmt.Errorf("failed to get list of available plugins: %w", err) + } + + manifests := make([]*model.Manifest, len(plugins)) + for i := range plugins { + manifests[i] = plugins[i].Manifest + } + + return manifests, nil +} + func (ps *PlatformService) FileBackend() filestore.FileBackend { return ps.filestore } @@ -554,3 +585,17 @@ func (ps *PlatformService) FileBackend() filestore.FileBackend { func (ps *PlatformService) ExportFileBackend() filestore.FileBackend { return ps.exportFilestore } + +func (ps *PlatformService) LdapDiagnostic() einterfaces.LdapDiagnosticInterface { + return ps.ldapDiagnostic +} + +// DatabaseTypeAndSchemaVersion returns the Database type (postgres or mysql) and current version of the schema +func (ps *PlatformService) DatabaseTypeAndSchemaVersion() (string, string, error) { + schemaVersion, err := ps.Store.GetDBSchemaVersion() + if err != nil { + return "", "", err + } + + return model.SafeDereference(ps.Config().SqlSettings.DriverName), strconv.Itoa(schemaVersion), nil +} diff --git a/server/channels/app/platform/service_test.go b/server/channels/app/platform/service_test.go index 3149dc50f09..4e0eec6a94e 100644 --- a/server/channels/app/platform/service_test.go +++ b/server/channels/app/platform/service_test.go @@ -7,11 +7,13 @@ import ( "math/rand" "net/http" "os" + "strconv" "strings" "sync/atomic" "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -231,3 +233,20 @@ func TestSetTelemetryId(t *testing.T) { require.Equal(t, clientConfig["DiagnosticId"], id) }) } + +func TestDatabaseTypeAndMattermostVersion(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + databaseType, schemaVersion, err := th.Service.DatabaseTypeAndSchemaVersion() + require.NoError(t, err) + if *th.Service.Config().SqlSettings.DriverName == model.DatabaseDriverPostgres { + assert.Equal(t, "postgres", databaseType) + } else { + assert.Equal(t, "mysql", databaseType) + } + + // It's hard to check wheather the schema version is correct or not. + // So, we just check if it's greater than 1. + assert.GreaterOrEqual(t, schemaVersion, strconv.Itoa(1)) +} diff --git a/server/channels/app/platform/support_packet.go b/server/channels/app/platform/support_packet.go new file mode 100644 index 00000000000..28153d44819 --- /dev/null +++ b/server/channels/app/platform/support_packet.go @@ -0,0 +1,259 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package platform + +import ( + "bytes" + "encoding/json" + "os" + "runtime" + rpprof "runtime/pprof" + "time" + + "github.com/hashicorp/go-multierror" + "github.com/pkg/errors" + "gopkg.in/yaml.v3" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/shared/request" +) + +const ( + envVarInstallType = "MM_INSTALL_TYPE" + unknownDataPoint = "unknown" +) + +func (ps *PlatformService) GenerateSupportPacket(rctx request.CTX, options *model.SupportPacketOptions) ([]model.FileData, error) { + functions := map[string]func(request.CTX) (*model.FileData, error){ + "diagnostics": ps.getSupportPacketDiagnostics, + "config": ps.getSanitizedConfigFile, + "cpu profile": ps.getCPUProfile, + "heap profile": ps.getHeapProfile, + "goroutines": ps.getGoroutineProfile, + } + + if options != nil && options.IncludeLogs { + functions["mattermost log"] = ps.GetLogFile + functions["notification log"] = ps.GetNotificationLogFile + } + + var ( + fileDatas []model.FileData + rErr *multierror.Error + ) + + for name, fn := range functions { + fileData, err := fn(rctx) + if err != nil { + rctx.Logger().Error("Failed to generate file for Support Packet", + mlog.String("file", name), + mlog.Err(err), + ) + rErr = multierror.Append(rErr, err) + } + + if fileData != nil { + fileDatas = append(fileDatas, *fileData) + } + } + + if options != nil && options.IncludeLogs { + advancedLogs, err := ps.GetAdvancedLogs(rctx) + if err != nil { + rctx.Logger().Error("Failed to read advanced log files for Support Packet", mlog.Err(err)) + rErr = multierror.Append(rErr, err) + } + + for _, log := range advancedLogs { + fileDatas = append(fileDatas, *log) + } + } + + return fileDatas, rErr.ErrorOrNil() +} + +func (ps *PlatformService) getSupportPacketDiagnostics(rctx request.CTX) (*model.FileData, error) { + var ( + rErr *multierror.Error + err error + d model.SupportPacketDiagnostics + ) + + d.Version = model.CurrentSupportPacketVersion + + /* License */ + if license := ps.License(); license != nil { + d.License.Company = license.Customer.Company + d.License.Users = model.SafeDereference(license.Features.Users) + d.License.SkuShortName = license.SkuShortName + d.License.IsTrial = license.IsTrial + d.License.IsGovSKU = license.IsGovSku + } + + /* Server */ + d.Server.OS = runtime.GOOS + d.Server.Architecture = runtime.GOARCH + d.Server.Hostname, err = os.Hostname() + if err != nil { + rErr = multierror.Append(errors.Wrap(err, "error while getting hostname")) + } + d.Server.Version = model.CurrentVersion + d.Server.BuildHash = model.BuildHash + installationType := os.Getenv(envVarInstallType) + if installationType == "" { + installationType = unknownDataPoint + } + d.Server.InstallationType = installationType + + /* Config */ + d.Config.Source = ps.DescribeConfig() + + /* DB */ + d.Database.Type, d.Database.SchemaVersion, err = ps.DatabaseTypeAndSchemaVersion() + if err != nil { + rErr = multierror.Append(errors.Wrap(err, "error while getting DB type and schema version")) + } + + databaseVersion, err := ps.Store.GetDbVersion(false) + if err != nil { + rErr = multierror.Append(errors.Wrap(err, "error while getting DB version")) + } else { + d.Database.Version = databaseVersion + } + d.Database.MasterConnectios = ps.Store.TotalMasterDbConnections() + d.Database.ReplicaConnectios = ps.Store.TotalReadDbConnections() + d.Database.SearchConnections = ps.Store.TotalSearchDbConnections() + + /* File store */ + d.FileStore.Status = model.StatusOk + err = ps.FileBackend().TestConnection() + if err != nil { + d.FileStore.Status = model.StatusFail + d.FileStore.Error = err.Error() + } + d.FileStore.Driver = ps.FileBackend().DriverName() + + /* Websockets */ + d.Websocket.Connections = ps.TotalWebsocketConnections() + + /* Cluster */ + if cluster := ps.Cluster(); cluster != nil { + d.Cluster.ID = cluster.GetClusterId() + clusterInfo := cluster.GetClusterInfos() + d.Cluster.NumberOfNodes = len(clusterInfo) + } + + /* LDAP */ + if ldap := ps.LdapDiagnostic(); ldap != nil && (*ps.Config().LdapSettings.Enable || *ps.Config().LdapSettings.EnableSync) { + d.LDAP.Status = model.StatusOk + appErr := ldap.RunTest(rctx) + if appErr != nil { + d.LDAP.Status = model.StatusFail + d.LDAP.Error = appErr.Error() + } + + severName, serverVersion := unknownDataPoint, unknownDataPoint + // Only if the LDAP test was successful, try to get the LDAP server info + if d.LDAP.Status == model.StatusOk { + severName, serverVersion, err = ldap.GetVendorNameAndVendorVersion(rctx) + if err != nil { + rErr = multierror.Append(errors.Wrap(err, "error while getting LDAP vendor info")) + } + + if severName == "" { + severName = unknownDataPoint + } + if serverVersion == "" { + serverVersion = unknownDataPoint + } + } + d.LDAP.ServerName = severName + d.LDAP.ServerVersion = serverVersion + } + + /* Elastic Search */ + if se := ps.SearchEngine.ElasticsearchEngine; se != nil { + d.ElasticSearch.ServerVersion = se.GetFullVersion() + d.ElasticSearch.ServerPlugins = se.GetPlugins() + } + + b, err := yaml.Marshal(&d) + if err != nil { + rErr = multierror.Append(errors.Wrap(err, "failed to marshal Support Packet into yaml")) + } + + fileData := &model.FileData{ + Filename: "diagnostics.yaml", + Body: b, + } + return fileData, rErr.ErrorOrNil() +} + +func (ps *PlatformService) getSanitizedConfigFile(rctx request.CTX) (*model.FileData, error) { + config := ps.getSanitizedConfig(rctx) + spConfig := model.SupportPacketConfig{ + Config: config, + FeatureFlags: *config.FeatureFlags, + } + sanitizedConfigPrettyJSON, err := json.MarshalIndent(spConfig, "", " ") + if err != nil { + return nil, errors.Wrap(err, "failed to sanitized config into json") + } + + fileData := &model.FileData{ + Filename: "sanitized_config.json", + Body: sanitizedConfigPrettyJSON, + } + return fileData, nil +} + +func (ps *PlatformService) getCPUProfile(_ request.CTX) (*model.FileData, error) { + var b bytes.Buffer + + err := rpprof.StartCPUProfile(&b) + if err != nil { + return nil, errors.Wrap(err, "failed to start CPU profile") + } + + time.Sleep(cpuProfileDuration) + + rpprof.StopCPUProfile() + + fileData := &model.FileData{ + Filename: "cpu.prof", + Body: b.Bytes(), + } + return fileData, nil +} + +func (ps *PlatformService) getHeapProfile(_ request.CTX) (*model.FileData, error) { + var b bytes.Buffer + + err := rpprof.Lookup("heap").WriteTo(&b, 0) + if err != nil { + return nil, errors.Wrap(err, "failed to lookup heap profile") + } + + fileData := &model.FileData{ + Filename: "heap.prof", + Body: b.Bytes(), + } + return fileData, nil +} + +func (ps *PlatformService) getGoroutineProfile(_ request.CTX) (*model.FileData, error) { + var b bytes.Buffer + + err := rpprof.Lookup("goroutine").WriteTo(&b, 2) + if err != nil { + return nil, errors.Wrap(err, "failed to lookup goroutine profile") + } + + fileData := &model.FileData{ + Filename: "goroutines", + Body: b.Bytes(), + } + return fileData, nil +} diff --git a/server/channels/app/platform/support_packet_test.go b/server/channels/app/platform/support_packet_test.go new file mode 100644 index 00000000000..281bec6c5d4 --- /dev/null +++ b/server/channels/app/platform/support_packet_test.go @@ -0,0 +1,398 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package platform + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/v8/channels/testlib" + "github.com/mattermost/mattermost/server/v8/config" + emocks "github.com/mattermost/mattermost/server/v8/einterfaces/mocks" + fmocks "github.com/mattermost/mattermost/server/v8/platform/shared/filestore/mocks" +) + +func TestGenerateSupportPacket(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + dir, err := os.MkdirTemp("", "") + require.NoError(t, err) + t.Cleanup(func() { + err = os.RemoveAll(dir) + assert.NoError(t, err) + }) + + th.Service.UpdateConfig(func(cfg *model.Config) { + *cfg.LogSettings.FileLocation = dir + *cfg.NotificationLogSettings.FileLocation = dir + }) + + logLocation := config.GetLogFileLocation(dir) + notificationsLogLocation := config.GetNotificationsLogFileLocation(dir) + + genMockLogFiles := func() { + d1 := []byte("hello\ngo\n") + genErr := os.WriteFile(logLocation, d1, 0600) + require.NoError(t, genErr) + genErr = os.WriteFile(notificationsLogLocation, d1, 0600) + require.NoError(t, genErr) + } + genMockLogFiles() + + getFileNames := func(t *testing.T, fileDatas []model.FileData) []string { + var rFileNames []string + for _, fileData := range fileDatas { + require.NotNil(t, fileData) + assert.Positive(t, len(fileData.Body)) + + rFileNames = append(rFileNames, fileData.Filename) + } + return rFileNames + } + + expectedFileNames := []string{ + "diagnostics.yaml", + "sanitized_config.json", + "cpu.prof", + "heap.prof", + "goroutines", + } + + expectedFileNamesWithLogs := append(expectedFileNames, []string{ + "mattermost.log", + "notifications.log", + }...) + + var fileDatas []model.FileData + + t.Run("generate Support Packet with logs", func(t *testing.T) { + fileDatas, err = th.Service.GenerateSupportPacket(th.Context, &model.SupportPacketOptions{ + IncludeLogs: true, + }) + require.NoError(t, err) + rFileNames := getFileNames(t, fileDatas) + + assert.ElementsMatch(t, expectedFileNamesWithLogs, rFileNames) + }) + + t.Run("generate Support Packet without logs", func(t *testing.T) { + fileDatas, err = th.Service.GenerateSupportPacket(th.Context, &model.SupportPacketOptions{ + IncludeLogs: false, + }) + require.NoError(t, err) + rFileNames := getFileNames(t, fileDatas) + + assert.ElementsMatch(t, expectedFileNames, rFileNames) + }) + + t.Run("remove the log files and ensure that an error is returned", func(t *testing.T) { + err = os.Remove(logLocation) + require.NoError(t, err) + err = os.Remove(notificationsLogLocation) + require.NoError(t, err) + t.Cleanup(genMockLogFiles) + + fileDatas, err = th.Service.GenerateSupportPacket(th.Context, &model.SupportPacketOptions{ + IncludeLogs: true, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed read mattermost log file") + rFileNames := getFileNames(t, fileDatas) + + assert.ElementsMatch(t, expectedFileNames, rFileNames) + }) + + t.Run("with advanced logs", func(t *testing.T) { + optLDAP := map[string]string{ + "filename": path.Join(dir, "ldap.log"), + } + dataLDAP, err := json.Marshal(optLDAP) + require.NoError(t, err) + + cfg := mlog.LoggerConfiguration{ + "ldap-file": mlog.TargetCfg{ + Type: "file", + Format: "json", + Levels: []mlog.Level{ + mlog.LvlLDAPError, + mlog.LvlLDAPWarn, + mlog.LvlLDAPInfo, + mlog.LvlLDAPDebug, + }, + Options: dataLDAP, + }, + } + cfgData, err := json.Marshal(cfg) + require.NoError(t, err) + + th.Service.UpdateConfig(func(c *model.Config) { + c.LogSettings.AdvancedLoggingJSON = cfgData + }) + + th.Service.Logger().LogM([]mlog.Level{mlog.LvlLDAPInfo}, "Some LDAP info") + err = th.Service.Logger().Flush() + require.NoError(t, err) + + fileDatas, err = th.Service.GenerateSupportPacket(th.Context, &model.SupportPacketOptions{ + IncludeLogs: true, + }) + require.NoError(t, err) + rFileNames := getFileNames(t, fileDatas) + + assert.ElementsMatch(t, append(expectedFileNamesWithLogs, "ldap.log"), rFileNames) + + found := false + for _, fileData := range fileDatas { + if fileData.Filename == "ldap.log" { + testlib.AssertLog(t, bytes.NewBuffer(fileData.Body), mlog.LvlLDAPInfo.Name, "Some LDAP info") + found = true + break + } + } + assert.True(t, found) + }) +} + +func TestGetSupportPacketDiagnostics(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + t.Setenv(envVarInstallType, "docker") + + licenseUsers := 100 + license := model.NewTestLicense("ldap") + license.SkuShortName = model.LicenseShortSkuEnterprise + license.Features.Users = model.NewPointer(licenseUsers) + ok := th.Service.SetLicense(license) + require.True(t, ok) + + getDiagnostics := func(t *testing.T) *model.SupportPacketDiagnostics { + t.Helper() + + fileData, err := th.Service.getSupportPacketDiagnostics(th.Context) + require.NotNil(t, fileData) + assert.Equal(t, "diagnostics.yaml", fileData.Filename) + assert.Positive(t, len(fileData.Body)) + assert.NoError(t, err) + + var d model.SupportPacketDiagnostics + require.NoError(t, yaml.Unmarshal(fileData.Body, &d)) + return &d + } + + t.Run("Happy path", func(t *testing.T) { + d := getDiagnostics(t) + + assert.Equal(t, 1, d.Version) + + /* License */ + assert.Equal(t, "My awesome Company", d.License.Company) + assert.Equal(t, licenseUsers, d.License.Users) + assert.Equal(t, model.LicenseShortSkuEnterprise, d.License.SkuShortName) + assert.Equal(t, false, d.License.IsTrial) + assert.Equal(t, false, d.License.IsGovSKU) + + /* Server information */ + assert.NotEmpty(t, d.Server.OS) + assert.NotEmpty(t, d.Server.Architecture) + assert.NotEmpty(t, d.Server.Hostname) + assert.Equal(t, model.CurrentVersion, d.Server.Version) + // BuildHash is not present in tests + assert.Equal(t, "docker", d.Server.InstallationType) + + /* Config */ + assert.Equal(t, "memory://", d.Config.Source) + + /* DB */ + assert.NotEmpty(t, d.Database.Type) + assert.NotEmpty(t, d.Database.Version) + assert.NotEmpty(t, d.Database.SchemaVersion) + assert.NotZero(t, d.Database.MasterConnectios) + assert.Zero(t, d.Database.ReplicaConnectios) + assert.Zero(t, d.Database.SearchConnections) + + /* File store */ + assert.Equal(t, "OK", d.FileStore.Status) + assert.Empty(t, d.FileStore.Error) + assert.Equal(t, "local", d.FileStore.Driver) + + /* Websockets */ + assert.Zero(t, d.Websocket.Connections) + + /* Cluster */ + assert.Empty(t, d.Cluster.ID) + assert.Zero(t, d.Cluster.NumberOfNodes) + + /* LDAP */ + assert.Empty(t, d.LDAP.Status) + assert.Empty(t, d.LDAP.Error) + assert.Empty(t, d.LDAP.ServerName) + assert.Empty(t, d.LDAP.ServerVersion) + + /* Elastic Search */ + assert.Empty(t, d.ElasticSearch.ServerVersion) + assert.Empty(t, d.ElasticSearch.ServerPlugins) + }) + + t.Run("filestore fails", func(t *testing.T) { + fb := &fmocks.FileBackend{} + err := SetFileStore(fb)(th.Service) + require.NoError(t, err) + fb.On("DriverName").Return("mock") + fb.On("TestConnection").Return(errors.New("all broken")) + + packet := getDiagnostics(t) + + assert.Equal(t, "FAIL", packet.FileStore.Status) + assert.Equal(t, "all broken", packet.FileStore.Error) + assert.Equal(t, "mock", packet.FileStore.Driver) + }) + + t.Run("no LDAP info if LDAP sync is disabled", func(t *testing.T) { + ldapMock := &emocks.LdapDiagnosticInterface{} + th.Service.ldapDiagnostic = ldapMock + + packet := getDiagnostics(t) + + assert.Equal(t, "", packet.LDAP.ServerName) + assert.Equal(t, "", packet.LDAP.ServerVersion) + }) + + th.Service.UpdateConfig(func(cfg *model.Config) { + cfg.LdapSettings.EnableSync = model.NewPointer(true) + }) + + t.Run("no LDAP vendor info found", func(t *testing.T) { + ldapMock := &emocks.LdapDiagnosticInterface{} + ldapMock.On( + "GetVendorNameAndVendorVersion", + mock.AnythingOfType("*request.Context"), + ).Return("", "", nil) + ldapMock.On( + "RunTest", + mock.AnythingOfType("*request.Context"), + ).Return(nil) + th.Service.ldapDiagnostic = ldapMock + + packet := getDiagnostics(t) + + assert.Equal(t, "OK", packet.LDAP.Status) + assert.Empty(t, packet.LDAP.Error) + assert.Equal(t, "unknown", packet.LDAP.ServerName) + assert.Equal(t, "unknown", packet.LDAP.ServerVersion) + }) + + t.Run("found LDAP vendor info", func(t *testing.T) { + ldapMock := &emocks.LdapDiagnosticInterface{} + ldapMock.On( + "GetVendorNameAndVendorVersion", + mock.AnythingOfType("*request.Context"), + ).Return("some vendor", "v1.0.0", nil) + ldapMock.On( + "RunTest", + mock.AnythingOfType("*request.Context"), + ).Return(nil) + th.Service.ldapDiagnostic = ldapMock + + packet := getDiagnostics(t) + + assert.Equal(t, "OK", packet.LDAP.Status) + assert.Empty(t, packet.LDAP.Error) + assert.Equal(t, "some vendor", packet.LDAP.ServerName) + assert.Equal(t, "v1.0.0", packet.LDAP.ServerVersion) + }) + + t.Run("LDAP test fails", func(t *testing.T) { + ldapMock := &emocks.LdapDiagnosticInterface{} + ldapMock.On( + "GetVendorNameAndVendorVersion", + mock.AnythingOfType("*request.Context"), + ).Return("some vendor", "v1.0.0", nil) + ldapMock.On( + "RunTest", + mock.AnythingOfType("*request.Context"), + ).Return(model.NewAppError("", "some error", nil, "", 0)) + th.Service.ldapDiagnostic = ldapMock + + packet := getDiagnostics(t) + + assert.Equal(t, "FAIL", packet.LDAP.Status) + assert.Equal(t, "some error", packet.LDAP.Error) + assert.Equal(t, "unknown", packet.LDAP.ServerName) + assert.Equal(t, "unknown", packet.LDAP.ServerVersion) + }) +} + +func TestGetSanitizedConfigFile(t *testing.T) { + t.Setenv("MM_FEATUREFLAGS_TestFeature", "true") + + th := Setup(t) + defer th.TearDown() + + th.Service.UpdateConfig(func(cfg *model.Config) { + cfg.ServiceSettings.AllowedUntrustedInternalConnections = model.NewPointer("example.com") + }) + + // Happy path where we have a sanitized config file with no err + fileData, err := th.Service.getSanitizedConfigFile(th.Context) + require.NotNil(t, fileData) + assert.Equal(t, "sanitized_config.json", fileData.Filename) + assert.Positive(t, len(fileData.Body)) + assert.NoError(t, err) + + var config model.Config + err = json.Unmarshal(fileData.Body, &config) + require.NoError(t, err) + + // Ensure sensitive fields are redacted + assert.Equal(t, model.FakeSetting, *config.SqlSettings.DataSource) + + // Ensure non-sensitive fields are present + assert.Equal(t, "example.com", *config.ServiceSettings.AllowedUntrustedInternalConnections) + + // Ensure feature flags are present + assert.Equal(t, "true", config.FeatureFlags.TestFeature) +} + +func TestGetCPUProfile(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + fileData, err := th.Service.getCPUProfile(th.Context) + require.NoError(t, err) + assert.Equal(t, "cpu.prof", fileData.Filename) + assert.Positive(t, len(fileData.Body)) +} + +func TestGetHeapProfile(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + fileData, err := th.Service.getHeapProfile(th.Context) + require.NoError(t, err) + assert.Equal(t, "heap.prof", fileData.Filename) + assert.Positive(t, len(fileData.Body)) +} + +func TestGetGoroutineProfile(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + fileData, err := th.Service.getGoroutineProfile(th.Context) + require.NoError(t, err) + assert.Equal(t, "goroutines", fileData.Filename) + assert.Positive(t, len(fileData.Body)) +} diff --git a/server/channels/app/role.go b/server/channels/app/role.go index 5803c83c719..c678f92790b 100644 --- a/server/channels/app/role.go +++ b/server/channels/app/role.go @@ -90,6 +90,20 @@ func (a *App) GetRolesByNames(names []string) ([]*model.Role, *model.AppError) { return roles, nil } +func (a *App) DeleteRole(id string) (*model.Role, *model.AppError) { + role, err := a.Srv().Store().Role().Delete(id) + if err != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + return nil, model.NewAppError("DeleteRole", "app.role.get.app_error", nil, "", http.StatusNotFound).Wrap(err) + default: + return nil, model.NewAppError("DeleteRole", "app.role.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + return role, nil +} + // mergeChannelHigherScopedPermissions updates the permissions based on the role type, whether the permission is // moderated, and the value of the permission on the higher-scoped scheme. func (s *Server) mergeChannelHigherScopedPermissions(roles []*model.Role) *model.AppError { diff --git a/server/channels/app/server.go b/server/channels/app/server.go index f1b2f6a1cae..29289d975f3 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -14,7 +14,6 @@ import ( "os" "os/exec" "path" - "strconv" "strings" "sync" "syscall" @@ -592,12 +591,6 @@ func (s *Server) Channels() *Channels { return s.ch } -// Return Database type (postgres or mysql) and current version of the schema -func (s *Server) DatabaseTypeAndSchemaVersion() (string, string) { - schemaVersion, _ := s.Store().GetDBSchemaVersion() - return *s.platform.Config().SqlSettings.DriverName, strconv.Itoa(schemaVersion) -} - func (s *Server) startInterClusterServices(license *model.License) error { if license == nil { mlog.Debug("No license provided; Remote Cluster services disabled") diff --git a/server/channels/app/server_test.go b/server/channels/app/server_test.go index 360b6d49dd8..5c6d19fb477 100644 --- a/server/channels/app/server_test.go +++ b/server/channels/app/server_test.go @@ -164,34 +164,6 @@ func TestStartServerTLSSuccess(t *testing.T) { require.NoError(t, serverErr) } -func TestDatabaseTypeAndMattermostVersion(t *testing.T) { - sqlDrivernameEnvironment := os.Getenv("MM_SQLSETTINGS_DRIVERNAME") - - if sqlDrivernameEnvironment != "" { - defer os.Setenv("MM_SQLSETTINGS_DRIVERNAME", sqlDrivernameEnvironment) - } else { - defer os.Unsetenv("MM_SQLSETTINGS_DRIVERNAME") - } - - os.Setenv("MM_SQLSETTINGS_DRIVERNAME", "postgres") - - th := Setup(t) - defer th.TearDown() - - databaseType, mattermostVersion := th.Server.DatabaseTypeAndSchemaVersion() - assert.Equal(t, "postgres", databaseType) - assert.GreaterOrEqual(t, mattermostVersion, strconv.Itoa(1)) - - os.Setenv("MM_SQLSETTINGS_DRIVERNAME", "mysql") - - th2 := Setup(t) - defer th2.TearDown() - - databaseType, mattermostVersion = th2.Server.DatabaseTypeAndSchemaVersion() - assert.Equal(t, "mysql", databaseType) - assert.GreaterOrEqual(t, mattermostVersion, strconv.Itoa(1)) -} - func TestStartServerTLSVersion(t *testing.T) { configStore, _ := config.NewMemoryStore() store, _ := config.NewStoreFromBacking(configStore, nil, false) diff --git a/server/channels/app/support_packet.go b/server/channels/app/support_packet.go index 64bc709d221..f30c2484547 100644 --- a/server/channels/app/support_packet.go +++ b/server/channels/app/support_packet.go @@ -5,13 +5,12 @@ package app import ( "encoding/json" - "runtime" "slices" "sync" "github.com/hashicorp/go-multierror" "github.com/pkg/errors" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" @@ -19,39 +18,36 @@ import ( "github.com/mattermost/mattermost/server/public/shared/request" ) -func (a *App) GenerateSupportPacket(c request.CTX, options *model.SupportPacketOptions) []model.FileData { - // A array of the functions that we can iterate through since they all have the same return value +func (a *App) GenerateSupportPacket(rctx request.CTX, options *model.SupportPacketOptions) []model.FileData { functions := map[string]func(c request.CTX) (*model.FileData, error){ - "support packet": a.generateSupportPacketYaml, - "plugins": a.createPluginsFile, - "config": a.createSanitizedConfigFile, - "cpu profile": a.Srv().Platform().CreateCPUProfile, - "heap profile": a.Srv().Platform().CreateHeapProfile, - "goroutines": a.Srv().Platform().CreateGoroutineProfile, - "metadata": a.createSupportPacketMetadata, + "metadata": a.getSupportPacketMetadata, + "stats": a.getSupportPacketStats, + "jobs": a.getSupportPacketJobList, + "permissions": a.getSupportPacketPermissionsInfo, + "plugins": a.getPluginsFile, } - if options.IncludeLogs { - functions["mattermost log"] = a.Srv().Platform().GetLogFile - functions["notification log"] = a.Srv().Platform().GetNotificationLogFile - } - - // If any errors we come across within this function, we will log it in a warning.txt file so that we know why certain files did not get produced if any - var warnings *multierror.Error - // Creating an array of files that we are going to be adding to our zip file - var fileDatas []model.FileData - var wg sync.WaitGroup - var mut sync.Mutex // Protects warnings and fileDatas + var ( + // If any errors we come across within this function, we will log it in a warning.txt file so that we know why certain files did not get produced if any + warnings *multierror.Error + // Creating an array of files that we are going to be adding to our zip file + fileDatas []model.FileData + wg sync.WaitGroup + mut sync.Mutex // Protects warnings and fileDatas + ) wg.Add(1) go func() { defer wg.Done() for name, fn := range functions { - fileData, err := fn(c) + fileData, err := fn(rctx) mut.Lock() if err != nil { - c.Logger().Error("Failed to generate file for Support Packet", mlog.String("file", name), mlog.Err(err)) + rctx.Logger().Error("Failed to generate file for Support Packet", + mlog.String("file", name), + mlog.Err(err), + ) warnings = multierror.Append(warnings, err) } @@ -60,6 +56,17 @@ func (a *App) GenerateSupportPacket(c request.CTX, options *model.SupportPacketO } mut.Unlock() } + + // Generate platform support packet + files, err := a.Srv().Platform().GenerateSupportPacket(rctx, options) + mut.Lock() + if err != nil { + warnings = multierror.Append(warnings, err) + } + if files != nil { + fileDatas = append(fileDatas, files...) + } + mut.Unlock() }() // Run the cluster generation in a separate goroutine as CPU profile generation and file upload can take a long time @@ -68,10 +75,10 @@ func (a *App) GenerateSupportPacket(c request.CTX, options *model.SupportPacketO go func() { defer wg.Done() - files, err := cluster.GenerateSupportPacket(c, options) + files, err := cluster.GenerateSupportPacket(rctx, options) mut.Lock() if err != nil { - c.Logger().Error("Failed to generate Support Packet from cluster nodes", mlog.Err(err)) + rctx.Logger().Error("Failed to generate Support Packet from cluster nodes", mlog.Err(err)) warnings = multierror.Append(warnings, err) } @@ -84,7 +91,7 @@ func (a *App) GenerateSupportPacket(c request.CTX, options *model.SupportPacketO wg.Wait() - pluginContext := pluginContext(c) + pluginContext := pluginContext(rctx) a.ch.RunMultiHook(func(hooks plugin.Hooks, manifest *model.Manifest) bool { // If the plugin defined the support_packet prop it means there is a UI element to include it in the support packet. // Check if the plugin is in the list of plugins to include in the Support Packet. @@ -97,7 +104,7 @@ func (a *App) GenerateSupportPacket(c request.CTX, options *model.SupportPacketO // Otherwise, just call the hook as the plugin decided to always include it in the Support Packet. pluginData, err := hooks.GenerateSupportData(pluginContext) if err != nil { - c.Logger().Warn("Failed to generate plugin file for Support Packet", mlog.String("plugin", manifest.Id), mlog.Err(err)) + rctx.Logger().Warn("Failed to generate plugin file for Support Packet", mlog.String("plugin", manifest.Id), mlog.Err(err)) warnings = multierror.Append(warnings, err) return true } @@ -120,230 +127,212 @@ func (a *App) GenerateSupportPacket(c request.CTX, options *model.SupportPacketO return fileDatas } -func (a *App) generateSupportPacketYaml(c request.CTX) (*model.FileData, error) { - var rErr *multierror.Error - - /* DB */ - - databaseType, databaseSchemaVersion := a.Srv().DatabaseTypeAndSchemaVersion() - databaseVersion, err := a.Srv().Store().GetDbVersion(false) - if err != nil { - rErr = multierror.Append(errors.Wrap(err, "error while getting DB version")) - } - - /* Cluster */ - - var clusterID string - if cluster := a.Cluster(); cluster != nil { - clusterID = cluster.GetClusterId() - } - - /* File store */ - - fileDriver := a.Srv().Platform().FileBackend().DriverName() - fileStatus := model.StatusOk - err = a.Srv().Platform().FileBackend().TestConnection() - if err != nil { - fileStatus = model.StatusFail + ": " + err.Error() - } - - /* LDAP */ - - var vendorName, vendorVersion string - if ldap := a.Ldap(); ldap != nil && (*a.Config().LdapSettings.Enable || *a.Config().LdapSettings.EnableSync) { - vendorName, vendorVersion, err = ldap.GetVendorNameAndVendorVersion(c) - if err != nil { - rErr = multierror.Append(errors.Wrap(err, "error while getting LDAP vendor info")) - } - - if vendorName == "" { - vendorName = "unknown" - } - if vendorVersion == "" { - vendorVersion = "unknown" - } - } - - /* Elastic Search */ - - var elasticServerVersion string - var elasticServerPlugins []string - if se := a.Srv().Platform().SearchEngine.ElasticsearchEngine; se != nil { - elasticServerVersion = se.GetFullVersion() - elasticServerPlugins = se.GetPlugins() - } - - /* License */ - +func (a *App) getSupportPacketStats(rctx request.CTX) (*model.FileData, error) { var ( - licenseTo string - supportedUsers int - isTrial bool + rErr *multierror.Error + err error + stats model.SupportPacketStats ) - if license := a.Srv().License(); license != nil { - licenseTo = license.Customer.Company - supportedUsers = *license.Features.Users - isTrial = license.IsTrial - } - /* Server stats */ - - uniqueUserCount, err := a.Srv().Store().User().Count(model.UserCountOptions{}) + stats.RegisteredUsers, err = a.Srv().Store().User().Count(model.UserCountOptions{IncludeDeleted: true}) if err != nil { - rErr = multierror.Append(errors.Wrap(err, "error while getting user count")) + rErr = multierror.Append(errors.Wrap(err, "failed to get registered user count")) } - var ( - totalChannels int - totalPosts int - totalTeams int - websocketConnections int - masterDbConnections int - replicaDbConnections int - dailyActiveUsers int - monthlyActiveUsers int - inactiveUserCount int - ) - analytics, appErr := a.GetAnalyticsForSupportPacket(c) - if appErr != nil { - rErr = multierror.Append(errors.Wrap(appErr, "error while getting analytics")) - } else { - if len(analytics) < 11 { - rErr = multierror.Append(errors.New("not enough analytics information found")) - } else { - totalChannels = int(analytics[0].Value) + int(analytics[1].Value) - totalPosts = int(analytics[2].Value) - totalTeams = int(analytics[4].Value) - websocketConnections = int(analytics[5].Value) - masterDbConnections = int(analytics[6].Value) - replicaDbConnections = int(analytics[7].Value) - dailyActiveUsers = int(analytics[8].Value) - monthlyActiveUsers = int(analytics[9].Value) - inactiveUserCount = int(analytics[10].Value) - } - } - - /* Jobs */ - - dataRetentionJobs, err := a.Srv().Store().Job().GetAllByTypePage(c, model.JobTypeDataRetention, 0, 2) + stats.ActiveUsers, err = a.Srv().Store().User().Count(model.UserCountOptions{}) if err != nil { - rErr = multierror.Append(errors.Wrap(err, "error while getting data retention jobs")) + rErr = multierror.Append(errors.Wrap(err, "failed to get active user count")) } - messageExportJobs, err := a.Srv().Store().Job().GetAllByTypePage(c, model.JobTypeMessageExport, 0, 2) + + stats.DailyActiveUsers, err = a.Srv().Store().User().AnalyticsActiveCount(DayMilliseconds, model.UserCountOptions{IncludeBotAccounts: false, IncludeDeleted: false}) if err != nil { - rErr = multierror.Append(errors.Wrap(err, "error while getting message export jobs")) + rErr = multierror.Append(errors.Wrap(err, "failed to get daily active user count")) } - elasticPostIndexingJobs, err := a.Srv().Store().Job().GetAllByTypePage(c, model.JobTypeElasticsearchPostIndexing, 0, 2) + + stats.MonthlyActiveUsers, err = a.Srv().Store().User().AnalyticsActiveCount(MonthMilliseconds, model.UserCountOptions{IncludeBotAccounts: false, IncludeDeleted: false}) if err != nil { - rErr = multierror.Append(errors.Wrap(err, "error while getting ES post indexing jobs")) + rErr = multierror.Append(errors.Wrap(err, "failed to get monthly active user count")) } - elasticPostAggregationJobs, err := a.Srv().Store().Job().GetAllByTypePage(c, model.JobTypeElasticsearchPostAggregation, 0, 2) + + stats.DeactivatedUsers, err = a.Srv().Store().User().AnalyticsGetInactiveUsersCount() if err != nil { - rErr = multierror.Append(errors.Wrap(err, "error while getting ES post aggregation jobs")) + rErr = multierror.Append(errors.Wrap(err, "failed to get deactivated user count")) } - blevePostIndexingJobs, err := a.Srv().Store().Job().GetAllByTypePage(c, model.JobTypeBlevePostIndexing, 0, 2) + + stats.Guests, err = a.Srv().Store().User().AnalyticsGetGuestCount() if err != nil { - rErr = multierror.Append(errors.Wrap(err, "error while getting bleve post indexing jobs")) + rErr = multierror.Append(errors.Wrap(err, "failed to get guest count")) } - ldapSyncJobs, err := a.Srv().Store().Job().GetAllByTypePage(c, model.JobTypeLdapSync, 0, 2) + + stats.BotAccounts, err = a.Srv().Store().User().Count(model.UserCountOptions{IncludeBotAccounts: true, ExcludeRegularUsers: true}) if err != nil { - rErr = multierror.Append(errors.Wrap(err, "error while getting LDAP sync jobs")) + rErr = multierror.Append(errors.Wrap(err, "failed to get bot acount count")) } - migrationJobs, err := a.Srv().Store().Job().GetAllByTypePage(c, model.JobTypeMigrations, 0, 2) + + stats.Posts, err = a.Srv().Store().Post().AnalyticsPostCount(&model.PostCountOptions{}) if err != nil { - rErr = multierror.Append(errors.Wrap(err, "error while getting migration jobs")) + rErr = multierror.Append(errors.Wrap(err, "failed to get post count")) } - // Creating the struct for Support Packet yaml file - supportPacket := model.SupportPacket{ - /* Build information */ - ServerOS: runtime.GOOS, - ServerArchitecture: runtime.GOARCH, - ServerVersion: model.CurrentVersion, - BuildHash: model.BuildHash, + openChannels, err := a.Srv().Store().Channel().AnalyticsTypeCount("", model.ChannelTypeOpen) + if err != nil { + rErr = multierror.Append(errors.Wrap(err, "failed to get open channels count")) + } + privateChannels, err := a.Srv().Store().Channel().AnalyticsTypeCount("", model.ChannelTypePrivate) + if err != nil { + rErr = multierror.Append(errors.Wrap(err, "failed to get private channels count")) + } + stats.Channels = openChannels + privateChannels - /* DB */ - DatabaseType: databaseType, - DatabaseVersion: databaseVersion, - DatabaseSchemaVersion: databaseSchemaVersion, - WebsocketConnections: websocketConnections, - MasterDbConnections: masterDbConnections, - ReplicaDbConnections: replicaDbConnections, - - /* Cluster */ - ClusterID: clusterID, - - /* File store */ - FileDriver: fileDriver, - FileStatus: fileStatus, - - /* LDAP */ - LdapVendorName: vendorName, - LdapVendorVersion: vendorVersion, - - /* Elastic Search */ - ElasticServerVersion: elasticServerVersion, - ElasticServerPlugins: elasticServerPlugins, - - /* License */ - LicenseTo: licenseTo, - LicenseSupportedUsers: supportedUsers, - LicenseIsTrial: isTrial, - - /* Server stats */ - ActiveUsers: int(uniqueUserCount), - DailyActiveUsers: dailyActiveUsers, - MonthlyActiveUsers: monthlyActiveUsers, - InactiveUserCount: inactiveUserCount, - TotalPosts: totalPosts, - TotalChannels: totalChannels, - TotalTeams: totalTeams, - - /* Jobs */ - DataRetentionJobs: dataRetentionJobs, - MessageExportJobs: messageExportJobs, - ElasticPostIndexingJobs: elasticPostIndexingJobs, - ElasticPostAggregationJobs: elasticPostAggregationJobs, - BlevePostIndexingJobs: blevePostIndexingJobs, - LdapSyncJobs: ldapSyncJobs, - MigrationJobs: migrationJobs, + stats.Teams, err = a.Srv().Store().Team().AnalyticsTeamCount(nil) + if err != nil { + rErr = multierror.Append(errors.Wrap(err, "failed to get team count")) } - // Marshal to a YAML File - supportPacketYaml, err := yaml.Marshal(&supportPacket) + stats.SlashCommands, err = a.Srv().Store().Command().AnalyticsCommandCount("") + if err != nil { + rErr = multierror.Append(errors.Wrap(err, "failed to get command count")) + } + + stats.IncomingWebhooks, err = a.Srv().Store().Webhook().AnalyticsIncomingCount("", "") + if err != nil { + rErr = multierror.Append(errors.Wrap(err, "failed to get incoming webhook count")) + } + + stats.OutgoingWebhooks, err = a.Srv().Store().Webhook().AnalyticsOutgoingCount("") + if err != nil { + rErr = multierror.Append(errors.Wrap(err, "failed to get outgoing webhook count")) + } + + b, err := yaml.Marshal(&stats) if err != nil { rErr = multierror.Append(errors.Wrap(err, "failed to marshal Support Packet into yaml")) } fileData := &model.FileData{ - Filename: "support_packet.yaml", - Body: supportPacketYaml, + Filename: "stats.yaml", + Body: b, } return fileData, rErr.ErrorOrNil() } -func (a *App) createSanitizedConfigFile(_ request.CTX) (*model.FileData, error) { - // Getting sanitized config, prettifying it, and then adding it to our file data array - sanitizedConfigPrettyJSON, err := json.MarshalIndent(a.GetSanitizedConfig(), "", " ") +func (a *App) getSupportPacketJobList(rctx request.CTX) (*model.FileData, error) { + const numberOfJobsRuns = 5 + + var ( + rErr *multierror.Error + err error + jobs model.SupportPacketJobList + ) + + jobs.LDAPSyncJobs, err = a.Srv().Store().Job().GetAllByTypePage(rctx, model.JobTypeLdapSync, 0, numberOfJobsRuns) if err != nil { - return nil, errors.Wrap(err, "failed to sanitized config into json") + rErr = multierror.Append(errors.Wrap(err, "error while getting LDAP sync jobs")) + } + jobs.DataRetentionJobs, err = a.Srv().Store().Job().GetAllByTypePage(rctx, model.JobTypeDataRetention, 0, numberOfJobsRuns) + if err != nil { + rErr = multierror.Append(errors.Wrap(err, "error while getting data retention jobs")) + } + jobs.MessageExportJobs, err = a.Srv().Store().Job().GetAllByTypePage(rctx, model.JobTypeMessageExport, 0, numberOfJobsRuns) + if err != nil { + rErr = multierror.Append(errors.Wrap(err, "error while getting message export jobs")) + } + jobs.ElasticPostIndexingJobs, err = a.Srv().Store().Job().GetAllByTypePage(rctx, model.JobTypeElasticsearchPostIndexing, 0, numberOfJobsRuns) + if err != nil { + rErr = multierror.Append(errors.Wrap(err, "error while getting ES post indexing jobs")) + } + jobs.ElasticPostAggregationJobs, err = a.Srv().Store().Job().GetAllByTypePage(rctx, model.JobTypeElasticsearchPostAggregation, 0, numberOfJobsRuns) + if err != nil { + rErr = multierror.Append(errors.Wrap(err, "error while getting ES post aggregation jobs")) + } + jobs.BlevePostIndexingJobs, err = a.Srv().Store().Job().GetAllByTypePage(rctx, model.JobTypeBlevePostIndexing, 0, numberOfJobsRuns) + if err != nil { + rErr = multierror.Append(errors.Wrap(err, "error while getting bleve post indexing jobs")) + } + jobs.MigrationJobs, err = a.Srv().Store().Job().GetAllByTypePage(rctx, model.JobTypeMigrations, 0, numberOfJobsRuns) + if err != nil { + rErr = multierror.Append(errors.Wrap(err, "error while getting migration jobs")) + } + + b, err := yaml.Marshal(&jobs) + if err != nil { + rErr = multierror.Append(errors.Wrap(err, "failed to marshal jobs list into yaml")) } fileData := &model.FileData{ - Filename: "sanitized_config.json", - Body: sanitizedConfigPrettyJSON, + Filename: "jobs.yaml", + Body: b, } - return fileData, nil + return fileData, rErr.ErrorOrNil() } -func (a *App) createPluginsFile(_ request.CTX) (*model.FileData, error) { +func (a *App) getSupportPacketPermissionsInfo(_ request.CTX) (*model.FileData, error) { + var ( + rErr *multierror.Error + err error + permissions model.SupportPacketPermissionInfo + ) + + var allSchemes []*model.Scheme + perPage := 100 + page := 0 + for { + schemes, appErr := a.GetSchemesPage("", page, perPage) + if appErr != nil { + rErr = multierror.Append(errors.Wrap(appErr, "failed to get list of schemes")) + break + } + + allSchemes = append(allSchemes, schemes...) + if len(schemes) < perPage { + break + } + page++ + } + + for _, s := range allSchemes { + s.Sanitize() + } + permissions.Schemes = allSchemes + + roles, appErr := a.GetAllRoles() + if appErr != nil { + rErr = multierror.Append(errors.Wrap(appErr, "failed to get list of roles")) + } + + for _, r := range roles { + r.Sanitize() + } + permissions.Roles = roles + + b, err := yaml.Marshal(&permissions) + if err != nil { + rErr = multierror.Append(errors.Wrap(err, "failed to marshal permission info into yaml")) + } + + fileData := &model.FileData{ + Filename: "permissions.yaml", + Body: b, + } + return fileData, rErr.ErrorOrNil() +} + +func (a *App) getPluginsFile(_ request.CTX) (*model.FileData, error) { // Getting the plugins installed on the server, prettify it, and then add them to the file data array - pluginsResponse, appErr := a.GetPlugins() + plugins, appErr := a.GetPlugins() if appErr != nil { return nil, errors.Wrap(appErr, "failed to get plugin list for Support Packet") } - pluginsPrettyJSON, err := json.MarshalIndent(pluginsResponse, "", " ") + var pluginList model.SupportPacketPluginList + for _, p := range plugins.Active { + pluginList.Enabled = append(pluginList.Enabled, p.Manifest) + } + for _, p := range plugins.Inactive { + pluginList.Disabled = append(pluginList.Disabled, p.Manifest) + } + + pluginsPrettyJSON, err := json.MarshalIndent(pluginList, "", " ") if err != nil { return nil, errors.Wrap(err, "failed to marshal plugin list into json") } @@ -355,7 +344,7 @@ func (a *App) createPluginsFile(_ request.CTX) (*model.FileData, error) { return fileData, nil } -func (a *App) createSupportPacketMetadata(_ request.CTX) (*model.FileData, error) { +func (a *App) getSupportPacketMetadata(_ request.CTX) (*model.FileData, error) { metadata, err := model.GeneratePacketMetadata(model.SupportPacketType, a.TelemetryId(), a.License(), nil) if err != nil { return nil, errors.Wrap(err, "failed to generate Packet metadata") diff --git a/server/channels/app/support_packet_test.go b/server/channels/app/support_packet_test.go index 497a773a249..d7ce7f80976 100644 --- a/server/channels/app/support_packet_test.go +++ b/server/channels/app/support_packet_test.go @@ -4,200 +4,29 @@ package app import ( + "bytes" + "encoding/json" "errors" "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost/server/v8/channels/app/platform" smocks "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks" + "github.com/mattermost/mattermost/server/v8/channels/utils/fileutils" "github.com/mattermost/mattermost/server/v8/config" - emocks "github.com/mattermost/mattermost/server/v8/einterfaces/mocks" - fmocks "github.com/mattermost/mattermost/server/v8/platform/shared/filestore/mocks" ) -func TestCreatePluginsFile(t *testing.T) { - th := Setup(t) - defer th.TearDown() - - // Happy path where we have a plugins file with no err - fileData, err := th.App.createPluginsFile(th.Context) - require.NotNil(t, fileData) - assert.Equal(t, "plugins.json", fileData.Filename) - assert.Positive(t, len(fileData.Body)) - assert.NoError(t, err) - - // Turn off plugins so we can get an error - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.PluginSettings.Enable = false - }) - - // Plugins off in settings so no fileData and we get a warning instead - fileData, err = th.App.createPluginsFile(th.Context) - assert.Nil(t, fileData) - assert.ErrorContains(t, err, "failed to get plugin list for Support Packet") -} - -func TestGenerateSupportPacketYaml(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - - licenseUsers := 100 - license := model.NewTestLicense("ldap") - license.Features.Users = model.NewPointer(licenseUsers) - th.App.Srv().SetLicense(license) - - generateSupportPacket := func(t *testing.T) *model.SupportPacket { - t.Helper() - - fileData, err := th.App.generateSupportPacketYaml(th.Context) - require.NotNil(t, fileData) - assert.Equal(t, "support_packet.yaml", fileData.Filename) - assert.Positive(t, len(fileData.Body)) - assert.NoError(t, err) - - var packet model.SupportPacket - require.NoError(t, yaml.Unmarshal(fileData.Body, &packet)) - require.NotNil(t, packet) - return &packet - } - - t.Run("Happy path", func(t *testing.T) { - // Happy path where we have a Support Packet yaml file without any warnings - packet := generateSupportPacket(t) - - /* Build information */ - assert.NotEmpty(t, packet.ServerOS) - assert.NotEmpty(t, packet.ServerArchitecture) - assert.Equal(t, model.CurrentVersion, packet.ServerVersion) - // BuildHash is not present in tests - - /* DB */ - assert.NotEmpty(t, packet.DatabaseType) - assert.NotEmpty(t, packet.DatabaseVersion) - assert.NotEmpty(t, packet.DatabaseSchemaVersion) - assert.Zero(t, packet.WebsocketConnections) - assert.NotZero(t, packet.MasterDbConnections) - assert.Zero(t, packet.ReplicaDbConnections) - - /* Cluster */ - assert.Empty(t, packet.ClusterID) - - /* File store */ - assert.Equal(t, "local", packet.FileDriver) - assert.Equal(t, "OK", packet.FileStatus) - - /* LDAP */ - assert.Empty(t, packet.LdapVendorName) - assert.Empty(t, packet.LdapVendorVersion) - - /* Elastic Search */ - assert.Empty(t, packet.ElasticServerVersion) - assert.Empty(t, packet.ElasticServerPlugins) - - /* License */ - assert.Equal(t, "My awesome Company", packet.LicenseTo) - assert.Equal(t, licenseUsers, packet.LicenseSupportedUsers) - assert.Equal(t, false, packet.LicenseIsTrial) - - /* Server stats */ - assert.Equal(t, 3, packet.ActiveUsers) // from InitBasic() - assert.Equal(t, 0, packet.DailyActiveUsers) - assert.Equal(t, 0, packet.MonthlyActiveUsers) - assert.Equal(t, 0, packet.InactiveUserCount) - assert.Equal(t, 5, packet.TotalPosts) // from InitBasic() - assert.Equal(t, 3, packet.TotalChannels) // from InitBasic() - assert.Equal(t, 1, packet.TotalTeams) // from InitBasic() - - /* Jobs */ - assert.Empty(t, packet.DataRetentionJobs) - assert.Empty(t, packet.MessageExportJobs) - assert.Empty(t, packet.ElasticPostIndexingJobs) - assert.Empty(t, packet.ElasticPostAggregationJobs) - assert.Empty(t, packet.BlevePostIndexingJobs) - assert.Empty(t, packet.LdapSyncJobs) - assert.Empty(t, packet.MigrationJobs) - }) - - t.Run("post count should be present if number of users extends AnalyticsSettings.MaxUsersForStatistics", func(t *testing.T) { - th.App.UpdateConfig(func(cfg *model.Config) { - cfg.AnalyticsSettings.MaxUsersForStatistics = model.NewPointer(1) - }) - - for i := 0; i < 5; i++ { - p := th.CreatePost(th.BasicChannel) - require.NotNil(t, p) - } - - // InitBasic() already creats 5 posts - packet := generateSupportPacket(t) - assert.Equal(t, 10, packet.TotalPosts) - }) - - t.Run("filestore fails", func(t *testing.T) { - fb := &fmocks.FileBackend{} - platform.SetFileStore(fb)(th.Server.Platform()) - fb.On("DriverName").Return("mock") - fb.On("TestConnection").Return(errors.New("all broken")) - - packet := generateSupportPacket(t) - - assert.Equal(t, "mock", packet.FileDriver) - assert.Equal(t, "FAIL: all broken", packet.FileStatus) - }) - - t.Run("no LDAP info if LDAP sync is disabled", func(t *testing.T) { - ldapMock := &emocks.LdapInterface{} - th.App.Channels().Ldap = ldapMock - - packet := generateSupportPacket(t) - - assert.Equal(t, "", packet.LdapVendorName) - assert.Equal(t, "", packet.LdapVendorVersion) - }) - - th.App.UpdateConfig(func(cfg *model.Config) { - cfg.LdapSettings.EnableSync = model.NewPointer(true) - }) - - t.Run("no LDAP vendor info found", func(t *testing.T) { - ldapMock := &emocks.LdapInterface{} - ldapMock.On( - "GetVendorNameAndVendorVersion", - mock.AnythingOfType("*request.Context"), - ).Return("", "", nil) - th.App.Channels().Ldap = ldapMock - - packet := generateSupportPacket(t) - - assert.Equal(t, "unknown", packet.LdapVendorName) - assert.Equal(t, "unknown", packet.LdapVendorVersion) - }) - - t.Run("found LDAP vendor info", func(t *testing.T) { - ldapMock := &emocks.LdapInterface{} - ldapMock.On( - "GetVendorNameAndVendorVersion", - mock.AnythingOfType("*request.Context"), - ).Return("some vendor", "v1.0.0", nil) - th.App.Channels().Ldap = ldapMock - - packet := generateSupportPacket(t) - - assert.Equal(t, "some vendor", packet.LdapVendorName) - assert.Equal(t, "v1.0.0", packet.LdapVendorVersion) - }) -} - func TestGenerateSupportPacket(t *testing.T) { th := Setup(t) defer th.TearDown() + th.App.SetPhase2PermissionsMigrationStatus(true) + dir, err := os.MkdirTemp("", "") require.NoError(t, err) t.Cleanup(func() { @@ -222,29 +51,42 @@ func TestGenerateSupportPacket(t *testing.T) { } genMockLogFiles() - t.Run("generate Support Packet with logs", func(t *testing.T) { - fileDatas := th.App.GenerateSupportPacket(th.Context, &model.SupportPacketOptions{ - IncludeLogs: true, - }) + getFileNames := func(t *testing.T, fileDatas []model.FileData) []string { var rFileNames []string - testFiles := []string{ - "support_packet.yaml", - "metadata.yaml", - "plugins.json", - "sanitized_config.json", - "mattermost.log", - "notifications.log", - "cpu.prof", - "heap.prof", - "goroutines", - } for _, fileData := range fileDatas { require.NotNil(t, fileData) assert.Positive(t, len(fileData.Body)) rFileNames = append(rFileNames, fileData.Filename) } - assert.ElementsMatch(t, testFiles, rFileNames) + return rFileNames + } + + expectedFileNames := []string{ + "metadata.yaml", + "stats.yaml", + "jobs.yaml", + "permissions.yaml", + "plugins.json", + "sanitized_config.json", + "diagnostics.yaml", + "cpu.prof", + "heap.prof", + "goroutines", + } + + expectedFileNamesWithLogs := append(expectedFileNames, []string{ + "mattermost.log", + "notifications.log", + }...) + + t.Run("generate Support Packet with logs", func(t *testing.T) { + fileDatas := th.App.GenerateSupportPacket(th.Context, &model.SupportPacketOptions{ + IncludeLogs: true, + }) + rFileNames := getFileNames(t, fileDatas) + + assert.ElementsMatch(t, expectedFileNamesWithLogs, rFileNames) }) t.Run("generate Support Packet without logs", func(t *testing.T) { @@ -252,27 +94,12 @@ func TestGenerateSupportPacket(t *testing.T) { IncludeLogs: false, }) - testFiles := []string{ - "support_packet.yaml", - "metadata.yaml", - "plugins.json", - "sanitized_config.json", - "cpu.prof", - "heap.prof", - "goroutines", - } - var rFileNames []string - for _, fileData := range fileDatas { - require.NotNil(t, fileData) - assert.Positive(t, len(fileData.Body)) + rFileNames := getFileNames(t, fileDatas) - rFileNames = append(rFileNames, fileData.Filename) - } - assert.ElementsMatch(t, testFiles, rFileNames) + assert.ElementsMatch(t, expectedFileNames, rFileNames) }) t.Run("remove the log files and ensure that warning.txt file is generated", func(t *testing.T) { - // Remove these two files and ensure that warning.txt file is generated err = os.Remove(logLocation) require.NoError(t, err) err = os.Remove(notificationsLogLocation) @@ -282,24 +109,9 @@ func TestGenerateSupportPacket(t *testing.T) { fileDatas := th.App.GenerateSupportPacket(th.Context, &model.SupportPacketOptions{ IncludeLogs: true, }) - testFiles := []string{ - "support_packet.yaml", - "metadata.yaml", - "plugins.json", - "sanitized_config.json", - "cpu.prof", - "heap.prof", - "warning.txt", - "goroutines", - } - var rFileNames []string - for _, fileData := range fileDatas { - require.NotNil(t, fileData) - assert.Positive(t, len(fileData.Body)) + rFileNames := getFileNames(t, fileDatas) - rFileNames = append(rFileNames, fileData.Filename) - } - assert.ElementsMatch(t, testFiles, rFileNames) + assert.ElementsMatch(t, append(expectedFileNames, "warning.txt"), rFileNames) }) t.Run("steps that generated an error should still return file data", func(t *testing.T) { @@ -320,45 +132,536 @@ func TestGenerateSupportPacket(t *testing.T) { mockStore.On("Webhook").Return(th.App.Srv().Store().Webhook()) mockStore.On("System").Return(th.App.Srv().Store().System()) mockStore.On("License").Return(th.App.Srv().Store().License()) + mockStore.On("Command").Return(th.App.Srv().Store().Command()) + mockStore.On("Role").Return(th.App.Srv().Store().Role()) + mockStore.On("Scheme").Return(th.App.Srv().Store().Scheme()) mockStore.On("Close").Return(nil) mockStore.On("GetDBSchemaVersion").Return(1, nil) mockStore.On("GetDbVersion", false).Return("1.0.0", nil) + mockStore.On("TotalMasterDbConnections").Return(30) + mockStore.On("TotalReadDbConnections").Return(20) + mockStore.On("TotalSearchDbConnections").Return(10) + + oldStore := th.App.Srv().Store() + t.Cleanup(func() { + th.App.Srv().SetStore(oldStore) + }) th.App.Srv().SetStore(&mockStore) fileDatas := th.App.GenerateSupportPacket(th.Context, &model.SupportPacketOptions{ IncludeLogs: false, }) + rFileNames := getFileNames(t, fileDatas) - var rFileNames []string - for _, fileData := range fileDatas { - require.NotNil(t, fileData) - assert.Positive(t, len(fileData.Body)) - - rFileNames = append(rFileNames, fileData.Filename) - } assert.Contains(t, rFileNames, "warning.txt") - assert.Contains(t, rFileNames, "support_packet.yaml") + assert.Contains(t, rFileNames, "stats.yaml") + assert.ElementsMatch(t, append(expectedFileNames, "warning.txt"), rFileNames) + }) + + pluginID := "testplugin" + pluginCode := ` + package main + import ( + "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/model" + ) + + type TestPlugin struct { + plugin.MattermostPlugin + } + + func (p *TestPlugin) GenerateSupportData(c *plugin.Context) ([]*model.FileData, error) { + return []*model.FileData{{ + Filename: "testplugin/diagnostics.yaml", + Body: []byte("foo"), + }}, nil + } + + func main() { + plugin.ClientMain(&TestPlugin{}) + }` + + t.Run("Support Packet always contains plugin data if the plugin doesn't define the support_packet prop", func(t *testing.T) { + pluginManifest := `{"id": "testplugin", "server": {"executable": "backend.exe"}}` + setupPluginAPITest(t, pluginCode, pluginManifest, pluginID, th.App, th.Context) + t.Cleanup(func() { + appErr := th.App.ch.RemovePlugin(pluginID) + require.Nil(t, appErr) + }) + + fileDatas := th.App.GenerateSupportPacket(th.Context, &model.SupportPacketOptions{ + IncludeLogs: false, + }) + rFileNames := getFileNames(t, fileDatas) + + assert.ElementsMatch(t, append(expectedFileNames, "testplugin/diagnostics.yaml"), rFileNames) + }) + + t.Run("Support Packet contains plugin data if the plugin defines the support_packet prop and it gets queried", func(t *testing.T) { + pluginManifest := `{"id": "testplugin", "server": {"executable": "backend.exe"}, "props": {"support_packet": "some text"}}` + setupPluginAPITest(t, pluginCode, pluginManifest, pluginID, th.App, th.Context) + t.Cleanup(func() { + appErr := th.App.ch.RemovePlugin(pluginID) + require.Nil(t, appErr) + }) + + fileDatas := th.App.GenerateSupportPacket(th.Context, &model.SupportPacketOptions{ + IncludeLogs: false, + PluginPackets: []string{pluginID}, + }) + rFileNames := getFileNames(t, fileDatas) + + assert.ElementsMatch(t, append(expectedFileNames, "testplugin/diagnostics.yaml"), rFileNames) + }) + + t.Run("Support Packet doesn't contain plugin data if the plugin defines the support_packet prop and it doesn't get queried", func(t *testing.T) { + pluginManifest := `{"id": "testplugin", "server": {"executable": "backend.exe"}, "props": {"support_packet": "some text"}}` + setupPluginAPITest(t, pluginCode, pluginManifest, pluginID, th.App, th.Context) + t.Cleanup(func() { + appErr := th.App.ch.RemovePlugin(pluginID) + require.Nil(t, appErr) + }) + + fileDatas := th.App.GenerateSupportPacket(th.Context, &model.SupportPacketOptions{ + IncludeLogs: false, + }) + rFileNames := getFileNames(t, fileDatas) + + assert.ElementsMatch(t, expectedFileNames, rFileNames) + }) + + t.Run("Plugin config values in the Support Packet are obfuscated, if the plugin marks them as secrets", func(t *testing.T) { + pluginManifest := `{"id": "testplugin", "server": {"executable": "backend.exe"}, "settings_schema": {"settings": [{"key": "foo", "type": "text"}, {"key": "bar", "type": "text", "secret": true}]}}` + setupPluginAPITest(t, pluginCode, pluginManifest, pluginID, th.App, th.Context) + t.Cleanup(func() { + appErr := th.App.ch.RemovePlugin(pluginID) + require.Nil(t, appErr) + }) + + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.PluginSettings.Plugins[pluginID] = map[string]any{ + "foo": "foo_value", + "bar": "bar_value", + } + }) + + fileDatas := th.App.GenerateSupportPacket(th.Context, &model.SupportPacketOptions{ + IncludeLogs: false, + }) + + found := false + for _, f := range fileDatas { + if f.Filename != "sanitized_config.json" { + continue + } + + var config model.Config + err = json.Unmarshal(f.Body, &config) + require.NoError(t, err) + + assert.Equal(t, "foo_value", config.PluginSettings.Plugins[pluginID]["foo"]) + assert.Equal(t, model.FakeSetting, config.PluginSettings.Plugins[pluginID]["bar"]) + + found = true + } + assert.True(t, found) }) } -func TestCreateSanitizedConfigFile(t *testing.T) { +func TestGetPluginsFile(t *testing.T) { th := Setup(t) defer th.TearDown() - // Happy path where we have a sanitized config file with no err - fileData, err := th.App.createSanitizedConfigFile(th.Context) - require.NotNil(t, fileData) - assert.Equal(t, "sanitized_config.json", fileData.Filename) - assert.Positive(t, len(fileData.Body)) - assert.NoError(t, err) + getJobList := func(t *testing.T) *model.SupportPacketPluginList { + t.Helper() + + fileData, err := th.App.getPluginsFile(th.Context) + assert.NoError(t, err) + require.NotNil(t, fileData) + assert.Equal(t, "plugins.json", fileData.Filename) + assert.Positive(t, len(fileData.Body)) + + var pl model.SupportPacketPluginList + err = json.Unmarshal(fileData.Body, &pl) + require.NoError(t, err) + + return &pl + } + + t.Run("no errors if no plugins are installed", func(t *testing.T) { + pl := getJobList(t) + assert.Len(t, pl.Enabled, 0) + assert.Len(t, pl.Disabled, 0) + }) + + t.Run("two plugins are installed", func(t *testing.T) { + path, _ := fileutils.FindDir("tests") + + bundle1, err := os.ReadFile(filepath.Join(path, "testplugin.tar.gz")) + require.NoError(t, err) + manifest1, appErr := th.App.InstallPlugin(bytes.NewReader(bundle1), false) + require.Nil(t, appErr) + require.Equal(t, "testplugin", manifest1.Id) + appErr = th.App.EnablePlugin(manifest1.Id) + require.Nil(t, appErr) + + bundle2, err := os.ReadFile(filepath.Join(path, "testplugin2.tar.gz")) + require.NoError(t, err) + manifest2, appErr := th.App.InstallPlugin(bytes.NewReader(bundle2), false) + require.Nil(t, appErr) + require.Equal(t, "testplugin2", manifest2.Id) + + pl := getJobList(t) + require.Len(t, pl.Enabled, 1) + assert.Equal(t, "testplugin", pl.Enabled[0].Id) + require.Len(t, pl.Disabled, 1) + assert.Equal(t, "testplugin2", pl.Disabled[0].Id) + }) + + t.Run("error if plugin are disabled", func(t *testing.T) { + // Turn off plugins so we can get an error + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.Enable = false + }) + + // Plugins off in settings so no fileData and we get a warning instead + fileData, err := th.App.getPluginsFile(th.Context) + assert.Nil(t, fileData) + assert.ErrorContains(t, err, "failed to get plugin list for Support Packet") + }) } -func TestCreateSupportPacketMetadata(t *testing.T) { +func TestGetSupportPacketStats(t *testing.T) { + th := Setup(t) + + generateStats := func(t *testing.T) *model.SupportPacketStats { + t.Helper() + + fileData, err := th.App.getSupportPacketStats(th.Context) + require.NotNil(t, fileData) + assert.Equal(t, "stats.yaml", fileData.Filename) + assert.Positive(t, len(fileData.Body)) + assert.NoError(t, err) + + var packet model.SupportPacketStats + err = yaml.Unmarshal(fileData.Body, &packet) + require.NoError(t, err) + + return &packet + } + + t.Run("fresh server", func(t *testing.T) { + sp := generateStats(t) + + assert.Equal(t, int64(0), sp.RegisteredUsers) + assert.Equal(t, int64(0), sp.ActiveUsers) + assert.Equal(t, int64(0), sp.DailyActiveUsers) + assert.Equal(t, int64(0), sp.MonthlyActiveUsers) + assert.Equal(t, int64(0), sp.DeactivatedUsers) + assert.Equal(t, int64(0), sp.Guests) + assert.Equal(t, int64(0), sp.BotAccounts) + assert.Equal(t, int64(0), sp.Posts) + assert.Equal(t, int64(0), sp.Channels) + assert.Equal(t, int64(0), sp.Teams) + assert.Equal(t, int64(0), sp.SlashCommands) + assert.Equal(t, int64(0), sp.IncomingWebhooks) + assert.Equal(t, int64(0), sp.OutgoingWebhooks) + }) + + t.Run("Happy path", func(t *testing.T) { + var user *model.User + for i := 0; i < 4; i++ { + user = th.CreateUser() + } + th.BasicUser = user + + for i := 0; i < 3; i++ { + deactivatedUser := th.CreateUser() + require.NotNil(t, deactivatedUser) + _, appErr := th.App.UpdateActive(th.Context, deactivatedUser, false) + require.Nil(t, appErr) + } + + for i := 0; i < 2; i++ { + guest := th.CreateGuest() + require.NotNil(t, guest) + } + + th.CreateBot() + + team := th.CreateTeam() + channel := th.CreateChannel(th.Context, team) + + for i := 0; i < 3; i++ { + p := th.CreatePost(channel) + require.NotNil(t, p) + } + + cmd, appErr := th.App.CreateCommand(&model.Command{ + CreatorId: user.Id, + TeamId: team.Id, + Trigger: "test", + Method: model.CommandMethodGet, + URL: "http://nowhere.com/", + }) + require.Nil(t, appErr) + require.NotNil(t, cmd) + + webhookIn, appErr := th.App.CreateIncomingWebhookForChannel(user.Id, channel, &model.IncomingWebhook{ChannelId: channel.Id}) + require.Nil(t, appErr) + require.NotNil(t, webhookIn) + + webhookOut, appErr := th.App.CreateOutgoingWebhook(&model.OutgoingWebhook{ + ChannelId: channel.Id, + TeamId: channel.TeamId, + CreatorId: th.BasicUser.Id, + CallbackURLs: []string{"http://nowhere.com/"}, + }) + require.Nil(t, appErr) + require.NotNil(t, webhookOut) + + sp := generateStats(t) + + assert.Equal(t, int64(9), sp.RegisteredUsers) + assert.Equal(t, int64(6), sp.ActiveUsers) + assert.Equal(t, int64(0), sp.DailyActiveUsers) + assert.Equal(t, int64(0), sp.MonthlyActiveUsers) + assert.Equal(t, int64(3), sp.DeactivatedUsers) + assert.Equal(t, int64(2), sp.Guests) + assert.Equal(t, int64(1), sp.BotAccounts) + assert.Equal(t, int64(4), sp.Posts) // 1 from the bot creation and 3 created directly + assert.Equal(t, int64(3), sp.Channels) // 2 from the team creation and 1 created directly + assert.Equal(t, int64(1), sp.Teams) + assert.Equal(t, int64(1), sp.SlashCommands) + assert.Equal(t, int64(1), sp.IncomingWebhooks) + assert.Equal(t, int64(1), sp.OutgoingWebhooks) + }) + + // Reset test server + th.TearDown() + th = Setup(t).InitBasic() + defer th.TearDown() + + t.Run("post count should be present if number of users extends AnalyticsSettings.MaxUsersForStatistics", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.AnalyticsSettings.MaxUsersForStatistics = model.NewPointer(1) + }) + + for i := 0; i < 5; i++ { + p := th.CreatePost(th.BasicChannel) + require.NotNil(t, p) + } + + // InitBasic() already creats 5 posts + packet := generateStats(t) + assert.Equal(t, int64(10), packet.Posts) + }) +} + +func TestGetSupportPacketJobList(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + getJobList := func(t *testing.T) *model.SupportPacketJobList { + t.Helper() + + fileData, err := th.App.getSupportPacketJobList(th.Context) + require.NoError(t, err) + require.NotNil(t, fileData) + assert.Equal(t, "jobs.yaml", fileData.Filename) + assert.Positive(t, len(fileData.Body)) + + var jobs model.SupportPacketJobList + err = yaml.Unmarshal(fileData.Body, &jobs) + require.NoError(t, err) + + return &jobs + } + + t.Run("no jobs run yet", func(t *testing.T) { + jobs := getJobList(t) + + assert.Empty(t, jobs.LDAPSyncJobs) + assert.Empty(t, jobs.DataRetentionJobs) + assert.Empty(t, jobs.MessageExportJobs) + assert.Empty(t, jobs.ElasticPostIndexingJobs) + assert.Empty(t, jobs.ElasticPostAggregationJobs) + assert.Empty(t, jobs.BlevePostIndexingJobs) + assert.Empty(t, jobs.MigrationJobs) + }) + + t.Run("jobs exist", func(t *testing.T) { + getJob := func(jobType string) *model.Job { + return &model.Job{ + Id: model.NewId(), + Type: jobType, + Priority: 1, + CreateAt: model.GetMillis() - 1, + StartAt: model.GetMillis(), + LastActivityAt: model.GetMillis() + 1, + Status: model.JobStatusPending, + Progress: 51, + Data: model.StringMap{"key": "value"}, + } + } + // Create some jobs + jobsToCreate := []*model.Job{ + getJob(model.JobTypeLdapSync), + getJob(model.JobTypeDataRetention), + getJob(model.JobTypeMessageExport), + getJob(model.JobTypeElasticsearchPostIndexing), + getJob(model.JobTypeElasticsearchPostAggregation), + getJob(model.JobTypeBlevePostIndexing), + getJob(model.JobTypeMigrations), + } + + var expectedJobs []*model.Job + for _, job := range jobsToCreate { + // Create the job using the store directly. + // Creating the job at the job service would error as the workers require enterprise code. + rJob, err := th.App.Srv().Store().Job().Save(job) + require.NoError(t, err) + + expectedJobs = append(expectedJobs, rJob) + } + + jobs := getJobList(t) + + // Helper to verify job content matches + verifyJob := func(t *testing.T, expected, actual *model.Job) { + t.Helper() + assert.Equal(t, expected.Id, actual.Id) + assert.Equal(t, expected.Type, actual.Type) + assert.Equal(t, expected.Priority, actual.Priority) + assert.Equal(t, expected.CreateAt, actual.CreateAt) + assert.Equal(t, expected.StartAt, actual.StartAt) + assert.Equal(t, expected.LastActivityAt, actual.LastActivityAt) + assert.Equal(t, expected.Status, actual.Status) + assert.Equal(t, expected.Progress, actual.Progress) + assert.Equal(t, expected.Data, actual.Data) + } + + // Verify LDAP sync jobs + require.Len(t, jobs.LDAPSyncJobs, 1, "Should have 1 LDAP sync job") + verifyJob(t, expectedJobs[0], jobs.LDAPSyncJobs[0]) + + // Verify data retention jobs + require.Len(t, jobs.DataRetentionJobs, 1, "Should have 1 data retention job") + verifyJob(t, expectedJobs[1], jobs.DataRetentionJobs[0]) + + // Verify message export jobs + require.Len(t, jobs.MessageExportJobs, 1, "Should have 1 message export job") + verifyJob(t, expectedJobs[2], jobs.MessageExportJobs[0]) + + // Verify elasticsearch post indexing jobs + require.Len(t, jobs.ElasticPostIndexingJobs, 1, "Should have 1 elasticsearch post indexing job") + verifyJob(t, expectedJobs[3], jobs.ElasticPostIndexingJobs[0]) + + // Verify elasticsearch post aggregation jobs + require.Len(t, jobs.ElasticPostAggregationJobs, 1, "Should have 1 elasticsearch post aggregation job") + verifyJob(t, expectedJobs[4], jobs.ElasticPostAggregationJobs[0]) + + // Verify bleve post indexing jobs + require.Len(t, jobs.BlevePostIndexingJobs, 1, "Should have 1 bleve post indexing job") + verifyJob(t, expectedJobs[5], jobs.BlevePostIndexingJobs[0]) + + // Verify migration jobs + require.Len(t, jobs.MigrationJobs, 1, "Should have 1 migration job") + verifyJob(t, expectedJobs[6], jobs.MigrationJobs[0]) + }) +} + +func TestGetSupportPacketPermissionsInfo(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.SetPhase2PermissionsMigrationStatus(true) + + generatePermissionInfo := func(t *testing.T) *model.SupportPacketPermissionInfo { + t.Helper() + + fileData, err := th.App.getSupportPacketPermissionsInfo(th.Context) + require.NotNil(t, fileData) + assert.Equal(t, "permissions.yaml", fileData.Filename) + assert.Positive(t, len(fileData.Body)) + assert.NoError(t, err) + + var permissions model.SupportPacketPermissionInfo + err = yaml.Unmarshal(fileData.Body, &permissions) + require.NoError(t, err) + + return &permissions + } + + t.Run("No custom permissions", func(t *testing.T) { + permissions := generatePermissionInfo(t) + + assert.Len(t, permissions.Roles, 23) + assert.Empty(t, permissions.Schemes) + }) + + scheme, appErr := th.App.CreateScheme(&model.Scheme{ + Name: "custom_scheme", + DisplayName: "Custom Scheme", + Scope: model.SchemeScopeTeam, + }) + require.Nil(t, appErr) + + t.Run("with custom scheme", func(t *testing.T) { + permissions := generatePermissionInfo(t) + + assert.Len(t, permissions.Roles, 33) // 23 default roles + 10 custom roles from the scheme + require.Len(t, permissions.Schemes, 1) + assert.Equal(t, scheme.Id, permissions.Schemes[0].Id) + assert.Equal(t, model.FakeSetting, permissions.Schemes[0].Name, "Name should be obfuscated") + assert.Equal(t, model.FakeSetting, permissions.Schemes[0].DisplayName, "DisplayName should be obfuscated") + assert.Equal(t, model.FakeSetting, permissions.Schemes[0].Description, "Description should be obfuscated") + }) + + t.Run("with custom role", func(t *testing.T) { + role, appErr := th.App.CreateRole(&model.Role{ + Name: "custom_role", + DisplayName: "Custom Role", + }) + require.Nil(t, appErr) + t.Cleanup(func() { + _, appErr := th.App.DeleteRole(role.Id) + require.Nil(t, appErr) + }) + + permissions := generatePermissionInfo(t) + + require.Len(t, permissions.Schemes, 1) + require.Len(t, permissions.Roles, 34) // 23 default roles + 10 custom roles from the scheme + 1 custom role + found := false + for _, r := range permissions.Roles { + // Confirm that sensitive fields are obfuscated + assert.Equal(t, model.FakeSetting, r.DisplayName, "DisplayName should be obfuscated") + assert.Equal(t, model.FakeSetting, r.Description, "Description should be obfuscated") + + // Fine the custom role + if r.Id == role.Id { + assert.Equal(t, role.Name, r.Name) + assert.Equal(t, role.CreateAt, r.CreateAt) + assert.Equal(t, role.UpdateAt, r.UpdateAt) + assert.Equal(t, role.DeleteAt, r.DeleteAt) + assert.Equal(t, role.Permissions, r.Permissions) + assert.Equal(t, role.SchemeManaged, r.SchemeManaged) + assert.Equal(t, role.BuiltIn, r.BuiltIn) + found = true + break + } + } + assert.True(t, found) + }) +} + +func TestGetSupportPacketMetadata(t *testing.T) { th := Setup(t) defer th.TearDown() t.Run("Happy path", func(t *testing.T) { - fileData, err := th.App.createSupportPacketMetadata(th.Context) + fileData, err := th.App.getSupportPacketMetadata(th.Context) require.NoError(t, err) require.NotNil(t, fileData) assert.Equal(t, "metadata.yaml", fileData.Filename) diff --git a/server/cmd/mmctl/client/client.go b/server/cmd/mmctl/client/client.go index 55ef8635888..2b52b7b9518 100644 --- a/server/cmd/mmctl/client/client.go +++ b/server/cmd/mmctl/client/client.go @@ -153,7 +153,7 @@ type Client interface { DownloadExport(ctx context.Context, name string, wr io.Writer, offset int64) (int64, *model.Response, error) GeneratePresignedURL(ctx context.Context, name string) (*model.PresignURLResponse, *model.Response, error) ResetSamlAuthDataToEmail(ctx context.Context, includeDeleted bool, dryRun bool, userIDs []string) (int64, *model.Response, error) - GenerateSupportPacket(ctx context.Context) ([]byte, *model.Response, error) + GenerateSupportPacket(ctx context.Context) (io.ReadCloser, string, *model.Response, error) GetOAuthApps(ctx context.Context, page, perPage int) ([]*model.OAuthApp, *model.Response, error) GetPreferences(ctx context.Context, userId string) (model.Preferences, *model.Response, error) GetPreferencesByCategory(ctx context.Context, userId, category string) (model.Preferences, *model.Response, error) diff --git a/server/cmd/mmctl/commands/system.go b/server/cmd/mmctl/commands/system.go index d29b7674e03..e96c7623021 100644 --- a/server/cmd/mmctl/commands/system.go +++ b/server/cmd/mmctl/commands/system.go @@ -6,8 +6,8 @@ package commands import ( "context" "fmt" + "io" "os" - "time" "github.com/pkg/errors" "github.com/spf13/cobra" @@ -81,7 +81,7 @@ func init() { SystemSetBusyCmd.Flags().UintP("seconds", "s", 3600, "Number of seconds until server is automatically marked as not busy.") _ = SystemSetBusyCmd.MarkFlagRequired("seconds") - SystemSupportPacketCmd.Flags().StringP("output-file", "o", "", "Output file name (default \"mattermost_support_packet_YYYY-MM-DD-HH-MM.zip\")") + SystemSupportPacketCmd.Flags().StringP("output-file", "o", "", "Define the output file name") SystemCmd.AddCommand( SystemGetBusyCmd, @@ -180,23 +180,23 @@ func systemSupportPacketCmdF(c client.Client, cmd *cobra.Command, _ []string) er return err } - if filename == "" { - filename = fmt.Sprintf("mattermost_support_packet_%s.zip", time.Now().Format("2006-01-02-03-04")) - } - printer.Print("Downloading Support Packet") - data, _, err := c.GenerateSupportPacket(context.TODO()) + data, rFilename, _, err := c.GenerateSupportPacket(context.TODO()) if err != nil { return fmt.Errorf("unable to fetch Support Packet: %w", err) } + if filename == "" { + filename = rFilename + } + file, err := os.Create(filename) if err != nil { return fmt.Errorf("failed to create zip file: %w", err) } - _, err = file.Write(data) + _, err = io.Copy(file, data) if err != nil { return fmt.Errorf("failed to write to zip file: %w", err) } diff --git a/server/cmd/mmctl/commands/system_e2e_test.go b/server/cmd/mmctl/commands/system_e2e_test.go index 6c7d785d302..50c971c3058 100644 --- a/server/cmd/mmctl/commands/system_e2e_test.go +++ b/server/cmd/mmctl/commands/system_e2e_test.go @@ -115,8 +115,6 @@ func (s *MmctlE2ETestSuite) TestSupportPacketCmdF() { s.Run("Download Support Packet with default filename", func() { printer.Clean() - s.T().Cleanup(cleanupSupportPacket(s.T())) - err := systemSupportPacketCmdF(s.th.SystemAdminClient, SystemSupportPacketCmd, []string{}) s.Require().NoError(err) s.Require().Len(printer.GetLines(), 2) @@ -129,12 +127,17 @@ func (s *MmctlE2ETestSuite) TestSupportPacketCmdF() { entries, err := os.ReadDir(".") s.Require().NoError(err) for _, e := range entries { - if strings.HasPrefix(e.Name(), "mattermost_support_packet_") && strings.HasSuffix(e.Name(), ".zip") { + if strings.HasPrefix(e.Name(), "mm_support_packet_") && strings.HasSuffix(e.Name(), ".zip") { b, err := os.ReadFile(e.Name()) s.NoError(err) s.NotEmpty(b, b) + s.T().Cleanup(func() { + err = os.Remove(e.Name()) + s.Require().NoError(err) + }) + found = true } } @@ -144,14 +147,16 @@ func (s *MmctlE2ETestSuite) TestSupportPacketCmdF() { s.Run("Download Support Packet with custom filename", func() { printer.Clean() - err := SystemSupportPacketCmd.ParseFlags([]string{"-o", "foo.zip"}) + systemSupportPacketCmd := &cobra.Command{} + systemSupportPacketCmd.Flags().StringP("output-file", "o", "", "Define the output file name") + err := systemSupportPacketCmd.ParseFlags([]string{"-o", "foo.zip"}) s.Require().NoError(err) - s.T().Cleanup(func() { + defer func() { s.Require().NoError(os.Remove("foo.zip")) - }) + }() - err = systemSupportPacketCmdF(s.th.SystemAdminClient, SystemSupportPacketCmd, []string{}) + err = systemSupportPacketCmdF(s.th.SystemAdminClient, systemSupportPacketCmd, []string{}) s.Require().NoError(err) s.Require().Len(printer.GetErrorLines(), 0) s.Require().Len(printer.GetLines(), 2) diff --git a/server/cmd/mmctl/commands/system_test.go b/server/cmd/mmctl/commands/system_test.go index c42d275caf6..c4629bd68ed 100644 --- a/server/cmd/mmctl/commands/system_test.go +++ b/server/cmd/mmctl/commands/system_test.go @@ -5,19 +5,17 @@ package commands import ( "context" + "io" "net/http" "os" "strconv" "strings" - "testing" "time" - "github.com/mattermost/mattermost/server/public/model" "github.com/pkg/errors" "github.com/spf13/cobra" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer" ) @@ -221,19 +219,6 @@ func (s *MmctlUnitTestSuite) TestServerStatusCmd() { }) } -func cleanupSupportPacket(t *testing.T) func() { - return func() { - entries, err := os.ReadDir(".") - require.NoError(t, err) - for _, e := range entries { - if strings.HasPrefix(e.Name(), "mattermost_support_packet_") && strings.HasSuffix(e.Name(), ".zip") { - err = os.Remove(e.Name()) - assert.NoError(t, err) - } - } - } -} - func (s *MmctlUnitTestSuite) TestSupportPacketCmdF() { printer.SetFormat(printer.FormatPlain) s.T().Cleanup(func() { printer.SetFormat(printer.FormatJSON) }) @@ -241,57 +226,51 @@ func (s *MmctlUnitTestSuite) TestSupportPacketCmdF() { s.Run("Download Support Packet with default filename", func() { printer.Clean() - s.T().Cleanup(cleanupSupportPacket(s.T())) - - data := []byte("some bytes") + reader := io.NopCloser(strings.NewReader("some bytes")) s.client. EXPECT(). GenerateSupportPacket(context.TODO()). - Return(data, &model.Response{}, nil). + Return(reader, "mm_support_packet.zip", &model.Response{}, nil). Times(1) + defer func() { + err := os.Remove("mm_support_packet.zip") + s.NoError(err) + }() + err := systemSupportPacketCmdF(s.client, SystemSupportPacketCmd, []string{}) s.Require().NoError(err) s.Require().Len(printer.GetErrorLines(), 0) s.Require().Len(printer.GetLines(), 2) s.Require().Equal(printer.GetLines()[0], "Downloading Support Packet") - s.Require().Contains(printer.GetLines()[1], "Downloaded Support Packet to ") + s.Require().Equal(printer.GetLines()[1], "Downloaded Support Packet to mm_support_packet.zip") - var found bool - - entries, err := os.ReadDir(".") - s.Require().NoError(err) - for _, e := range entries { - if strings.HasPrefix(e.Name(), "mattermost_support_packet_") && strings.HasSuffix(e.Name(), ".zip") { - b, err := os.ReadFile(e.Name()) - s.NoError(err) - s.Equal(b, data) - - found = true - } - } - - s.True(found) + b, err := os.ReadFile("mm_support_packet.zip") + s.NoError(err) + s.Equal(b, []byte("some bytes")) }) s.Run("Download Support Packet with custom filename", func() { printer.Clean() - data := []byte("some bytes") + reader := io.NopCloser(strings.NewReader("some bytes")) s.client. EXPECT(). GenerateSupportPacket(context.TODO()). - Return(data, &model.Response{}, nil). + Return(reader, "mm_support_packet.zip", &model.Response{}, nil). Times(1) - err := SystemSupportPacketCmd.ParseFlags([]string{"-o", "foo.zip"}) + systemSupportPacketCmd := &cobra.Command{} + systemSupportPacketCmd.Flags().StringP("output-file", "o", "", "Define the output file name") + err := systemSupportPacketCmd.ParseFlags([]string{"-o", "foo.zip"}) s.Require().NoError(err) - s.T().Cleanup(func() { - s.Require().NoError(os.Remove("foo.zip")) - }) + defer func() { + err = os.Remove("foo.zip") + s.Require().NoError(err) + }() - err = systemSupportPacketCmdF(s.client, SystemSupportPacketCmd, []string{}) + err = systemSupportPacketCmdF(s.client, systemSupportPacketCmd, []string{}) s.Require().NoError(err) s.Require().Len(printer.GetErrorLines(), 0) s.Require().Len(printer.GetLines(), 2) @@ -300,7 +279,7 @@ func (s *MmctlUnitTestSuite) TestSupportPacketCmdF() { b, err := os.ReadFile("foo.zip") s.Require().NoError(err) - s.Equal(b, data) + s.Equal(string(b), "some bytes") }) s.Run("Request to the server fails", func() { @@ -309,7 +288,7 @@ func (s *MmctlUnitTestSuite) TestSupportPacketCmdF() { s.client. EXPECT(). GenerateSupportPacket(context.TODO()). - Return(nil, &model.Response{}, errors.New("mock error")). + Return(nil, "", &model.Response{}, errors.New("mock error")). Times(1) err := systemSupportPacketCmdF(s.client, SystemSupportPacketCmd, []string{}) diff --git a/server/cmd/mmctl/docs/mmctl_system_supportpacket.rst b/server/cmd/mmctl/docs/mmctl_system_supportpacket.rst index 31edd250533..59418bbed6a 100644 --- a/server/cmd/mmctl/docs/mmctl_system_supportpacket.rst +++ b/server/cmd/mmctl/docs/mmctl_system_supportpacket.rst @@ -28,7 +28,7 @@ Options :: -h, --help help for supportpacket - -o, --output-file string Output file name (default "mattermost_support_packet_YYYY-MM-DD-HH-MM.zip") + -o, --output-file string Define the output file name Options inherited from parent commands ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/server/cmd/mmctl/mocks/client_mock.go b/server/cmd/mmctl/mocks/client_mock.go index 99ea8eee8aa..aabd3f95f93 100644 --- a/server/cmd/mmctl/mocks/client_mock.go +++ b/server/cmd/mmctl/mocks/client_mock.go @@ -572,13 +572,14 @@ func (mr *MockClientMockRecorder) GeneratePresignedURL(arg0, arg1 interface{}) * } // GenerateSupportPacket mocks base method. -func (m *MockClient) GenerateSupportPacket(arg0 context.Context) ([]byte, *model.Response, error) { +func (m *MockClient) GenerateSupportPacket(arg0 context.Context) (io.ReadCloser, string, *model.Response, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GenerateSupportPacket", arg0) - ret0, _ := ret[0].([]byte) - ret1, _ := ret[1].(*model.Response) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 + ret0, _ := ret[0].(io.ReadCloser) + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(*model.Response) + ret3, _ := ret[3].(error) + return ret0, ret1, ret2, ret3 } // GenerateSupportPacket indicates an expected call of GenerateSupportPacket. diff --git a/server/einterfaces/ldap.go b/server/einterfaces/ldap.go index bf4b0420bdd..cfbcfb9820d 100644 --- a/server/einterfaces/ldap.go +++ b/server/einterfaces/ldap.go @@ -17,7 +17,6 @@ type LdapInterface interface { CheckProviderAttributes(c request.CTX, LS *model.LdapSettings, ouser *model.User, patch *model.UserPatch) string SwitchToLdap(c request.CTX, userID, ldapID, ldapPassword string) *model.AppError StartSynchronizeJob(c request.CTX, waitForJobToFinish bool, includeRemovedMembers bool) (*model.Job, *model.AppError) - RunTest(rctx request.CTX) *model.AppError GetAllLdapUsers(c request.CTX) ([]*model.User, *model.AppError) MigrateIDAttribute(c request.CTX, toAttribute string) error GetGroup(rctx request.CTX, groupUID string) (*model.Group, *model.AppError) @@ -26,5 +25,9 @@ type LdapInterface interface { UpdateProfilePictureIfNecessary(request.CTX, model.User, model.Session) GetADLdapIdFromSAMLId(c request.CTX, authData string) string GetSAMLIdFromADLdapId(c request.CTX, authData string) string +} + +type LdapDiagnosticInterface interface { + RunTest(rctx request.CTX) *model.AppError GetVendorNameAndVendorVersion(rctx request.CTX) (string, string, error) } diff --git a/server/einterfaces/mocks/LdapDiagnosticInterface.go b/server/einterfaces/mocks/LdapDiagnosticInterface.go new file mode 100644 index 00000000000..9bc5eb8edcb --- /dev/null +++ b/server/einterfaces/mocks/LdapDiagnosticInterface.go @@ -0,0 +1,85 @@ +// Code generated by mockery v2.42.2. DO NOT EDIT. + +// Regenerate this file using `make einterfaces-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost/server/public/model" + request "github.com/mattermost/mattermost/server/public/shared/request" + mock "github.com/stretchr/testify/mock" +) + +// LdapDiagnosticInterface is an autogenerated mock type for the LdapDiagnosticInterface type +type LdapDiagnosticInterface struct { + mock.Mock +} + +// GetVendorNameAndVendorVersion provides a mock function with given fields: rctx +func (_m *LdapDiagnosticInterface) GetVendorNameAndVendorVersion(rctx request.CTX) (string, string, error) { + ret := _m.Called(rctx) + + if len(ret) == 0 { + panic("no return value specified for GetVendorNameAndVendorVersion") + } + + var r0 string + var r1 string + var r2 error + if rf, ok := ret.Get(0).(func(request.CTX) (string, string, error)); ok { + return rf(rctx) + } + if rf, ok := ret.Get(0).(func(request.CTX) string); ok { + r0 = rf(rctx) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(request.CTX) string); ok { + r1 = rf(rctx) + } else { + r1 = ret.Get(1).(string) + } + + if rf, ok := ret.Get(2).(func(request.CTX) error); ok { + r2 = rf(rctx) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// RunTest provides a mock function with given fields: rctx +func (_m *LdapDiagnosticInterface) RunTest(rctx request.CTX) *model.AppError { + ret := _m.Called(rctx) + + if len(ret) == 0 { + panic("no return value specified for RunTest") + } + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX) *model.AppError); ok { + r0 = rf(rctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + +// NewLdapDiagnosticInterface creates a new instance of LdapDiagnosticInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewLdapDiagnosticInterface(t interface { + mock.TestingT + Cleanup(func()) +}) *LdapDiagnosticInterface { + mock := &LdapDiagnosticInterface{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/einterfaces/mocks/LdapInterface.go b/server/einterfaces/mocks/LdapInterface.go index e4d54d0cc6c..673595890f2 100644 --- a/server/einterfaces/mocks/LdapInterface.go +++ b/server/einterfaces/mocks/LdapInterface.go @@ -328,41 +328,6 @@ func (_m *LdapInterface) GetUserAttributes(rctx request.CTX, id string, attribut return r0, r1 } -// GetVendorNameAndVendorVersion provides a mock function with given fields: rctx -func (_m *LdapInterface) GetVendorNameAndVendorVersion(rctx request.CTX) (string, string, error) { - ret := _m.Called(rctx) - - if len(ret) == 0 { - panic("no return value specified for GetVendorNameAndVendorVersion") - } - - var r0 string - var r1 string - var r2 error - if rf, ok := ret.Get(0).(func(request.CTX) (string, string, error)); ok { - return rf(rctx) - } - if rf, ok := ret.Get(0).(func(request.CTX) string); ok { - r0 = rf(rctx) - } else { - r0 = ret.Get(0).(string) - } - - if rf, ok := ret.Get(1).(func(request.CTX) string); ok { - r1 = rf(rctx) - } else { - r1 = ret.Get(1).(string) - } - - if rf, ok := ret.Get(2).(func(request.CTX) error); ok { - r2 = rf(rctx) - } else { - r2 = ret.Error(2) - } - - return r0, r1, r2 -} - // MigrateIDAttribute provides a mock function with given fields: c, toAttribute func (_m *LdapInterface) MigrateIDAttribute(c request.CTX, toAttribute string) error { ret := _m.Called(c, toAttribute) @@ -381,26 +346,6 @@ func (_m *LdapInterface) MigrateIDAttribute(c request.CTX, toAttribute string) e return r0 } -// RunTest provides a mock function with given fields: rctx -func (_m *LdapInterface) RunTest(rctx request.CTX) *model.AppError { - ret := _m.Called(rctx) - - if len(ret) == 0 { - panic("no return value specified for RunTest") - } - - var r0 *model.AppError - if rf, ok := ret.Get(0).(func(request.CTX) *model.AppError); ok { - r0 = rf(rctx) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*model.AppError) - } - } - - return r0 -} - // StartSynchronizeJob provides a mock function with given fields: c, waitForJobToFinish, includeRemovedMembers func (_m *LdapInterface) StartSynchronizeJob(c request.CTX, waitForJobToFinish bool, includeRemovedMembers bool) (*model.Job, *model.AppError) { ret := _m.Called(c, waitForJobToFinish, includeRemovedMembers) diff --git a/server/go.mod b/server/go.mod index c7d64ce82f1..076862124f6 100644 --- a/server/go.mod +++ b/server/go.mod @@ -82,7 +82,7 @@ require ( golang.org/x/term v0.22.0 golang.org/x/tools v0.23.0 gopkg.in/mail.v2 v2.3.1 - gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -234,7 +234,7 @@ require ( gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect modernc.org/gc/v3 v3.0.0-20240722195230-4a140ff9c08e // indirect modernc.org/libc v1.55.3 // indirect modernc.org/mathutil v1.6.0 // indirect diff --git a/server/i18n/en.json b/server/i18n/en.json index 3290c5b2f51..2bd14994323 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -6586,6 +6586,10 @@ "id": "app.role.check_roles_exist.role_not_found", "translation": "The provided role does not exist" }, + { + "id": "app.role.delete.app_error", + "translation": "Unable to delete role." + }, { "id": "app.role.get.app_error", "translation": "Unable to get role." diff --git a/server/public/go.mod b/server/public/go.mod index 81b453b7083..076c7e6dc7d 100644 --- a/server/public/go.mod +++ b/server/public/go.mod @@ -32,7 +32,7 @@ require ( golang.org/x/oauth2 v0.21.0 golang.org/x/text v0.16.0 golang.org/x/tools v0.23.0 - gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -70,7 +70,7 @@ require ( google.golang.org/protobuf v1.34.2 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) // Hack to prevent the willf/bitset module from being upgraded to 1.2.0. diff --git a/server/public/model/client4.go b/server/public/model/client4.go index fe8d5d70be2..6c8910516c4 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "io" + "mime" "mime/multipart" "net" "net/http" @@ -4778,18 +4779,19 @@ func (c *Client4) GetFileInfosForPostIncludeDeleted(ctx context.Context, postId // General/System Section // GenerateSupportPacket generates and downloads a Support Packet. -func (c *Client4) GenerateSupportPacket(ctx context.Context) ([]byte, *Response, error) { +// It returns a ReadCloser to the packet and the filename. The caller needs to close the ReadCloser. +func (c *Client4) GenerateSupportPacket(ctx context.Context) (io.ReadCloser, string, *Response, error) { r, err := c.DoAPIGet(ctx, c.systemRoute()+"/support_packet", "") if err != nil { - return nil, BuildResponse(r), err + return nil, "", BuildResponse(r), err } - defer closeBody(r) - data, err := io.ReadAll(r.Body) + _, params, err := mime.ParseMediaType(r.Header.Get("Content-Disposition")) if err != nil { - return nil, BuildResponse(r), NewAppError("GetFile", "model.client.read_job_result_file.app_error", nil, "", r.StatusCode).Wrap(err) + return nil, "", BuildResponse(r), fmt.Errorf("could not parse Content-Disposition header: %w", err) } - return data, BuildResponse(r), nil + + return r.Body, params["filename"], BuildResponse(r), nil } // GetPing will return ok if the running goRoutines are below the threshold and unhealthy for above. diff --git a/server/public/model/job.go b/server/public/model/job.go index a01836ffe73..815fe7d96d3 100644 --- a/server/public/model/job.go +++ b/server/public/model/job.go @@ -5,6 +5,8 @@ package model import ( "net/http" + + "github.com/mattermost/mattermost/server/public/utils/timeutils" ) const ( @@ -103,6 +105,75 @@ func (j *Job) Auditable() map[string]interface{} { } } +func (j *Job) MarshalYAML() (any, error) { + return struct { + Id string `yaml:"id"` + Type string `yaml:"type"` + Priority int64 `yaml:"priority"` + CreateAt string `yaml:"create_at"` + StartAt string `yaml:"start_at"` + LastActivityAt string `yaml:"last_activity_at"` + Status string `yaml:"status"` + Progress int64 `yaml:"progress"` + Data StringMap `yaml:"data"` + }{ + Id: j.Id, + Type: j.Type, + Priority: j.Priority, + CreateAt: timeutils.FormatMillis(j.CreateAt), + StartAt: timeutils.FormatMillis(j.StartAt), + LastActivityAt: timeutils.FormatMillis(j.LastActivityAt), + Status: j.Status, + Progress: j.Progress, + Data: j.Data, + }, nil +} + +func (j *Job) UnmarshalYAML(unmarshal func(interface{}) error) error { + out := struct { + Id string `yaml:"id"` + Type string `yaml:"type"` + Priority int64 `yaml:"priority"` + CreateAt string `yaml:"create_at"` + StartAt string `yaml:"start_at"` + LastActivityAt string `yaml:"last_activity_at"` + Status string `yaml:"status"` + Progress int64 `yaml:"progress"` + Data StringMap `yaml:"data"` + }{} + + err := unmarshal(&out) + if err != nil { + return err + } + + createAt, err := timeutils.ParseFormatedMillis(out.CreateAt) + if err != nil { + return err + } + updateAt, err := timeutils.ParseFormatedMillis(out.StartAt) + if err != nil { + return err + } + deleteAt, err := timeutils.ParseFormatedMillis(out.LastActivityAt) + if err != nil { + return err + } + + *j = Job{ + Id: out.Id, + Type: out.Type, + Priority: out.Priority, + CreateAt: createAt, + StartAt: updateAt, + LastActivityAt: deleteAt, + Status: out.Status, + Progress: out.Progress, + Data: out.Data, + } + return nil +} + func (j *Job) IsValid() *AppError { if !IsValidId(j.Id) { return NewAppError("Job.IsValid", "model.job.is_valid.id.app_error", nil, "id="+j.Id, http.StatusBadRequest) diff --git a/server/public/model/manifest.go b/server/public/model/manifest.go index c3e46d8245f..a7f7689324c 100644 --- a/server/public/model/manifest.go +++ b/server/public/model/manifest.go @@ -13,7 +13,7 @@ import ( "github.com/blang/semver/v4" "github.com/pkg/errors" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) type PluginOption struct { diff --git a/server/public/model/manifest_test.go b/server/public/model/manifest_test.go index d5fee3d6b95..0a4014b4d65 100644 --- a/server/public/model/manifest_test.go +++ b/server/public/model/manifest_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) func TestIsValid(t *testing.T) { diff --git a/server/public/model/packet_metadata.go b/server/public/model/packet_metadata.go index f3da64b4f95..e2a2215e19e 100644 --- a/server/public/model/packet_metadata.go +++ b/server/public/model/packet_metadata.go @@ -7,7 +7,7 @@ import ( "fmt" "github.com/blang/semver/v4" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) type PacketType string diff --git a/server/public/model/packet_metadata_test.go b/server/public/model/packet_metadata_test.go index 2f02eed66dc..27b5c1a02bb 100644 --- a/server/public/model/packet_metadata_test.go +++ b/server/public/model/packet_metadata_test.go @@ -7,7 +7,7 @@ import ( "testing" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) func TestPacketMetadataValidate(t *testing.T) { diff --git a/server/public/model/role.go b/server/public/model/role.go index 2cbd51316c9..b410d9d6d75 100644 --- a/server/public/model/role.go +++ b/server/public/model/role.go @@ -6,6 +6,8 @@ package model import ( "fmt" "strings" + + "github.com/mattermost/mattermost/server/public/utils/timeutils" ) // SysconsoleAncillaryPermissions maps the non-sysconsole permissions required by each sysconsole view. @@ -438,6 +440,84 @@ func (r *Role) Auditable() map[string]interface{} { } } +func (r *Role) Sanitize() { + r.DisplayName = FakeSetting + r.Description = FakeSetting +} + +func (r *Role) MarshalYAML() (any, error) { + return struct { + Id string `yaml:"id"` + Name string `yaml:"name"` + DisplayName string `yaml:"display_name"` + Description string `yaml:"description"` + CreateAt string `yaml:"create_at"` + UpdateAt string `yaml:"update_at"` + DeleteAt string `yaml:"delete_at"` + Permissions []string `yaml:"permissions"` + SchemeManaged bool `yaml:"scheme_managed"` + BuiltIn bool `yaml:"built_in"` + }{ + Id: r.Id, + Name: r.Name, + DisplayName: r.DisplayName, + Description: r.Description, + CreateAt: timeutils.FormatMillis(r.CreateAt), + UpdateAt: timeutils.FormatMillis(r.UpdateAt), + DeleteAt: timeutils.FormatMillis(r.DeleteAt), + Permissions: r.Permissions, + SchemeManaged: r.SchemeManaged, + BuiltIn: r.BuiltIn, + }, nil +} + +func (r *Role) UnmarshalYAML(unmarshal func(interface{}) error) error { + out := struct { + Id string `yaml:"id"` + Name string `yaml:"name"` + DisplayName string `yaml:"display_name"` + Description string `yaml:"description"` + CreateAt string `yaml:"create_at"` + UpdateAt string `yaml:"update_at"` + DeleteAt string `yaml:"delete_at"` + Permissions []string `yaml:"permissions"` + SchemeManaged bool `yaml:"scheme_managed"` + BuiltIn bool `yaml:"built_in"` + }{} + + err := unmarshal(&out) + if err != nil { + return err + } + + createAt, err := timeutils.ParseFormatedMillis(out.CreateAt) + if err != nil { + return err + } + updateAt, err := timeutils.ParseFormatedMillis(out.UpdateAt) + if err != nil { + return err + } + deleteAt, err := timeutils.ParseFormatedMillis(out.DeleteAt) + if err != nil { + return err + } + + *r = Role{ + Id: out.Id, + Name: out.Name, + DisplayName: out.DisplayName, + Description: out.Description, + CreateAt: createAt, + UpdateAt: updateAt, + DeleteAt: deleteAt, + Permissions: out.Permissions, + SchemeManaged: out.SchemeManaged, + BuiltIn: out.BuiltIn, + } + return nil +} + type RolePatch struct { Permissions *[]string `json:"permissions"` } diff --git a/server/public/model/scheme.go b/server/public/model/scheme.go index a231f0cc4fc..d85516e2148 100644 --- a/server/public/model/scheme.go +++ b/server/public/model/scheme.go @@ -6,6 +6,8 @@ package model import ( "fmt" "regexp" + + "github.com/mattermost/mattermost/server/public/utils/timeutils" ) const ( @@ -62,6 +64,117 @@ func (scheme *Scheme) Auditable() map[string]interface{} { } } +func (scheme *Scheme) Sanitize() { + scheme.Name = FakeSetting + scheme.DisplayName = FakeSetting + scheme.Description = FakeSetting +} + +func (scheme *Scheme) MarshalYAML() (any, error) { + return struct { + Id string `yaml:"id"` + Name string `yaml:"name"` + DisplayName string `yaml:"display_name"` + Description string `yaml:"description"` + CreateAt string `yaml:"create_at"` + UpdateAt string `yaml:"update_at"` + DeleteAt string `yaml:"delete_at"` + Scope string `yaml:"scope"` + DefaultTeamAdminRole string `yaml:"default_team_admin_role"` + DefaultTeamUserRole string `yaml:"default_team_user_role"` + DefaultChannelAdminRole string `yaml:"default_channel_admin_role"` + DefaultChannelUserRole string `yaml:"default_channel_user_role"` + DefaultTeamGuestRole string `yaml:"default_team_guest_role"` + DefaultChannelGuestRole string `yaml:"default_channel_guest_role"` + DefaultPlaybookAdminRole string `yaml:"default_playbook_admin_role"` + DefaultPlaybookMemberRole string `yaml:"default_playbook_member_role"` + DefaultRunAdminRole string `yaml:"default_run_admin_role"` + DefaultRunMemberRole string `yaml:"default_run_member_role"` + }{ + Id: scheme.Id, + Name: scheme.Name, + DisplayName: scheme.DisplayName, + Description: scheme.Description, + CreateAt: timeutils.FormatMillis(scheme.CreateAt), + UpdateAt: timeutils.FormatMillis(scheme.UpdateAt), + DeleteAt: timeutils.FormatMillis(scheme.DeleteAt), + Scope: scheme.Scope, + DefaultTeamAdminRole: scheme.DefaultTeamAdminRole, + DefaultTeamUserRole: scheme.DefaultTeamUserRole, + DefaultChannelAdminRole: scheme.DefaultChannelAdminRole, + DefaultChannelUserRole: scheme.DefaultChannelUserRole, + DefaultTeamGuestRole: scheme.DefaultTeamGuestRole, + DefaultChannelGuestRole: scheme.DefaultChannelGuestRole, + DefaultPlaybookAdminRole: scheme.DefaultPlaybookAdminRole, + DefaultPlaybookMemberRole: scheme.DefaultPlaybookMemberRole, + DefaultRunAdminRole: scheme.DefaultRunAdminRole, + DefaultRunMemberRole: scheme.DefaultRunMemberRole, + }, nil +} + +func (scheme *Scheme) UnmarshalYAML(unmarshal func(interface{}) error) error { + out := struct { + Id string `yaml:"id"` + Name string `yaml:"name"` + DisplayName string `yaml:"display_name"` + Description string `yaml:"description"` + CreateAt string `yaml:"create_at"` + UpdateAt string `yaml:"update_at"` + DeleteAt string `yaml:"delete_at"` + Scope string `yaml:"scope"` + DefaultTeamAdminRole string `yaml:"default_team_admin_role"` + DefaultTeamUserRole string `yaml:"default_team_user_role"` + DefaultChannelAdminRole string `yaml:"default_channel_admin_role"` + DefaultChannelUserRole string `yaml:"default_channel_user_role"` + DefaultTeamGuestRole string `yaml:"default_team_guest_role"` + DefaultChannelGuestRole string `yaml:"default_channel_guest_role"` + DefaultPlaybookAdminRole string `yaml:"default_playbook_admin_role"` + DefaultPlaybookMemberRole string `yaml:"default_playbook_member_role"` + DefaultRunAdminRole string `yaml:"default_run_admin_role"` + DefaultRunMemberRole string `yaml:"default_run_member_role"` + }{} + + err := unmarshal(&out) + if err != nil { + return err + } + + createAt, err := timeutils.ParseFormatedMillis(out.CreateAt) + if err != nil { + return err + } + updateAt, err := timeutils.ParseFormatedMillis(out.UpdateAt) + if err != nil { + return err + } + deleteAt, err := timeutils.ParseFormatedMillis(out.DeleteAt) + if err != nil { + return err + } + + *scheme = Scheme{ + Id: out.Id, + Name: out.Name, + DisplayName: out.DisplayName, + Description: out.Description, + CreateAt: createAt, + UpdateAt: updateAt, + DeleteAt: deleteAt, + Scope: out.Scope, + DefaultTeamAdminRole: out.DefaultTeamAdminRole, + DefaultTeamUserRole: out.DefaultTeamUserRole, + DefaultChannelAdminRole: out.DefaultChannelAdminRole, + DefaultChannelUserRole: out.DefaultChannelUserRole, + DefaultTeamGuestRole: out.DefaultTeamGuestRole, + DefaultChannelGuestRole: out.DefaultChannelGuestRole, + DefaultPlaybookAdminRole: out.DefaultPlaybookAdminRole, + DefaultPlaybookMemberRole: out.DefaultPlaybookMemberRole, + DefaultRunAdminRole: out.DefaultRunAdminRole, + DefaultRunMemberRole: out.DefaultRunMemberRole, + } + return nil +} + type SchemePatch struct { Name *string `json:"name"` DisplayName *string `json:"display_name"` diff --git a/server/public/model/support_packet.go b/server/public/model/support_packet.go index 4acd844edef..a9bcae55c8f 100644 --- a/server/public/model/support_packet.go +++ b/server/public/model/support_packet.go @@ -9,72 +9,120 @@ import ( ) const ( - SupportPacketErrorFile = "warning.txt" + CurrentSupportPacketVersion = 1 + SupportPacketErrorFile = "warning.txt" ) -type SupportPacket struct { - /* Build information */ +type SupportPacketDiagnostics struct { + Version int `yaml:"version"` - ServerOS string `yaml:"server_os"` - ServerArchitecture string `yaml:"server_architecture"` - ServerVersion string `yaml:"server_version"` - BuildHash string `yaml:"build_hash"` + License struct { + Company string `yaml:"company"` + Users int `yaml:"users"` + SkuShortName string `yaml:"sku_short_name"` + IsTrial bool `yaml:"is_trial,omitempty"` + IsGovSKU bool `yaml:"is_gov_sku,omitempty"` + } `yaml:"license"` - /* DB */ + Server struct { + OS string `yaml:"os"` + Architecture string `yaml:"architecture"` + Hostname string `yaml:"hostname"` + Version string `yaml:"version"` + BuildHash string `yaml:"build_hash"` + InstallationType string `yaml:"installation_type"` + } `yaml:"server"` - DatabaseType string `yaml:"database_type"` - DatabaseVersion string `yaml:"database_version"` - DatabaseSchemaVersion string `yaml:"database_schema_version"` - WebsocketConnections int `yaml:"websocket_connections"` - MasterDbConnections int `yaml:"master_db_connections"` - ReplicaDbConnections int `yaml:"read_db_connections"` + Config struct { + Source string `yaml:"store_type"` + } `yaml:"config"` - /* Cluster */ + Database struct { + Type string `yaml:"type"` + Version string `yaml:"version"` + SchemaVersion string `yaml:"schema_version"` + MasterConnectios int `yaml:"master_connections"` + ReplicaConnectios int `yaml:"replica_connections"` + SearchConnections int `yaml:"search_connections"` + } `yaml:"database"` - ClusterID string `yaml:"cluster_id"` + FileStore struct { + Status string `yaml:"file_status"` + Error string `yaml:"erorr,omitempty"` + Driver string `yaml:"file_driver"` + } `yaml:"file_store"` - /* File store */ + Websocket struct { + Connections int `yaml:"connections"` + } `yaml:"websocket"` - FileDriver string `yaml:"file_driver"` - FileStatus string `yaml:"file_status"` + Cluster struct { + ID string `yaml:"id"` + NumberOfNodes int `yaml:"number_of_nodes"` + } `yaml:"cluster"` - /* LDAP */ + LDAP struct { + Status string `yaml:"status,omitempty"` + Error string `yaml:"erorr,omitempty"` + ServerName string `yaml:"server_name,omitempty"` + ServerVersion string `yaml:"server_version,omitempty"` + } `yaml:"ldap"` - LdapVendorName string `yaml:"ldap_vendor_name,omitempty"` - LdapVendorVersion string `yaml:"ldap_vendor_version,omitempty"` + ElasticSearch struct { + ServerVersion string `yaml:"server_version,omitempty"` + ServerPlugins []string `yaml:"server_plugins,omitempty"` + } `yaml:"elastic"` +} - /* Elastic Search */ - - ElasticServerVersion string `yaml:"elastic_server_version,omitempty"` - ElasticServerPlugins []string `yaml:"elastic_server_plugins,omitempty"` - - /* License */ - - LicenseTo string `yaml:"license_to"` - LicenseSupportedUsers int `yaml:"license_supported_users"` - LicenseIsTrial bool `yaml:"license_is_trial,omitempty"` - - /* Server stats */ - - ActiveUsers int `yaml:"active_users"` - DailyActiveUsers int `yaml:"daily_active_users"` - MonthlyActiveUsers int `yaml:"monthly_active_users"` - InactiveUserCount int `yaml:"inactive_user_count"` - TotalPosts int `yaml:"total_posts"` - TotalChannels int `yaml:"total_channels"` - TotalTeams int `yaml:"total_teams"` - - /* Jobs */ +type SupportPacketStats struct { + RegisteredUsers int64 `yaml:"registered_users"` + ActiveUsers int64 `yaml:"active_users"` + DailyActiveUsers int64 `yaml:"daily_active_users"` + MonthlyActiveUsers int64 `yaml:"monthly_active_users"` + DeactivatedUsers int64 `yaml:"deactivated_users"` + Guests int64 `yaml:"guests"` + BotAccounts int64 `yaml:"bot_accounts"` + Posts int64 `yaml:"posts"` + Channels int64 `yaml:"channels"` + Teams int64 `yaml:"teams"` + SlashCommands int64 `yaml:"slash_commands"` + IncomingWebhooks int64 `yaml:"incoming_webhooks"` + OutgoingWebhooks int64 `yaml:"outgoing_webhooks"` +} +// SupportPacketJobList contains the list of latest run enterprise job runs. +// It is included in the Support Packet. +type SupportPacketJobList struct { + LDAPSyncJobs []*Job `yaml:"ldap_sync_jobs"` DataRetentionJobs []*Job `yaml:"data_retention_jobs"` MessageExportJobs []*Job `yaml:"message_export_jobs"` ElasticPostIndexingJobs []*Job `yaml:"elastic_post_indexing_jobs"` ElasticPostAggregationJobs []*Job `yaml:"elastic_post_aggregation_jobs"` BlevePostIndexingJobs []*Job `yaml:"bleve_post_indexin_jobs"` - LdapSyncJobs []*Job `yaml:"ldap_sync_jobs"` MigrationJobs []*Job `yaml:"migration_jobs"` } +// SupportPacketPermissionInfo contains the list of schemes and the list of roles. +// It is included in the Support Packet. +type SupportPacketPermissionInfo struct { + Roles []*Role `yaml:"roles"` + Schemes []*Scheme `yaml:"schemes"` +} + +// SupportPacketConfig contains the Mattermost configuration. In contrast to [Config], it also contains the list of Feature Flags. +// It is included in the Support Packet. +type SupportPacketConfig struct { + *Config + FeatureFlags FeatureFlags `json:"FeatureFlags"` +} + +// SupportPacketPluginList contains the list of enabled and disabled plugins. +// It is included in the Support Packet. +type SupportPacketPluginList struct { + Enabled []Manifest `json:"enabled"` + Disabled []Manifest `json:"disabled"` +} + type FileData struct { Filename string Body []byte diff --git a/server/public/pluginapi/system.go b/server/public/pluginapi/system.go index 62f720c3595..2e78d4a33ae 100644 --- a/server/public/pluginapi/system.go +++ b/server/public/pluginapi/system.go @@ -9,7 +9,7 @@ import ( "github.com/blang/semver/v4" "github.com/pkg/errors" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" diff --git a/server/public/pluginapi/system_test.go b/server/public/pluginapi/system_test.go index cd0936ada3a..684086df4ed 100644 --- a/server/public/pluginapi/system_test.go +++ b/server/public/pluginapi/system_test.go @@ -8,7 +8,7 @@ import ( "github.com/pkg/errors" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin/plugintest" diff --git a/server/public/utils/fileutils.go b/server/public/utils/fileutils.go index 3832a8aac75..55f7d59d2d3 100644 --- a/server/public/utils/fileutils.go +++ b/server/public/utils/fileutils.go @@ -6,6 +6,8 @@ package utils import ( "os" "path/filepath" + "regexp" + "strings" ) func CommonBaseSearchPaths() []string { @@ -111,3 +113,25 @@ func FindDirRelBinary(dir string) (string, bool) { } return found, true } + +// Valid characters are: alphanumeric, dash, underscore +var safeFileNameRegex = regexp.MustCompile(`[^\w\-\_]`) + +// SanitizeFileName takes a string and returns a safe file name without an extension. +func SanitizeFileName(input string) string { + // Trim leading or trailing dots or spaces + safeName := strings.Trim(input, ". ") + // Replace dots with nothing + safeName = strings.ReplaceAll(safeName, ".", "") + + // Replace all invalid characters with an underscore + safeName = safeFileNameRegex.ReplaceAllString(safeName, "_") + + // Limit length + const maxLength = 100 + if len(safeName) > maxLength { + safeName = safeName[:maxLength] + } + + return safeName +} diff --git a/server/public/utils/fileutils_test.go b/server/public/utils/fileutils_test.go index 688d5ad9ae6..1476c998805 100644 --- a/server/public/utils/fileutils_test.go +++ b/server/public/utils/fileutils_test.go @@ -7,12 +7,69 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestSanitizeFileName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "normal characters", + input: "normal-file_name", + expected: "normal-file_name", + }, + { + name: "special characters", + input: "file*name@#$", + expected: "file_name___", + }, + { + name: "spaces", + input: "my file name", + expected: "my_file_name", + }, + { + name: "leading/trailing dots and spaces", + input: " .filename. ", + expected: "filename", + }, + { + name: "very long filename", + input: strings.Repeat("a", 150) + ".txt", + expected: strings.Repeat("a", 100), + }, + { + name: "unicode characters", + input: "résumé", + expected: "r_sum_", + }, + { + name: "german umlaute", + input: "äëïöüß", + expected: "______", + }, + { + name: "empty string", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeFileName(tt.input) + assert.Equal(t, tt.expected, result, tt.name) + }) + } +} + func TestFindFile(t *testing.T) { t.Run("files from various paths", func(t *testing.T) { // Create the following directory structure: diff --git a/server/public/utils/timeutils/time.go b/server/public/utils/timeutils/time.go new file mode 100644 index 00000000000..be677739e15 --- /dev/null +++ b/server/public/utils/timeutils/time.go @@ -0,0 +1,29 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package timeutils + +import ( + "time" +) + +const ( + RFC3339Milli = "2006-01-02T15:04:05.999Z07:00" +) + +func FormatMillis(millis int64) string { + return time.UnixMilli(millis).Format(RFC3339Milli) +} + +func ParseFormatedMillis(s string) (millis int64, err error) { + if s == "" { + return 0, nil + } + + t, err := time.Parse(RFC3339Milli, s) + if err != nil { + return 0, err + } + + return t.UnixMilli(), nil +} diff --git a/server/public/utils/timeutils/time_test.go b/server/public/utils/timeutils/time_test.go new file mode 100644 index 00000000000..303777e54ac --- /dev/null +++ b/server/public/utils/timeutils/time_test.go @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package timeutils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatMillis(t *testing.T) { + t.Run("zero time", func(t *testing.T) { + result := FormatMillis(0) + // The concrete time depends on the timezone, so we can't test the exact time + assert.Contains(t, result, "1970-01-01") + assert.Contains(t, result, "00:00") + }) + + t.Run("positive time", func(t *testing.T) { + result := FormatMillis(1609459200000) // 2021-01-01 00:00:00 UTC + assert.Contains(t, result, "2021-01-01") + assert.Contains(t, result, "00:00") + }) + + t.Run("negative time", func(t *testing.T) { + result := FormatMillis(-1609459200000) // 1919-01-01 00:00:00 UTC + assert.Contains(t, result, "1919-01-01") + assert.Contains(t, result, "00:00") + }) +} + +func TestParseFormatedMillis(t *testing.T) { + t.Run("empty string", func(t *testing.T) { + result, err := ParseFormatedMillis("") + assert.NoError(t, err) + assert.Equal(t, int64(0), result) + }) + + t.Run("valid timestamp", func(t *testing.T) { + result, err := ParseFormatedMillis("2021-01-01T00:00:00.000Z") + assert.NoError(t, err) + assert.Equal(t, int64(1609459200000), result) + }) + + t.Run("invalid format", func(t *testing.T) { + result, err := ParseFormatedMillis("2021-01-01") + assert.Error(t, err) + assert.Equal(t, int64(0), result) + }) + + t.Run("invalid date", func(t *testing.T) { + result, err := ParseFormatedMillis("2021-13-01T00:00:00.000Z") + assert.Error(t, err) + assert.Equal(t, int64(0), result) + }) +} diff --git a/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.tsx b/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.tsx index 4fc7c0d4ad9..e7f1d2c87dc 100644 --- a/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.tsx +++ b/webapp/channels/src/components/commercial_support_modal/commercial_support_modal.tsx @@ -92,8 +92,8 @@ export default class CommercialSupportModal extends React.PureComponent { // construct the expected filename in case of an error in the header - const formattedDate = (moment(new Date())).format('YYYY-MM-DD-HH-mm'); - const presumedFileName = `mattermost_support_packet_${formattedDate}.zip`; + const formattedDate = (moment(new Date())).format('YYYY-MM-DDTHH-mm'); + const presumedFileName = `mm_support_packet_${formattedDate}.zip`; if (input === null) { return presumedFileName;