diff --git a/api4/system.go b/api4/system.go index 5148b0882d..cfe4eedf88 100644 --- a/api4/system.go +++ b/api4/system.go @@ -70,6 +70,7 @@ func (api *API) InitSystem() { api.BaseRoutes.System.Handle("/support_packet", api.APISessionRequired(generateSupportPacket)).Methods("GET") api.BaseRoutes.System.Handle("/onboarding/complete", api.APISessionRequired(getOnboarding)).Methods("GET") api.BaseRoutes.System.Handle("/onboarding/complete", api.APISessionRequired(completeOnboarding)).Methods("POST") + api.BaseRoutes.System.Handle("/schema/version", api.APISessionRequired(getAppliedSchemaMigrations)).Methods("GET") } func generateSupportPacket(c *Context, w http.ResponseWriter, r *http.Request) { @@ -934,3 +935,28 @@ func completeOnboarding(c *Context, w http.ResponseWriter, r *http.Request) { auditRec.Success() ReturnStatusOK(w) } + +func getAppliedSchemaMigrations(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.Err = model.NewAppError("getAppliedMigrations", "app.system.applied_migrations.not_authorized", nil, "", http.StatusForbidden) + return + } + + auditRec := c.MakeAuditRecord("getAppliedSchemaMigrations", audit.Fail) + defer c.LogAuditRec(auditRec) + + migrations, appErr := c.App.GetAppliedSchemaMigrations() + if appErr != nil { + c.Err = appErr + return + } + + js, jsonErr := json.Marshal(migrations) + if jsonErr != nil { + c.Err = model.NewAppError("getAppliedMigrations", "api.marshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + return + } + + w.Write(js) + auditRec.Success() +} diff --git a/api4/system_local.go b/api4/system_local.go index 872ac38609..3b316f0f53 100644 --- a/api4/system_local.go +++ b/api4/system_local.go @@ -18,6 +18,7 @@ func (api *API) InitSystemLocal() { api.BaseRoutes.APIRoot.Handle("/server_busy", api.APILocal(getServerBusyExpires)).Methods("GET") api.BaseRoutes.APIRoot.Handle("/server_busy", api.APILocal(clearServerBusy)).Methods("DELETE") api.BaseRoutes.APIRoot.Handle("/integrity", api.APILocal(localCheckIntegrity)).Methods("POST") + api.BaseRoutes.System.Handle("/schema/version", api.APILocal(getAppliedSchemaMigrations)).Methods("GET") } func localCheckIntegrity(c *Context, w http.ResponseWriter, r *http.Request) { diff --git a/api4/system_test.go b/api4/system_test.go index 4178b49de4..0b66da9065 100644 --- a/api4/system_test.go +++ b/api4/system_test.go @@ -931,3 +931,20 @@ func TestCompleteOnboarding(t *testing.T) { CheckOKStatus(t, resp) }) } + +func TestGetAppliedSchemaMigrations(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + t.Run("as a regular user", func(t *testing.T) { + _, resp, err := th.Client.GetAppliedSchemaMigrations() + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) { + _, resp, err := c.GetAppliedSchemaMigrations() + require.NoError(t, err) + CheckOKStatus(t, resp) + }) +} diff --git a/app/app_iface.go b/app/app_iface.go index 5ada83a7dc..a7d4f31063 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -550,6 +550,7 @@ type AppIface interface { GetAllTeamsPage(offset int, limit int, opts *model.TeamSearch) ([]*model.Team, *model.AppError) GetAllTeamsPageWithCount(offset int, limit int, opts *model.TeamSearch) (*model.TeamsWithCount, *model.AppError) GetAnalytics(name string, teamID string) (model.AnalyticsRows, *model.AppError) + GetAppliedSchemaMigrations() ([]model.AppliedMigration, *model.AppError) GetAudits(userID string, limit int) (model.Audits, *model.AppError) GetAuditsPage(userID string, page int, perPage int) (model.Audits, *model.AppError) GetAuthorizationCode(w http.ResponseWriter, r *http.Request, service string, props map[string]string, loginHint string) (string, *model.AppError) diff --git a/app/app_test.go b/app/app_test.go index 525bb10667..2a47e03b6e 100644 --- a/app/app_test.go +++ b/app/app_test.go @@ -56,6 +56,7 @@ func TestUnitUpdateConfig(t *testing.T) { mockStore.On("Post").Return(&mockPostStore) mockStore.On("System").Return(&mockSystemStore) mockStore.On("License").Return(&mockLicenseStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) prev := *th.App.Config().ServiceSettings.SiteURL diff --git a/app/channel_test.go b/app/channel_test.go index 45fbbee47c..e48837ae6e 100644 --- a/app/channel_test.go +++ b/app/channel_test.go @@ -2103,6 +2103,7 @@ func TestMarkChannelAsUnreadFromPostPanic(t *testing.T) { mockStore.On("User").Return(&mockUserStore) mockStore.On("System").Return(&mockSystemStore) mockStore.On("License").Return(&mockLicenseStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.ThreadAutoFollow = true @@ -2132,6 +2133,7 @@ func TestClearChannelMembersCache(t *testing.T) { ChannelId: "1", }}, nil) mockStore.On("Channel").Return(&mockChannelStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) th.App.ClearChannelMembersCache("channelID") } @@ -2152,6 +2154,7 @@ func TestGetMemberCountsByGroup(t *testing.T) { } mockChannelStore.On("GetMemberCountsByGroup", context.Background(), "channelID", true).Return(cmc, nil) mockStore.On("Channel").Return(&mockChannelStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) resp, err := th.App.GetMemberCountsByGroup(context.Background(), "channelID", true) require.Nil(t, err) require.ElementsMatch(t, cmc, resp) diff --git a/app/config.go b/app/config.go index b7033d01a3..b86e4f5b9c 100644 --- a/app/config.go +++ b/app/config.go @@ -420,6 +420,11 @@ func (a *App) ClientConfigWithComputed() map[string]string { if installationDate, err := a.ch.srv.getSystemInstallDate(); err == nil { respCfg["InstallationDate"] = strconv.FormatInt(installationDate, 10) } + if ver, err := a.ch.srv.Store.GetDBSchemaVersion(); err != nil { + mlog.Error("Could not get the schema version", mlog.Err(err)) + } else { + respCfg["SchemaVersion"] = strconv.Itoa(ver) + } return respCfg } diff --git a/app/config_test.go b/app/config_test.go index f24eda326a..fcca2889a4 100644 --- a/app/config_test.go +++ b/app/config_test.go @@ -79,12 +79,16 @@ func TestClientConfigWithComputed(t *testing.T) { mockStore.On("User").Return(&mockUserStore) mockStore.On("Post").Return(&mockPostStore) mockStore.On("System").Return(&mockSystemStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) config := th.App.ClientConfigWithComputed() _, ok := config["NoAccounts"] assert.True(t, ok, "expected NoAccounts in returned config") _, ok = config["MaxPostSize"] assert.True(t, ok, "expected MaxPostSize in returned config") + v, ok := config["SchemaVersion"] + assert.True(t, ok, "expected SchemaVersion in returned config") + assert.Equal(t, "1", v) } func TestEnsureInstallationDate(t *testing.T) { diff --git a/app/enterprise_test.go b/app/enterprise_test.go index d01b6079dd..be2e6d87de 100644 --- a/app/enterprise_test.go +++ b/app/enterprise_test.go @@ -77,6 +77,7 @@ func TestSAMLSettings(t *testing.T) { mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil) mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil) mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil) + mockStore.On("GetDBSchemaVersion").Return(1, nil) mockStore.On("User").Return(&mockUserStore) mockStore.On("Post").Return(&mockPostStore) diff --git a/app/notification_push_test.go b/app/notification_push_test.go index 1866c83f42..92f570fb77 100644 --- a/app/notification_push_test.go +++ b/app/notification_push_test.go @@ -567,6 +567,7 @@ func TestGetPushNotificationMessage(t *testing.T) { mockStore.On("User").Return(&mockUserStore) mockStore.On("Post").Return(&mockPostStore) mockStore.On("System").Return(&mockSystemStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) for name, tc := range map[string]struct { Message string @@ -1149,6 +1150,7 @@ func TestClearPushNotificationSync(t *testing.T) { mockStore.On("Post").Return(&mockPostStore) mockStore.On("System").Return(&mockSystemStore) mockStore.On("Session").Return(&mockSessionStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.EmailSettings.PushNotificationServer = pushServer.URL @@ -1222,6 +1224,7 @@ func TestUpdateMobileAppBadgeSync(t *testing.T) { mockStore.On("Post").Return(&mockPostStore) mockStore.On("System").Return(&mockSystemStore) mockStore.On("Session").Return(&mockSessionStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.EmailSettings.PushNotificationServer = pushServer.URL @@ -1288,6 +1291,7 @@ func TestSendAckToPushProxy(t *testing.T) { mockStore.On("User").Return(&mockUserStore) mockStore.On("Post").Return(&mockPostStore) mockStore.On("System").Return(&mockSystemStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.EmailSettings.PushNotificationServer = pushServer.URL @@ -1531,6 +1535,7 @@ func BenchmarkPushNotificationThroughput(b *testing.B) { mockStore.On("System").Return(&mockSystemStore) mockStore.On("Session").Return(&mockSessionStore) mockStore.On("Preference").Return(&mockPreferenceStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) // create 50 users, each having 2 sessions. type userSession struct { diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 13c679dcb1..47fa37d670 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -4604,6 +4604,28 @@ func (a *OpenTracingAppLayer) GetAnalytics(name string, teamID string) (model.An return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetAppliedSchemaMigrations() ([]model.AppliedMigration, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetAppliedSchemaMigrations") + + 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.GetAppliedSchemaMigrations() + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetAudits(userID string, limit int) (model.Audits, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetAudits") diff --git a/app/plugin_signature_test.go b/app/plugin_signature_test.go index 8b96de6986..f1b0d31d1d 100644 --- a/app/plugin_signature_test.go +++ b/app/plugin_signature_test.go @@ -34,6 +34,7 @@ func TestPluginPublicKeys(t *testing.T) { mockStore.On("User").Return(&mockUserStore) mockStore.On("Post").Return(&mockPostStore) mockStore.On("System").Return(&mockSystemStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) path, _ := fileutils.FindDir("tests") publicKeyFilename := "test-public-key.plugin.gpg" diff --git a/app/post_test.go b/app/post_test.go index ebad76739f..b49e6e32f5 100644 --- a/app/post_test.go +++ b/app/post_test.go @@ -464,6 +464,7 @@ func TestImageProxy(t *testing.T) { mockStore.On("User").Return(&mockUserStore) mockStore.On("Post").Return(&mockPostStore) mockStore.On("System").Return(&mockSystemStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://mymattermost.com" diff --git a/app/product_notices_test.go b/app/product_notices_test.go index 6091409cf3..8403634dc5 100644 --- a/app/product_notices_test.go +++ b/app/product_notices_test.go @@ -32,6 +32,7 @@ func TestNoticeValidation(t *testing.T) { mockStore.On("User").Return(&mockUserStore) mockStore.On("Post").Return(&mockPostStore) mockStore.On("Preference").Return(&mockPreferenceStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) mockSystemStore.On("SaveOrUpdate", &model.System{Name: "ActiveLicenseId", Value: ""}).Return(nil) mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil) mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil) diff --git a/app/server.go b/app/server.go index 44a979f460..dfc6949ab8 100644 --- a/app/server.go +++ b/app/server.go @@ -20,6 +20,7 @@ import ( "os/exec" "path" "runtime" + "strconv" "strings" "sync" "sync/atomic" @@ -752,10 +753,10 @@ func (s *Server) Channels() *Channels { return ch } -// Return Database type (postgres or mysql) and current version of Mattermost -func (s *Server) DatabaseTypeAndMattermostVersion() (string, string) { - mattermostVersion, _ := s.Store.System().GetByName("Version") - return *s.Config().SqlSettings.DriverName, mattermostVersion.Value +// Return Database type (postgres or mysql) and current version of the schema +func (s *Server) DatabaseTypeAndSchemaVersion() (string, string) { + schemaVersion, _ := s.Store.GetDBSchemaVersion() + return *s.Config().SqlSettings.DriverName, strconv.Itoa(schemaVersion) } // initLogging initializes and configures the logger(s). This may be called more than once. @@ -2194,13 +2195,15 @@ func (a *App) generateSupportPacketYaml() (*model.FileData, string) { vendorName, vendorVersion = ldapInterface.GetVendorNameAndVendorVersion() } - // Here we are getting information regarding the database (mysql/postgres + current Mattermost version) - databaseType, databaseVersion := a.Srv().DatabaseTypeAndMattermostVersion() + // Here we are getting information regarding the database (mysql/postgres + current schema version) + databaseType, databaseVersion := a.Srv().DatabaseTypeAndSchemaVersion() // Creating the struct for support packet yaml file supportPacket := model.SupportPacket{ ServerOS: runtime.GOOS, ServerArchitecture: runtime.GOARCH, + ServerVersion: model.CurrentVersion, + BuildHash: model.BuildHash, DatabaseType: databaseType, DatabaseVersion: databaseVersion, LdapVendorName: vendorName, @@ -2304,3 +2307,11 @@ func runDNDStatusExpireJob(a *App) { } }) } + +func (a *App) GetAppliedSchemaMigrations() ([]model.AppliedMigration, *model.AppError) { + table, err := a.Srv().Store.GetAppliedMigrations() + if err != nil { + return nil, model.NewAppError("GetDBSchemaTable", "api.file.read_file.app_error", nil, err.Error(), http.StatusInternalServerError) + } + return table, nil +} diff --git a/app/server_test.go b/app/server_test.go index 515f76b4ee..29b2e78aef 100644 --- a/app/server_test.go +++ b/app/server_test.go @@ -233,18 +233,18 @@ func TestDatabaseTypeAndMattermostVersion(t *testing.T) { th := Setup(t) defer th.TearDown() - databaseType, mattermostVersion := th.Server.DatabaseTypeAndMattermostVersion() + databaseType, mattermostVersion := th.Server.DatabaseTypeAndSchemaVersion() assert.Equal(t, "postgres", databaseType) - assert.Equal(t, "5.31.0", mattermostVersion) + assert.GreaterOrEqual(t, mattermostVersion, strconv.Itoa(1)) os.Setenv("MM_SQLSETTINGS_DRIVERNAME", "mysql") th2 := Setup(t) defer th2.TearDown() - databaseType, mattermostVersion = th2.Server.DatabaseTypeAndMattermostVersion() + databaseType, mattermostVersion = th2.Server.DatabaseTypeAndSchemaVersion() assert.Equal(t, "mysql", databaseType) - assert.Equal(t, "5.31.0", mattermostVersion) + assert.GreaterOrEqual(t, mattermostVersion, strconv.Itoa(1)) } func TestGenerateSupportPacket(t *testing.T) { diff --git a/app/team_test.go b/app/team_test.go index eab7512ed1..964cb10c4e 100644 --- a/app/team_test.go +++ b/app/team_test.go @@ -882,6 +882,7 @@ func TestLeaveTeamPanic(t *testing.T) { mockStore.On("System").Return(&mockSystemStore) mockStore.On("License").Return(&mockLicenseStore) mockStore.On("Team").Return(&mockTeamStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) team := &model.Team{Id: "myteam"} user := &model.User{Id: "userID"} @@ -1239,6 +1240,7 @@ func TestClearTeamMembersCache(t *testing.T) { TeamId: "1", }}, nil) mockStore.On("Team").Return(&mockTeamStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) th.App.ClearTeamMembersCache("teamID") } diff --git a/app/web_hub_test.go b/app/web_hub_test.go index 423a800053..7031434c85 100644 --- a/app/web_hub_test.go +++ b/app/web_hub_test.go @@ -161,6 +161,7 @@ func TestHubSessionRevokeRace(t *testing.T) { mockStore.On("User").Return(&mockUserStore) mockStore.On("Post").Return(&mockPostStore) mockStore.On("System").Return(&mockSystemStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) userService, err := users.New(users.ServiceConfig{ UserStore: &mockUserStore, diff --git a/cmd/mattermost/commands/db.go b/cmd/mattermost/commands/db.go index 8501b35d48..dfe8c81b7b 100644 --- a/cmd/mattermost/commands/db.go +++ b/cmd/mattermost/commands/db.go @@ -5,6 +5,7 @@ package commands import ( "fmt" + "strconv" "github.com/pkg/errors" "github.com/spf13/cobra" @@ -51,13 +52,21 @@ var MigrateCmd = &cobra.Command{ RunE: migrateCmdF, } +var DBVersionCmd = &cobra.Command{ + Use: "version", + Short: "Returns the recent applied version number", + RunE: dbVersionCmdF, +} + func init() { ResetCmd.Flags().Bool("confirm", false, "Confirm you really want to delete everything and a DB backup has been performed.") + DBVersionCmd.Flags().Bool("all", false, "Returns all applied migrations") DbCmd.AddCommand( InitDbCmd, ResetCmd, MigrateCmd, + DBVersionCmd, ) RootCmd.AddCommand( @@ -137,3 +146,35 @@ func migrateCmdF(command *cobra.Command, args []string) error { return nil } + +func dbVersionCmdF(command *cobra.Command, args []string) error { + cfgDSN := getConfigDSN(command, config.GetEnvironment()) + cfgStore, err := config.NewStoreFromDSN(cfgDSN, true, nil, true) + if err != nil { + return errors.Wrap(err, "failed to load configuration") + } + config := cfgStore.Get() + + store := sqlstore.New(config.SqlSettings, nil) + defer store.Close() + + allFlag, _ := command.Flags().GetBool("all") + if allFlag { + applied, err2 := store.GetAppliedMigrations() + if err2 != nil { + return errors.Wrap(err2, "failed to get applied migrations") + } + for _, migration := range applied { + CommandPrettyPrintln(fmt.Sprintf("Varsion: %d, Name: %s", migration.Version, migration.Name)) + } + return nil + } + + v, err := store.GetDBSchemaVersion() + if err != nil { + return errors.Wrap(err, "failed to get schema version") + } + CommandPrettyPrintln("Current database schema version is: " + strconv.Itoa(v)) + + return nil +} diff --git a/i18n/en.json b/i18n/en.json index d7febf48ed..92dbe47b30 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -6155,6 +6155,10 @@ "id": "app.submit_interactive_dialog.json_error", "translation": "Encountered an error encoding JSON for the interactive dialog." }, + { + "id": "app.system.applied_migrations.not_authorized", + "translation": "You don't have the appropriate permissions." + }, { "id": "app.system.complete_onboarding_request.app_error", "translation": "Failed to decode the complete onboarding request." diff --git a/model/client4.go b/model/client4.go index beabda82c9..000c0b4146 100644 --- a/model/client4.go +++ b/model/client4.go @@ -7931,3 +7931,16 @@ func (c *Client4) GetUsersWithInvalidEmails(page, perPage int) ([]*User, *Respon } return list, BuildResponse(r), nil } + +func (c *Client4) GetAppliedSchemaMigrations() ([]AppliedMigration, *Response, error) { + r, err := c.DoAPIGet(c.systemRoute()+"/schema/version", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var list []AppliedMigration + if jsonErr := json.NewDecoder(r.Body).Decode(&list); jsonErr != nil { + return nil, nil, NewAppError("GetUsers", "api.unmarshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + } + return list, BuildResponse(r), nil +} diff --git a/model/system.go b/model/system.go index b1b9ca1969..29fef33600 100644 --- a/model/system.go +++ b/model/system.go @@ -79,6 +79,8 @@ type ServerBusyState struct { type SupportPacket struct { ServerOS string `yaml:"server_os"` ServerArchitecture string `yaml:"server_architecture"` + ServerVersion string `yaml:"server_version"` + BuildHash string `yaml:"build_hash,omitempty"` DatabaseType string `yaml:"database_type"` DatabaseVersion string `yaml:"database_version"` LdapVendorName string `yaml:"ldap_vendor_name,omitempty"` @@ -173,3 +175,8 @@ type WarnMetricStatus struct { type SendWarnMetricAck struct { ForceAck bool `json:"forceAck"` } + +type AppliedMigration struct { + Version int `json:"version"` + Name string `json:"name"` +} diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index 5d91851388..20211dbcd5 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -11835,10 +11835,6 @@ func (s *OpenTracingLayer) DropAllTables() { s.Store.DropAllTables() } -func (s *OpenTracingLayer) GetCurrentSchemaVersion() string { - return s.Store.GetCurrentSchemaVersion() -} - func (s *OpenTracingLayer) LockToMaster() { s.Store.LockToMaster() } diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index 768338c8cd..5130006008 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -13485,10 +13485,6 @@ func (s *RetryLayer) DropAllTables() { s.Store.DropAllTables() } -func (s *RetryLayer) GetCurrentSchemaVersion() string { - return s.Store.GetCurrentSchemaVersion() -} - func (s *RetryLayer) LockToMaster() { s.Store.LockToMaster() } diff --git a/store/sqlstore/store.go b/store/sqlstore/store.go index 3bef2e0350..8dadbdaa7d 100644 --- a/store/sqlstore/store.go +++ b/store/sqlstore/store.go @@ -353,10 +353,10 @@ func (ss *SqlStore) DriverName() string { return *ss.settings.DriverName } -func (ss *SqlStore) GetCurrentSchemaVersion() string { +func (ss *SqlStore) getCurrentSchemaVersion() (string, error) { var version string - _ = ss.GetMasterX().Get(&version, "SELECT Value FROM Systems WHERE Name='Version'") - return version + err := ss.GetMasterX().Get(&version, "SELECT Value FROM Systems WHERE Name='Version'") + return version, err } // GetDbVersion returns the version of the database being used. @@ -949,6 +949,7 @@ func (ss *SqlStore) DropAllTables() { FROM pg_class WHERE relkind = 'r' -- only tables AND relnamespace = 'public'::regnamespace + AND NOT relname = 'db_migrations' ); END $func$;`) @@ -956,7 +957,9 @@ func (ss *SqlStore) DropAllTables() { tables := []string{} ss.masterX.Select(&tables, `show tables`) for _, t := range tables { - ss.masterX.Exec(`TRUNCATE TABLE ` + t) + if t != "db_migrations" { + ss.masterX.Exec(`TRUNCATE TABLE ` + t) + } } } } @@ -1204,3 +1207,20 @@ func (ss *SqlStore) toReserveCase(str string) string { return fmt.Sprintf("`%s`", strings.Title(str)) } + +func (ss *SqlStore) GetDBSchemaVersion() (int, error) { + var version int + if err := ss.GetMasterX().Get(&version, "SELECT Version FROM db_migrations ORDER BY Version DESC LIMIT 1"); err != nil { + return 0, errors.Wrap(err, "unable to select from db_migrations") + } + return version, nil +} + +func (ss *SqlStore) GetAppliedMigrations() ([]model.AppliedMigration, error) { + migrations := []model.AppliedMigration{} + if err := ss.GetMasterX().Select(&migrations, "SELECT Version, Name FROM db_migrations ORDER BY Version DESC"); err != nil { + return nil, errors.Wrap(err, "unable to select from db_migrations") + } + + return migrations, nil +} diff --git a/store/sqlstore/store_test.go b/store/sqlstore/store_test.go index 0ac631b6fb..0b8bc2c808 100644 --- a/store/sqlstore/store_test.go +++ b/store/sqlstore/store_test.go @@ -6,7 +6,11 @@ package sqlstore import ( "fmt" "os" + "path/filepath" "regexp" + "sort" + "strconv" + "strings" "sync" "testing" "time" @@ -18,6 +22,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/mattermost/mattermost-server/v6/db" "github.com/mattermost/mattermost-server/v6/einterfaces/mocks" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/store" @@ -784,3 +789,79 @@ func TestMySQLReadTimeout(t *testing.T) { _, err = store.GetMasterX().ExecNoTimeout(`SELECT SLEEP(3)`) require.NoError(t, err) } + +func TestGetDBSchemaVersion(t *testing.T) { + testDrivers := []string{ + model.DatabaseDriverPostgres, + model.DatabaseDriverMysql, + } + + assets := db.Assets() + + for _, driver := range testDrivers { + t.Run("Should return latest version number of applied migrations for "+driver, func(t *testing.T) { + t.Parallel() + settings := makeSqlSettings(driver) + store := New(*settings, nil) + + assetsList, err := assets.ReadDir(filepath.Join("migrations", driver)) + require.NoError(t, err) + + var assetNamesForDriver []string + for _, entry := range assetsList { + assetNamesForDriver = append(assetNamesForDriver, entry.Name()) + } + sort.Strings(assetNamesForDriver) + + require.NotEmpty(t, assetNamesForDriver) + lastMigration := assetNamesForDriver[len(assetNamesForDriver)-1] + expectedVersion := strings.Split(lastMigration, "_")[0] + + version, err := store.GetDBSchemaVersion() + require.NoError(t, err) + require.Equal(t, expectedVersion, fmt.Sprintf("%06d", version)) + }) + } +} + +func TestGetAppliedMigrations(t *testing.T) { + testDrivers := []string{ + model.DatabaseDriverPostgres, + model.DatabaseDriverMysql, + } + + assets := db.Assets() + + for _, driver := range testDrivers { + t.Run("Should return db applied migrations for "+driver, func(t *testing.T) { + t.Parallel() + settings := makeSqlSettings(driver) + store := New(*settings, nil) + + assetsList, err := assets.ReadDir(filepath.Join("migrations", driver)) + require.NoError(t, err) + + var migrationsFromFiles []model.AppliedMigration + for _, entry := range assetsList { + if strings.HasSuffix(entry.Name(), ".up.sql") { + versionString := strings.Split(entry.Name(), "_")[0] + version, vErr := strconv.Atoi(versionString) + require.NoError(t, vErr) + + name := strings.TrimSuffix(strings.TrimLeft(entry.Name(), versionString+"_"), ".up.sql") + + migrationsFromFiles = append(migrationsFromFiles, model.AppliedMigration{ + Version: version, + Name: name, + }) + } + } + + require.NotEmpty(t, migrationsFromFiles) + + migrations, err := store.GetAppliedMigrations() + require.NoError(t, err) + require.ElementsMatch(t, migrationsFromFiles, migrations) + }) + } +} diff --git a/store/sqlstore/upgrade.go b/store/sqlstore/upgrade.go index 084d3ce61b..def7262e39 100644 --- a/store/sqlstore/upgrade.go +++ b/store/sqlstore/upgrade.go @@ -114,8 +114,12 @@ func upgradeDatabase(sqlStore *SqlStore, currentModelVersionString string) error return errors.Wrapf(err, "failed to parse oldest supported version %s", OldestSupportedVersion) } + currentSchemaVersionString, err := sqlStore.getCurrentSchemaVersion() + if err != nil { + mlog.Warn("could not receive the schema version from systems table", mlog.Err(err)) + } + var currentSchemaVersion *semver.Version - currentSchemaVersionString := sqlStore.GetCurrentSchemaVersion() if currentSchemaVersionString != "" { currentSchemaVersion, err = semver.New(currentSchemaVersionString) if err != nil { @@ -232,7 +236,11 @@ func saveSchemaVersion(sqlStore *SqlStore, version string) { } func shouldPerformUpgrade(sqlStore *SqlStore, currentSchemaVersion string, expectedSchemaVersion string) bool { - storedSchemaVersion := sqlStore.GetCurrentSchemaVersion() + storedSchemaVersion, err := sqlStore.getCurrentSchemaVersion() + if err != nil { + mlog.Error("could not receive the schema version from systems table", mlog.Err(err)) + return false + } storedVersion, err := semver.Parse(storedSchemaVersion) if err != nil { diff --git a/store/sqlstore/upgrade_test.go b/store/sqlstore/upgrade_test.go index f21f121264..60ae37893f 100644 --- a/store/sqlstore/upgrade_test.go +++ b/store/sqlstore/upgrade_test.go @@ -17,7 +17,11 @@ func TestStoreUpgradeDotRelease(t *testing.T) { saveSchemaVersion(sqlStore, "5.33.1") err := upgradeDatabase(sqlStore, CurrentSchemaVersion) require.NoError(t, err) - require.Equal(t, CurrentSchemaVersion, sqlStore.GetCurrentSchemaVersion()) + + currentVersion, err := sqlStore.getCurrentSchemaVersion() + require.NoError(t, err) + + require.Equal(t, CurrentSchemaVersion, currentVersion) }) } @@ -34,28 +38,44 @@ func TestStoreUpgrade(t *testing.T) { saveSchemaVersion(sqlStore, "invalid") err := upgradeDatabase(sqlStore, "5.8.0") require.EqualError(t, err, "failed to parse database schema version invalid: No Major.Minor.Patch elements found") - require.Equal(t, "invalid", sqlStore.GetCurrentSchemaVersion()) + + currentVersion, err := sqlStore.getCurrentSchemaVersion() + require.NoError(t, err) + + require.Equal(t, "invalid", currentVersion) }) t.Run("upgrade from unsupported version", func(t *testing.T) { saveSchemaVersion(sqlStore, "2.0.0") err := upgradeDatabase(sqlStore, "5.8.0") require.EqualError(t, err, "Database schema version 2.0.0 is no longer supported. This Mattermost server supports automatic upgrades from schema version 3.0.0 through schema version 5.8.0. Please manually upgrade to at least version 3.0.0 before continuing.") - require.Equal(t, "2.0.0", sqlStore.GetCurrentSchemaVersion()) + + currentVersion, err := sqlStore.getCurrentSchemaVersion() + require.NoError(t, err) + + require.Equal(t, "2.0.0", currentVersion) }) t.Run("upgrade from earliest supported version", func(t *testing.T) { saveSchemaVersion(sqlStore, Version300) err := upgradeDatabase(sqlStore, CurrentSchemaVersion) require.NoError(t, err) - require.Equal(t, CurrentSchemaVersion, sqlStore.GetCurrentSchemaVersion()) + + currentVersion, err := sqlStore.getCurrentSchemaVersion() + require.NoError(t, err) + + require.Equal(t, CurrentSchemaVersion, currentVersion) }) t.Run("upgrade from no existing version", func(t *testing.T) { saveSchemaVersion(sqlStore, "") err := upgradeDatabase(sqlStore, CurrentSchemaVersion) require.NoError(t, err) - require.Equal(t, CurrentSchemaVersion, sqlStore.GetCurrentSchemaVersion()) + + currentVersion, err := sqlStore.getCurrentSchemaVersion() + require.NoError(t, err) + + require.Equal(t, CurrentSchemaVersion, currentVersion) }) t.Run("upgrade schema running earlier minor version", func(t *testing.T) { @@ -64,28 +84,44 @@ func TestStoreUpgrade(t *testing.T) { require.NoError(t, err) // Assert CurrentSchemaVersion, not 5.8.0, since the migrations will move // past 5.8.0 regardless of the input parameter. - require.Equal(t, CurrentSchemaVersion, sqlStore.GetCurrentSchemaVersion()) + + currentVersion, err := sqlStore.getCurrentSchemaVersion() + require.NoError(t, err) + + require.Equal(t, CurrentSchemaVersion, currentVersion) }) t.Run("upgrade schema running later minor version", func(t *testing.T) { saveSchemaVersion(sqlStore, "5.99.0") err := upgradeDatabase(sqlStore, "5.8.0") require.NoError(t, err) - require.Equal(t, "5.99.0", sqlStore.GetCurrentSchemaVersion()) + + currentVersion, err := sqlStore.getCurrentSchemaVersion() + require.NoError(t, err) + + require.Equal(t, "5.99.0", currentVersion) }) t.Run("upgrade schema running earlier major version", func(t *testing.T) { saveSchemaVersion(sqlStore, "4.1.0") err := upgradeDatabase(sqlStore, CurrentSchemaVersion) require.NoError(t, err) - require.Equal(t, CurrentSchemaVersion, sqlStore.GetCurrentSchemaVersion()) + + currentVersion, err := sqlStore.getCurrentSchemaVersion() + require.NoError(t, err) + + require.Equal(t, CurrentSchemaVersion, currentVersion) }) t.Run("upgrade schema running later major version", func(t *testing.T) { saveSchemaVersion(sqlStore, "6.0.0") err := upgradeDatabase(sqlStore, "5.8.0") require.EqualError(t, err, "Database schema version 6.0.0 is not supported. This Mattermost server supports only >=5.8.0, <6.0.0. Please upgrade to at least version 6.0.0 before continuing.") - require.Equal(t, "6.0.0", sqlStore.GetCurrentSchemaVersion()) + + currentVersion, err := sqlStore.getCurrentSchemaVersion() + require.NoError(t, err) + + require.Equal(t, "6.0.0", currentVersion) }) }) } @@ -100,7 +136,11 @@ func TestSaveSchemaVersion(t *testing.T) { require.NoError(t, err) require.Equal(t, Version300, props["Version"]) - require.Equal(t, Version300, sqlStore.GetCurrentSchemaVersion()) + + currentVersion, err := sqlStore.getCurrentSchemaVersion() + require.NoError(t, err) + + require.Equal(t, Version300, currentVersion) }) t.Run("set current version", func(t *testing.T) { @@ -109,7 +149,11 @@ func TestSaveSchemaVersion(t *testing.T) { require.NoError(t, err) require.Equal(t, CurrentSchemaVersion, props["Version"]) - require.Equal(t, CurrentSchemaVersion, sqlStore.GetCurrentSchemaVersion()) + + currentVersion, err := sqlStore.getCurrentSchemaVersion() + require.NoError(t, err) + + require.Equal(t, CurrentSchemaVersion, currentVersion) }) }) } diff --git a/store/store.go b/store/store.go index d9433dd574..8136639ae1 100644 --- a/store/store.go +++ b/store/store.go @@ -63,7 +63,8 @@ type Store interface { UnlockFromMaster() DropAllTables() RecycleDBConnections(d time.Duration) - GetCurrentSchemaVersion() string + GetDBSchemaVersion() (int, error) + GetAppliedMigrations() ([]model.AppliedMigration, error) GetDbVersion(numerical bool) (string, error) TotalMasterDbConnections() int TotalReadDbConnections() int diff --git a/store/storetest/mocks/Store.go b/store/storetest/mocks/Store.go index 508e698ec7..88abc607ea 100644 --- a/store/storetest/mocks/Store.go +++ b/store/storetest/mocks/Store.go @@ -222,18 +222,48 @@ func (_m *Store) FileInfo() store.FileInfoStore { return r0 } -// GetCurrentSchemaVersion provides a mock function with given fields: -func (_m *Store) GetCurrentSchemaVersion() string { +// GetAppliedMigrations provides a mock function with given fields: +func (_m *Store) GetAppliedMigrations() ([]model.AppliedMigration, error) { ret := _m.Called() - var r0 string - if rf, ok := ret.Get(0).(func() string); ok { + var r0 []model.AppliedMigration + if rf, ok := ret.Get(0).(func() []model.AppliedMigration); ok { r0 = rf() } else { - r0 = ret.Get(0).(string) + if ret.Get(0) != nil { + r0 = ret.Get(0).([]model.AppliedMigration) + } } - return r0 + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetDBSchemaVersion provides a mock function with given fields: +func (_m *Store) GetDBSchemaVersion() (int, error) { + ret := _m.Called() + + var r0 int + if rf, ok := ret.Get(0).(func() int); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 } // GetDbVersion provides a mock function with given fields: numerical diff --git a/store/storetest/store.go b/store/storetest/store.go index d2217f5192..f814f0a9b6 100644 --- a/store/storetest/store.go +++ b/store/storetest/store.go @@ -104,10 +104,13 @@ func (s *Store) UnlockFromMaster() { /* do nothing */ } func (s *Store) DropAllTables() { /* do nothing */ } func (s *Store) GetDbVersion(bool) (string, error) { return "", nil } func (s *Store) RecycleDBConnections(time.Duration) {} -func (s *Store) TotalMasterDbConnections() int { return 1 } -func (s *Store) TotalReadDbConnections() int { return 1 } -func (s *Store) TotalSearchDbConnections() int { return 1 } -func (s *Store) GetCurrentSchemaVersion() string { return "" } +func (s *Store) GetDBSchemaVersion() (int, error) { return 1, nil } +func (s *Store) GetAppliedMigrations() ([]model.AppliedMigration, error) { + return []model.AppliedMigration{}, nil +} +func (s *Store) TotalMasterDbConnections() int { return 1 } +func (s *Store) TotalReadDbConnections() int { return 1 } +func (s *Store) TotalSearchDbConnections() int { return 1 } func (s *Store) CheckIntegrity() <-chan model.IntegrityCheckResult { return make(chan model.IntegrityCheckResult) } diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index f9574f0e2d..b03eeeb28c 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -10664,10 +10664,6 @@ func (s *TimerLayer) DropAllTables() { s.Store.DropAllTables() } -func (s *TimerLayer) GetCurrentSchemaVersion() string { - return s.Store.GetCurrentSchemaVersion() -} - func (s *TimerLayer) LockToMaster() { s.Store.LockToMaster() } diff --git a/testlib/store.go b/testlib/store.go index 688da733fe..ee2cb31d23 100644 --- a/testlib/store.go +++ b/testlib/store.go @@ -110,5 +110,7 @@ func GetMockStoreForSetupFunctions() *mocks.Store { mockStore.On("Session").Return(&sessionStore) mockStore.On("OAuth").Return(&oAuthStore) mockStore.On("Group").Return(&groupStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) + return &mockStore } diff --git a/web/context_test.go b/web/context_test.go index f47049b0ab..9cb71cf0b5 100644 --- a/web/context_test.go +++ b/web/context_test.go @@ -68,6 +68,7 @@ func TestMfaRequired(t *testing.T) { mockStore.On("User").Return(&mockUserStore) mockStore.On("Post").Return(&mockPostStore) mockStore.On("System").Return(&mockSystemStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) th.App.Srv().SetLicense(model.NewTestLicense("mfa")) diff --git a/web/handlers_test.go b/web/handlers_test.go index 2d93dd9db4..5ae26292c6 100644 --- a/web/handlers_test.go +++ b/web/handlers_test.go @@ -79,6 +79,7 @@ func TestHandlerServeHTTPSecureTransport(t *testing.T) { mockStore.On("User").Return(&mockUserStore) mockStore.On("Post").Return(&mockPostStore) mockStore.On("System").Return(&mockSystemStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) th.App.UpdateConfig(func(config *model.Config) { *config.ServiceSettings.TLSStrictTransport = true @@ -321,6 +322,7 @@ func TestHandlerServeCSPHeader(t *testing.T) { mockStore.On("User").Return(&mockUserStore) mockStore.On("Post").Return(&mockPostStore) mockStore.On("System").Return(&mockSystemStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = *cfg.ServiceSettings.SiteURL + "/subpath" @@ -641,6 +643,7 @@ func TestCheckCSRFToken(t *testing.T) { mockStore.On("User").Return(&mockUserStore) mockStore.On("Post").Return(&mockPostStore) mockStore.On("System").Return(&mockSystemStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.ExperimentalStrictCSRFEnforcement = true