This commit is contained in:
Wayne Wollesen 2026-05-23 03:03:45 +02:00 committed by GitHub
commit 39ad886efa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 944 additions and 0 deletions

View file

@ -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:

View file

@ -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:

View file

@ -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))
}
}

View file

@ -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))
}
}

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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 "<nil>"
}
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
}

View file

@ -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")
})
}

View file

@ -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()

View file

@ -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
}

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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."

View file

@ -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

View file

@ -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)

View file

@ -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