diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index 6f40b0de1bd..de3db196de0 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -1711,6 +1711,36 @@ components: description: Map of all available LDAP attributes additionalProperties: type: string + ConfigChange: + type: object + description: A single setting change between two configuration versions. + properties: + path: + type: string + description: The dot-separated path of the changed setting. + old_value: + description: The previous value of the setting. Only present when detailed diffs are requested. + new_value: + description: The new value of the setting. Only present when detailed diffs are requested. + ConfigListItem: + type: object + description: Metadata about a stored configuration entry. + properties: + id: + type: string + description: The unique identifier of the configuration. + create_at: + type: integer + format: int64 + description: The time in milliseconds the configuration was created. + active: + type: boolean + description: Whether this is the currently active configuration. + changes: + type: array + description: The list of setting changes from the previous configuration. + items: + $ref: "#/components/schemas/ConfigChange" Config: type: object properties: diff --git a/api/v4/source/system.yaml b/api/v4/source/system.yaml index 780ed1c1aa2..486baa342c3 100644 --- a/api/v4/source/system.yaml +++ b/api/v4/source/system.yaml @@ -768,6 +768,87 @@ $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + /api/v4/config/list: + get: + tags: + - system + summary: List configuration history + description: | + Retrieve a list of previous configurations with optional diffs between them. + + ##### Permissions + Must have `manage_system` permission. + operationId: ListConfigurations + parameters: + - name: limit + in: query + description: The number of configuration history entries to return. Default is 5, maximum is 100. + required: false + schema: + type: integer + default: 5 + - name: include_diffs + in: query + description: Whether to include diffs between configurations. Set to "true" to include. + required: false + schema: + type: string + responses: + "200": + description: Configuration history retrieval successful + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ConfigListItem" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalServerError" + /api/v4/config/rollback: + post: + tags: + - system + summary: Rollback to a previous configuration + description: | + Rollback the server configuration to a previous version identified by its config ID. + + ##### Permissions + Must have `manage_system` permission. + operationId: RollbackConfig + requestBody: + description: The config ID to rollback to + required: true + content: + application/json: + schema: + type: object + properties: + config_id: + type: string + description: The ID of the configuration to rollback to + required: + - config_id + responses: + "200": + description: Configuration rollback successful + content: + application/json: + schema: + $ref: "#/components/schemas/Config" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" /api/v4/config/patch: put: tags: diff --git a/server/channels/api4/config.go b/server/channels/api4/config.go index c35ec4daa86..913a6904ab7 100644 --- a/server/channels/api4/config.go +++ b/server/channels/api4/config.go @@ -38,6 +38,8 @@ func (api *API) InitConfig() { api.BaseRoutes.APIRoot.Handle("/config/reload", api.APISessionRequired(configReload)).Methods(http.MethodPost) api.BaseRoutes.APIRoot.Handle("/config/client", api.APIHandler(getClientConfig)).Methods(http.MethodGet) api.BaseRoutes.APIRoot.Handle("/config/environment", api.APISessionRequired(getEnvironmentConfig)).Methods(http.MethodGet) + api.BaseRoutes.APIRoot.Handle("/config/list", api.APISessionRequired(listConfigurations)).Methods(http.MethodGet) + api.BaseRoutes.APIRoot.Handle("/config/rollback", api.APISessionRequired(rollbackConfig)).Methods(http.MethodPost) } func init() { @@ -458,3 +460,85 @@ func makeFilterConfigByPermission(accessType filterType) func(c *Context, struct return c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) } } + +func listConfigurations(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + auditRec := c.MakeAuditRecord(model.AuditEventListConfigurations, model.AuditStatusFail) + defer c.LogAuditRec(auditRec) + + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 { + limit = 5 + } + if limit > 100 { + limit = 100 + } + + includeDiffs := r.URL.Query().Get("include_diffs") + + items, appErr := c.App.ListConfigurations(limit, includeDiffs) + if appErr != nil { + c.Err = appErr + return + } + + if c.App.Channels().License().IsCloud() { + model.FilterCloudRestrictedChanges(items) + } + + auditRec.Success() + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + if err := json.NewEncoder(w).Encode(items); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func rollbackConfig(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) { + c.SetPermissionError(model.PermissionManageSystem) + return + } + + auditRec := c.MakeAuditRecord(model.AuditEventRollbackConfig, model.AuditStatusFail) + defer c.LogAuditRec(auditRec) + + var body struct { + ConfigID string `json:"config_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ConfigID == "" { + c.SetInvalidParamWithErr("config_id", err) + return + } + + auditRec.AddMeta("config_id", body.ConfigID) + + _, newCfg, appErr := c.App.RollbackConfig(body.ConfigID) + if appErr != nil { + c.Err = appErr + return + } + + c.App.SanitizedConfig(newCfg) + + auditRec.Success() + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + if c.App.Channels().License().IsCloud() { + js, err := newCfg.ToJSONFiltered(model.ConfigAccessTagType, model.ConfigAccessTagCloudRestrictable) + if err != nil { + c.Err = model.NewAppError("rollbackConfig", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + if _, err := w.Write(js); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } + return + } + + if err := json.NewEncoder(w).Encode(newCfg); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} diff --git a/server/channels/api4/config_local.go b/server/channels/api4/config_local.go index c6a64357fb8..6b8c683c404 100644 --- a/server/channels/api4/config_local.go +++ b/server/channels/api4/config_local.go @@ -22,6 +22,8 @@ func (api *API) InitConfigLocal() { api.BaseRoutes.APIRoot.Handle("/config/reload", api.APILocal(configReload)).Methods(http.MethodPost) api.BaseRoutes.APIRoot.Handle("/config/migrate", api.APILocal(localMigrateConfig)).Methods(http.MethodPost) api.BaseRoutes.APIRoot.Handle("/config/client", api.APILocal(localGetClientConfig)).Methods(http.MethodGet) + api.BaseRoutes.APIRoot.Handle("/config/list", api.APILocal(localListConfigurations)).Methods(http.MethodGet) + api.BaseRoutes.APIRoot.Handle("/config/rollback", api.APILocal(localRollbackConfig)).Methods(http.MethodPost) } func localGetConfig(c *Context, w http.ResponseWriter, r *http.Request) { @@ -199,3 +201,57 @@ func localGetClientConfig(c *Context, w http.ResponseWriter, r *http.Request) { c.Logger.Warn("Error while writing response", mlog.Err(err)) } } + +func localListConfigurations(c *Context, w http.ResponseWriter, r *http.Request) { + auditRec := c.MakeAuditRecord(model.AuditEventListConfigurations, model.AuditStatusFail) + defer c.LogAuditRec(auditRec) + + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 { + limit = 5 + } + if limit > 100 { + limit = 100 + } + + includeDiffs := r.URL.Query().Get("include_diffs") + + items, appErr := c.App.ListConfigurations(limit, includeDiffs) + if appErr != nil { + c.Err = appErr + return + } + + auditRec.Success() + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + if err := json.NewEncoder(w).Encode(items); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func localRollbackConfig(c *Context, w http.ResponseWriter, r *http.Request) { + auditRec := c.MakeAuditRecord(model.AuditEventRollbackConfig, model.AuditStatusFail) + defer c.LogAuditRec(auditRec) + + var body struct { + ConfigID string `json:"config_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ConfigID == "" { + c.SetInvalidParamWithErr("config_id", err) + return + } + + _, newCfg, appErr := c.App.RollbackConfig(body.ConfigID) + if appErr != nil { + c.Err = appErr + return + } + + c.App.SanitizedConfig(newCfg) + + auditRec.Success() + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + if err := json.NewEncoder(w).Encode(newCfg); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} diff --git a/server/channels/app/config.go b/server/channels/app/config.go index 12c0f4eb7b1..f762f26843f 100644 --- a/server/channels/app/config.go +++ b/server/channels/app/config.go @@ -6,7 +6,10 @@ package app import ( "crypto/ecdsa" "crypto/rand" + "database/sql" "encoding/json" + stderrors "errors" + "net/http" "net/url" "reflect" "strconv" @@ -244,6 +247,27 @@ func (a *App) SaveConfig(newCfg *model.Config, sendConfigChangeClusterMessage bo return a.Srv().platform.SaveConfig(newCfg, sendConfigChangeClusterMessage) } +// ListConfigurations returns metadata for stored configuration entries with optional diffs. +func (a *App) ListConfigurations(limit int, includeDiffs string) ([]*model.ConfigListItem, *model.AppError) { + items, err := a.Srv().platform.ListConfigurations(limit, includeDiffs) + if err != nil { + return nil, model.NewAppError("ListConfigurations", "api.config.list_configurations.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return items, nil +} + +// RollbackConfig restores a historical configuration identified by its ID. +func (a *App) RollbackConfig(id string) (*model.Config, *model.Config, *model.AppError) { + historicalCfg, err := a.Srv().platform.GetConfigByID(id) + if err != nil { + if stderrors.Is(err, sql.ErrNoRows) { + return nil, nil, model.NewAppError("RollbackConfig", "api.config.rollback_config.not_found.app_error", nil, "", http.StatusNotFound).Wrap(err) + } + return nil, nil, model.NewAppError("RollbackConfig", "api.config.rollback_config.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return a.SaveConfig(historicalCfg, true) +} + func (a *App) HandleMessageExportConfig(cfg *model.Config, appCfg *model.Config) { // If the Message Export feature has been toggled in the System Console, rewrite the ExportFromTimestamp field to an // appropriate value. The rewriting occurs here to ensure it doesn't affect values written to the config file diff --git a/server/channels/app/platform/config.go b/server/channels/app/platform/config.go index 562d420091c..a6fdd526d7f 100644 --- a/server/channels/app/platform/config.go +++ b/server/channels/app/platform/config.go @@ -151,6 +151,16 @@ func (ps *PlatformService) CleanUpConfig() error { return ps.configStore.CleanUp() } +// ListConfigurations delegates to the config store to retrieve configuration history. +func (ps *PlatformService) ListConfigurations(limit int, includeDiffs string) ([]*model.ConfigListItem, error) { + return ps.configStore.ListConfigurations(limit, includeDiffs) +} + +// GetConfigByID delegates to the config store to retrieve a configuration by its ID. +func (ps *PlatformService) GetConfigByID(id string) (*model.Config, error) { + return ps.configStore.GetConfigByID(id) +} + // ConfigureLogger applies the specified configuration to a logger. func (ps *PlatformService) ConfigureLogger(name string, logger *mlog.Logger, logSettings *model.LogSettings, getPath func(string) string) error { // Advanced logging is E20 only, however logging must be initialized before the license diff --git a/server/cmd/mmctl/client/client.go b/server/cmd/mmctl/client/client.go index 4f157884068..0ea35659c33 100644 --- a/server/cmd/mmctl/client/client.go +++ b/server/cmd/mmctl/client/client.go @@ -102,6 +102,8 @@ type Client interface { PatchConfig(context.Context, *model.Config) (*model.Config, *model.Response, error) ReloadConfig(ctx context.Context) (*model.Response, error) MigrateConfig(ctx context.Context, from, to string) (*model.Response, error) + ListConfigurations(ctx context.Context, limit int, includeDiffs string) ([]*model.ConfigListItem, *model.Response, error) + RollbackConfig(ctx context.Context, configID string) (*model.Config, *model.Response, error) SyncLdap(ctx context.Context) (*model.Response, error) MigrateIdLdap(ctx context.Context, toAttribute string) (*model.Response, error) GetUsers(ctx context.Context, page, perPage int, etag string) ([]*model.User, *model.Response, error) diff --git a/server/cmd/mmctl/commands/config.go b/server/cmd/mmctl/commands/config.go index 5b65501586d..d1d929b04bc 100644 --- a/server/cmd/mmctl/commands/config.go +++ b/server/cmd/mmctl/commands/config.go @@ -12,6 +12,7 @@ import ( "reflect" "strconv" "strings" + "time" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/v8/channels/utils" @@ -129,6 +130,24 @@ var ConfigExportCmd = &cobra.Command{ RunE: withClient(configExportCmdF), } +var ConfigListCmd = &cobra.Command{ + Use: "list", + Short: "List stored configurations", + Long: "Lists recent configuration entries from the database-backed config store, showing Id, creation time, and active status.", + Example: " config list\n config list --limit 10", + Args: cobra.NoArgs, + RunE: withClient(configListCmdF), +} + +var ConfigRollbackCmd = &cobra.Command{ + Use: "rollback [config_id]", + Short: "Rollback to a previous configuration", + Long: "Restores a previous configuration by ID. Use 'config list' to find available config IDs. This creates a new config entry preserving the full audit trail.", + Example: " config rollback abc123def456ghi789jkl0mn", + Args: cobra.ExactArgs(1), + RunE: withClient(configRollbackCmdF), +} + func init() { ConfigResetCmd.Flags().Bool("confirm", false, "confirm you really want to reset all configuration settings to its default value") @@ -140,6 +159,10 @@ func init() { ConfigExportCmd.Flags().Bool("remove-masked", true, "remove masked values from the exported configuration") ConfigExportCmd.Flags().Bool("remove-defaults", false, "remove default values from the exported configuration") + ConfigListCmd.Flags().Int("limit", 5, "Maximum number of configurations to list") + ConfigListCmd.Flags().Bool("detailed", false, "Show old and new values for each change") + ConfigListCmd.Flags().Bool("no-delta", false, "Skip delta computation, show only config metadata") + ConfigCmd.AddCommand( ConfigGetCmd, ConfigSetCmd, @@ -151,6 +174,8 @@ func init() { ConfigMigrateCmd, ConfigSubpathCmd, ConfigExportCmd, + ConfigListCmd, + ConfigRollbackCmd, ) RootCmd.AddCommand(ConfigCmd) } @@ -611,3 +636,105 @@ func configExportCmdF(c client.Client, cmd *cobra.Command, _ []string) error { return nil } + +func configListCmdF(c client.Client, cmd *cobra.Command, args []string) error { + limit, _ := cmd.Flags().GetInt("limit") + detailed, _ := cmd.Flags().GetBool("detailed") + noDelta, _ := cmd.Flags().GetBool("no-delta") + + includeDiffs := "true" + if detailed { + includeDiffs = "detailed" + } + if noDelta { + includeDiffs = "" + } + + items, _, err := c.ListConfigurations(context.TODO(), limit, includeDiffs) + if err != nil { + return fmt.Errorf("unable to list configurations: %w", err) + } + + if len(items) == 0 { + if printer.GetFormat() == printer.FormatJSON { + printer.Print([]any{}) + } else { + printer.Print("No configurations found.") + } + return nil + } + + // "%-26s %s %-8s " = Id(26) + 2 + date(20) + 2 + status(8) + 2 + const prefix = "%-26s %s %-8s" + const padWidth = 26 + 2 + 20 + 2 + 8 + 2 + pad := strings.Repeat(" ", padWidth) + + for _, item := range items { + created := time.UnixMilli(item.CreateAt).UTC().Format(time.RFC3339) + status := "inactive" + if item.Active { + status = "active" + } + header := fmt.Sprintf(prefix, item.Id, created, status) + + if printer.GetFormat() != printer.FormatJSON && len(item.Changes) > 0 { + for i, change := range item.Changes { + var changeLine string + if detailed { + oldStr := formatConfigValue(change.OldValue) + newStr := formatConfigValue(change.NewValue) + if oldStr == model.FakeSetting || newStr == model.FakeSetting { + changeLine = fmt.Sprintf("%s: [redacted]", change.Path) + } else { + changeLine = fmt.Sprintf("%s: %s -> %s", change.Path, oldStr, newStr) + } + } else { + changeLine = change.Path + } + if i == 0 { + printer.PrintT(header+" "+changeLine, item) + } else { + printer.Print(pad + changeLine) + } + } + } else { + printer.PrintT(header, item) + } + } + + return nil +} + +func formatConfigValue(v any) string { + if v == nil { + return "" + } + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Map, reflect.Slice: + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(b) + case reflect.Float32, reflect.Float64: + f := rv.Float() + if f == float64(int64(f)) { + return strconv.FormatInt(int64(f), 10) + } + return strconv.FormatFloat(f, 'f', -1, 64) + default: + return fmt.Sprintf("%v", v) + } +} + +func configRollbackCmdF(c client.Client, cmd *cobra.Command, args []string) error { + _, _, err := c.RollbackConfig(context.TODO(), args[0]) + if err != nil { + return fmt.Errorf("unable to rollback configuration: %w", err) + } + + printer.Print(fmt.Sprintf("Configuration rolled back successfully to %s.", args[0])) + + return nil +} diff --git a/server/cmd/mmctl/commands/config_test.go b/server/cmd/mmctl/commands/config_test.go index b42dba1340b..f60d64f99d4 100644 --- a/server/cmd/mmctl/commands/config_test.go +++ b/server/cmd/mmctl/commands/config_test.go @@ -1025,3 +1025,198 @@ func (s *MmctlUnitTestSuite) TestConfigExportCmd() { s.Require().Len(printer.GetErrorLines(), 0) }) } + +func (s *MmctlUnitTestSuite) TestConfigListCmd() { + newListCmd := func() *cobra.Command { + cmd := &cobra.Command{} + cmd.Flags().Int("limit", 5, "") + cmd.Flags().Bool("detailed", false, "") + cmd.Flags().Bool("no-delta", false, "") + return cmd + } + + s.Run("List configurations with deltas by default", func() { + printer.Clean() + items := []*model.ConfigListItem{ + { + Id: "abc123def456ghi789jkl0mn", CreateAt: 1700000000000, Active: true, + Changes: []model.ConfigChange{{Path: "ServiceSettings.SiteURL"}}, + }, + {Id: "xyz789abc012def345ghi6mn", CreateAt: 1699999000000, Active: false}, + } + + s.client. + EXPECT(). + ListConfigurations(context.TODO(), 5, "true"). + Return(items, &model.Response{}, nil). + Times(1) + + err := configListCmdF(s.client, newListCmd(), []string{}) + s.Require().Nil(err) + // In JSON mode (test default), changes are embedded in struct, not separate lines + s.Require().Len(printer.GetLines(), 2) + s.Require().Len(printer.GetErrorLines(), 0) + // Verify the changes are embedded in the first item + item := printer.GetLines()[0].(*model.ConfigListItem) + s.Require().Len(item.Changes, 1) + s.Require().Equal("ServiceSettings.SiteURL", item.Changes[0].Path) + }) + + s.Run("List configurations with custom limit", func() { + printer.Clean() + items := []*model.ConfigListItem{ + {Id: "abc123def456ghi789jkl0mn", CreateAt: 1700000000000, Active: true}, + } + + s.client. + EXPECT(). + ListConfigurations(context.TODO(), 10, "true"). + Return(items, &model.Response{}, nil). + Times(1) + + cmd := newListCmd() + _ = cmd.Flags().Set("limit", "10") + + err := configListCmdF(s.client, cmd, []string{}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + }) + + s.Run("List configurations returns error", func() { + printer.Clean() + + s.client. + EXPECT(). + ListConfigurations(context.TODO(), 5, "true"). + Return(nil, &model.Response{}, errors.New("database error")). + Times(1) + + err := configListCmdF(s.client, newListCmd(), []string{}) + s.Require().NotNil(err) + s.Require().Contains(err.Error(), "unable to list configurations") + }) + + s.Run("No configurations found", func() { + printer.Clean() + + s.client. + EXPECT(). + ListConfigurations(context.TODO(), 5, "true"). + Return([]*model.ConfigListItem{}, &model.Response{}, nil). + Times(1) + + err := configListCmdF(s.client, newListCmd(), []string{}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + }) + + s.Run("Detailed mode shows old and new values", func() { + printer.Clean() + items := []*model.ConfigListItem{ + { + Id: "abc123def456ghi789jkl0mn", CreateAt: 1700000000000, Active: true, + Changes: []model.ConfigChange{ + {Path: "ServiceSettings.SiteURL", OldValue: "", NewValue: "https://mm.example.com"}, + }, + }, + } + + s.client. + EXPECT(). + ListConfigurations(context.TODO(), 5, "detailed"). + Return(items, &model.Response{}, nil). + Times(1) + + cmd := newListCmd() + _ = cmd.Flags().Set("detailed", "true") + + err := configListCmdF(s.client, cmd, []string{}) + s.Require().Nil(err) + // In JSON mode, changes embedded in struct + s.Require().Len(printer.GetLines(), 1) + item := printer.GetLines()[0].(*model.ConfigListItem) + s.Require().Len(item.Changes, 1) + s.Require().Equal("https://mm.example.com", item.Changes[0].NewValue) + }) + + s.Run("No-delta takes precedence over detailed", func() { + printer.Clean() + items := []*model.ConfigListItem{ + {Id: "abc123def456ghi789jkl0mn", CreateAt: 1700000000000, Active: true}, + {Id: "xyz789abc012def345ghi6mn", CreateAt: 1699999000000, Active: false}, + } + + s.client. + EXPECT(). + ListConfigurations(context.TODO(), 5, ""). + Return(items, &model.Response{}, nil). + Times(1) + + cmd := newListCmd() + _ = cmd.Flags().Set("detailed", "true") + _ = cmd.Flags().Set("no-delta", "true") + + err := configListCmdF(s.client, cmd, []string{}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 2) + // Verify no changes are present on either item + for _, line := range printer.GetLines() { + item := line.(*model.ConfigListItem) + s.Require().Empty(item.Changes) + } + }) + + s.Run("No-delta mode skips diffs", func() { + printer.Clean() + items := []*model.ConfigListItem{ + {Id: "abc123def456ghi789jkl0mn", CreateAt: 1700000000000, Active: true}, + {Id: "xyz789abc012def345ghi6mn", CreateAt: 1699999000000, Active: false}, + } + + s.client. + EXPECT(). + ListConfigurations(context.TODO(), 5, ""). + Return(items, &model.Response{}, nil). + Times(1) + + cmd := newListCmd() + _ = cmd.Flags().Set("no-delta", "true") + + err := configListCmdF(s.client, cmd, []string{}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 2) + }) +} + +func (s *MmctlUnitTestSuite) TestConfigRollbackCmd() { + s.Run("Successful rollback", func() { + printer.Clean() + cfg := &model.Config{} + cfg.SetDefaults() + + s.client. + EXPECT(). + RollbackConfig(context.TODO(), "abc123def456ghi789jkl0mn"). + Return(cfg, &model.Response{}, nil). + Times(1) + + err := configRollbackCmdF(s.client, &cobra.Command{}, []string{"abc123def456ghi789jkl0mn"}) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + s.Require().Contains(printer.GetLines()[0], "rolled back successfully") + }) + + s.Run("Rollback returns error", func() { + printer.Clean() + + s.client. + EXPECT(). + RollbackConfig(context.TODO(), "nonexistent"). + Return(nil, &model.Response{}, errors.New("configuration not found")). + Times(1) + + err := configRollbackCmdF(s.client, &cobra.Command{}, []string{"nonexistent"}) + s.Require().NotNil(err) + s.Require().Contains(err.Error(), "unable to rollback configuration") + }) +} diff --git a/server/cmd/mmctl/mocks/client_mock.go b/server/cmd/mmctl/mocks/client_mock.go index de9182a593c..752d6051614 100644 --- a/server/cmd/mmctl/mocks/client_mock.go +++ b/server/cmd/mmctl/mocks/client_mock.go @@ -1691,6 +1691,22 @@ func (mr *MockClientMockRecorder) ListCommands(arg0, arg1, arg2 interface{}) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCommands", reflect.TypeOf((*MockClient)(nil).ListCommands), arg0, arg1, arg2) } +// ListConfigurations mocks base method. +func (m *MockClient) ListConfigurations(arg0 context.Context, arg1 int, arg2 string) ([]*model.ConfigListItem, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListConfigurations", arg0, arg1, arg2) + ret0, _ := ret[0].([]*model.ConfigListItem) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ListConfigurations indicates an expected call of ListConfigurations. +func (mr *MockClientMockRecorder) ListConfigurations(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListConfigurations", reflect.TypeOf((*MockClient)(nil).ListConfigurations), arg0, arg1, arg2) +} + // ListExports mocks base method. func (m *MockClient) ListExports(arg0 context.Context) ([]string, *model.Response, error) { m.ctrl.T.Helper() @@ -2218,6 +2234,22 @@ func (mr *MockClientMockRecorder) RevokeUserAccessToken(arg0, arg1 interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RevokeUserAccessToken", reflect.TypeOf((*MockClient)(nil).RevokeUserAccessToken), arg0, arg1) } +// RollbackConfig mocks base method. +func (m *MockClient) RollbackConfig(arg0 context.Context, arg1 string) (*model.Config, *model.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RollbackConfig", arg0, arg1) + ret0, _ := ret[0].(*model.Config) + ret1, _ := ret[1].(*model.Response) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// RollbackConfig indicates an expected call of RollbackConfig. +func (mr *MockClientMockRecorder) RollbackConfig(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RollbackConfig", reflect.TypeOf((*MockClient)(nil).RollbackConfig), arg0, arg1) +} + // SearchTeams mocks base method. func (m *MockClient) SearchTeams(arg0 context.Context, arg1 *model.TeamSearch) ([]*model.Team, *model.Response, error) { m.ctrl.T.Helper() diff --git a/server/cmd/mmctl/printer/printer.go b/server/cmd/mmctl/printer/printer.go index db199bb58cb..f85b10e9b0c 100644 --- a/server/cmd/mmctl/printer/printer.go +++ b/server/cmd/mmctl/printer/printer.go @@ -62,6 +62,11 @@ func SetFormat(t string) { printer.Format = t } +// GetFormat returns the current output format +func GetFormat() string { + return printer.Format +} + func SetCommand(cmd *cobra.Command) { printer.cmd = cmd } diff --git a/server/config/database.go b/server/config/database.go index 06203243c7f..52e6ed53c99 100644 --- a/server/config/database.go +++ b/server/config/database.go @@ -13,6 +13,7 @@ import ( "encoding/json" "fmt" "path/filepath" + "sort" "strings" "github.com/jmoiron/sqlx" @@ -350,3 +351,153 @@ func (ds *DatabaseStore) cleanUp(thresholdCreateAt int64) error { return nil } + +func (ds *DatabaseStore) listConfigurations(limit int, includeDiffs string) ([]*model.ConfigListItem, error) { + type configListRow struct { + Id string `db:"id"` + CreateAt int64 `db:"createat"` + Active bool `db:"active"` + } + + query := ` + SELECT Id, CreateAt, COALESCE(Active, false) AS Active + FROM Configurations + ORDER BY Active DESC NULLS LAST, CreateAt DESC + LIMIT $1 + ` + + // When diffs are enabled, fetch one extra predecessor row so the oldest + // visible item can be diffed against its predecessor. + queryLimit := limit + if includeDiffs != "" { + queryLimit = limit + 1 + } + + var rows []configListRow + if err := ds.db.Select(&rows, query, queryLimit); err != nil { + return nil, errors.Wrap(err, "failed to list configurations") + } + + // Build the full set for diffing, but only return up to limit items + allItems := make([]*model.ConfigListItem, len(rows)) + for i, row := range rows { + allItems[i] = &model.ConfigListItem{ + Id: row.Id, + CreateAt: row.CreateAt, + Active: row.Active, + } + } + + // Items to return (capped at limit) + items := allItems + if len(items) > limit { + items = allItems[:limit] + } + + if includeDiffs == "" || len(allItems) <= 1 { + return items, nil + } + + // Fetch config values for all rows (including the extra predecessor) + // and compute diffs between consecutive entries. + ids := make([]string, len(allItems)) + for i, item := range allItems { + ids[i] = item.Id + } + + values, err := ds.getConfigValuesByIDs(ids) + if err != nil { + return nil, err + } + + // Unmarshal configs and apply SetDefaults() so that diffs only reflect + // intentional user changes, not new fields introduced by server upgrades. + // Without SetDefaults(), upgrading the server would cause every new config + // field to appear as a diff (nil -> default value), which is noise. + configs := make(map[string]*model.Config, len(values)) + for id, raw := range values { + cfg := &model.Config{} + if err := json.Unmarshal(raw, cfg); err != nil { + return nil, errors.Wrapf(err, "failed to unmarshal config %s", id) + } + cfg.SetDefaults() + configs[id] = cfg + } + + // Sort chronologically (oldest first) for diffing, using allItems + // so the extra predecessor is included in the diff computation. + sorted := make([]*model.ConfigListItem, len(allItems)) + copy(sorted, allItems) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].CreateAt < sorted[j].CreateAt + }) + + detailed := includeDiffs == "detailed" + changesMap := make(map[string][]model.ConfigChange) + for i := 1; i < len(sorted); i++ { + prevCfg := configs[sorted[i-1].Id] + currCfg := configs[sorted[i].Id] + if prevCfg == nil || currCfg == nil { + continue + } + diffs, diffErr := Diff(prevCfg, currCfg) + if diffErr != nil { + continue + } + diffs = diffs.Sanitize() + + changes := make([]model.ConfigChange, len(diffs)) + for j, d := range diffs { + changes[j] = model.ConfigChange{Path: d.Path} + if detailed { + changes[j].OldValue = d.BaseVal + changes[j].NewValue = d.ActualVal + } + } + changesMap[sorted[i].Id] = changes + } + + for _, item := range items { + if c, ok := changesMap[item.Id]; ok { + item.Changes = c + } + } + + return items, nil +} + +func (ds *DatabaseStore) getConfigValuesByIDs(ids []string) (map[string][]byte, error) { + query, args, err := sqlx.In("SELECT Id, Value FROM Configurations WHERE Id IN (?)", ids) + if err != nil { + return nil, errors.Wrap(err, "failed to build query") + } + query = ds.db.Rebind(query) + + type row struct { + Id string `db:"id"` + Value []byte `db:"value"` + } + var rows []row + if err := ds.db.Select(&rows, query, args...); err != nil { + return nil, errors.Wrap(err, "failed to fetch configuration values") + } + + result := make(map[string][]byte, len(rows)) + for _, r := range rows { + result[r.Id] = r.Value + } + return result, nil +} + +func (ds *DatabaseStore) getConfigByID(id string) (*model.Config, error) { + var value []byte + if err := ds.db.Get(&value, "SELECT Value FROM Configurations WHERE Id = $1", id); err != nil { + return nil, errors.Wrapf(err, "failed to fetch configuration %s", id) + } + + cfg := &model.Config{} + if err := json.Unmarshal(value, cfg); err != nil { + return nil, errors.Wrapf(err, "failed to unmarshal configuration %s", id) + } + return cfg, nil +} diff --git a/server/config/store.go b/server/config/store.go index e411f34c82f..dbe6f099c6a 100644 --- a/server/config/store.go +++ b/server/config/store.go @@ -411,3 +411,25 @@ func (s *Store) CleanUp() error { return nil } } + +// ListConfigurations returns metadata for recent configurations. +// This only works with DatabaseStore; returns an error for FileStore. +func (s *Store) ListConfigurations(limit int, includeDiffs string) ([]*model.ConfigListItem, error) { + switch bs := s.backingStore.(type) { + case *DatabaseStore: + return bs.listConfigurations(limit, includeDiffs) + default: + return nil, errors.New("listing configurations is only supported with database-backed config stores") + } +} + +// GetConfigByID retrieves a historical configuration by its ID. +// This only works with DatabaseStore; returns an error for FileStore. +func (s *Store) GetConfigByID(id string) (*model.Config, error) { + switch bs := s.backingStore.(type) { + case *DatabaseStore: + return bs.getConfigByID(id) + default: + return nil, errors.New("retrieving configurations by ID is only supported with database-backed config stores") + } +} diff --git a/server/i18n/en.json b/server/i18n/en.json index b826e4c0f33..f4fad11e072 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -1897,6 +1897,18 @@ "id": "api.config.get_config.restricted_merge.app_error", "translation": "Failed to merge given config." }, + { + "id": "api.config.list_configurations.app_error", + "translation": "Unable to list configurations." + }, + { + "id": "api.config.rollback_config.app_error", + "translation": "Unable to rollback configuration." + }, + { + "id": "api.config.rollback_config.not_found.app_error", + "translation": "Configuration not found." + }, { "id": "api.config.migrate_config.app_error", "translation": "Failed to migrate config store." diff --git a/server/public/model/audit_events.go b/server/public/model/audit_events.go index 16447f76cec..dde287ab1aa 100644 --- a/server/public/model/audit_events.go +++ b/server/public/model/audit_events.go @@ -137,7 +137,9 @@ const ( const ( AuditEventConfigReload = "configReload" // reload server configuration AuditEventGetConfig = "getConfig" // get current server configuration + AuditEventListConfigurations = "listConfigurations" // list stored configuration history AuditEventLocalGetClientConfig = "localGetClientConfig" // get client configuration locally + AuditEventRollbackConfig = "rollbackConfig" // rollback to a previous configuration AuditEventLocalGetConfig = "localGetConfig" // get server configuration locally AuditEventLocalPatchConfig = "localPatchConfig" // update server configuration locally AuditEventLocalUpdateConfig = "localUpdateConfig" // update server configuration locally diff --git a/server/public/model/client4.go b/server/public/model/client4.go index c3eb8c76e43..cc790d4d083 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -4451,6 +4451,34 @@ func (c *Client4) ReloadConfig(ctx context.Context) (*Response, error) { return BuildResponse(r), nil } +// ListConfigurations returns metadata for stored configuration entries. +func (c *Client4) ListConfigurations(ctx context.Context, limit int, includeDiffs string) ([]*ConfigListItem, *Response, error) { + query := url.Values{} + if limit > 0 { + query.Set("limit", strconv.Itoa(limit)) + } + if includeDiffs != "" { + query.Set("include_diffs", includeDiffs) + } + r, err := c.doAPIGetWithQuery(ctx, c.configRoute().Join("list"), query, "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + return DecodeJSONFromResponse[[]*ConfigListItem](r) +} + +// RollbackConfig restores a historical configuration by ID. +func (c *Client4) RollbackConfig(ctx context.Context, configID string) (*Config, *Response, error) { + body := map[string]string{"config_id": configID} + r, err := c.doAPIPostJSON(ctx, c.configRoute().Join("rollback"), body) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + return DecodeJSONFromResponse[*Config](r) +} + // GetClientConfig will retrieve the parts of the server configuration needed by the client. func (c *Client4) GetClientConfig(ctx context.Context, etag string) (map[string]string, *Response, error) { r, err := c.doAPIGet(ctx, c.configRoute().Join("client"), etag) diff --git a/server/public/model/config.go b/server/public/model/config.go index d975f59c46b..e73100512af 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -5359,6 +5359,89 @@ type GetConfigOptions struct { RemoveDefaults bool } +// ConfigChange represents a single setting change between two configuration versions. +type ConfigChange struct { + Path string `json:"path"` + OldValue any `json:"old_value,omitempty"` + NewValue any `json:"new_value,omitempty"` +} + +// ConfigListItem represents metadata about a stored configuration entry. +type ConfigListItem struct { + Id string `json:"id"` + CreateAt int64 `json:"create_at"` + Active bool `json:"active"` + Changes []ConfigChange `json:"changes,omitempty"` +} + +// collectTaggedPaths walks a struct type and returns all dot-separated field +// paths that carry the given tag value in the specified tag key. +func collectTaggedPaths(t reflect.Type, tagKey, tagValue, prefix string) map[string]bool { + paths := map[string]bool{} + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + fieldPath := field.Name + if prefix != "" { + fieldPath = prefix + "." + field.Name + } + + tags := strings.Split(field.Tag.Get(tagKey), ",") + if isTagPresent(tagValue, tags) { + paths[fieldPath] = true + continue + } + + ft := field.Type + if ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + if ft.Kind() == reflect.Struct { + for p := range collectTaggedPaths(ft, tagKey, tagValue, fieldPath) { + paths[p] = true + } + } + } + return paths +} + +// CloudRestrictedPaths returns the set of config field paths tagged as cloud_restrictable. +func CloudRestrictedPaths() map[string]bool { + return collectTaggedPaths(reflect.TypeFor[Config](), ConfigAccessTagType, ConfigAccessTagCloudRestrictable, "") +} + +// isCloudRestricted checks whether a change path matches or is a descendant +// of any cloud-restrictable config field path (e.g. "SqlSettings.ReplicaLagSettings" +// also matches "SqlSettings.ReplicaLagSettings[0].DataSource"). +func isCloudRestricted(path string, restricted map[string]bool) bool { + if restricted[path] { + return true + } + for rp := range restricted { + if strings.HasPrefix(path, rp+".") || strings.HasPrefix(path, rp+"[") { + return true + } + } + return false +} + +// FilterCloudRestrictedChanges removes changes whose paths match or descend from +// cloud-restrictable config fields. +func FilterCloudRestrictedChanges(items []*ConfigListItem) { + restricted := CloudRestrictedPaths() + for _, item := range items { + if len(item.Changes) == 0 { + continue + } + filtered := make([]ConfigChange, 0, len(item.Changes)) + for _, ch := range item.Changes { + if !isCloudRestricted(ch.Path, restricted) { + filtered = append(filtered, ch) + } + } + item.Changes = filtered + } +} + // FilterConfig returns a map[string]any representation of the configuration. // Also, the function can filter the configuration by the options passed // in the argument. The options are used to remove the default values, the masked