snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,193 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)
var coverprofileCounters = make(map[string]int)
var mainHelper *testlib.MainHelper
type testHelper struct {
*api4.TestHelper
config *model.Config
tempDir string
configFilePath string
disableAutoConfig bool
}
// Setup creates an instance of testHelper.
func SetupWithStoreMock(tb testing.TB) *testHelper {
dir, err := testlib.SetupTestResources()
require.NoError(tb, err)
tb.Cleanup(func() {
err = os.RemoveAll(dir)
require.NoError(tb, err)
})
api4TestHelper := api4.SetupWithStoreMock(tb)
systemStore := mocks.SystemStore{}
systemStore.On("Get").Return(make(model.StringMap), nil)
licenseStore := mocks.LicenseStore{}
licenseStore.On("Get", "").Return(&model.LicenseRecord{}, nil)
api4TestHelper.App.Srv().Store().(*mocks.Store).On("System").Return(&systemStore)
api4TestHelper.App.Srv().Store().(*mocks.Store).On("License").Return(&licenseStore)
testHelper := &testHelper{
TestHelper: api4TestHelper,
tempDir: dir,
configFilePath: filepath.Join(dir, "config-helper.json"),
}
config := &model.Config{}
config.SetDefaults()
testHelper.SetConfig(config)
return testHelper
}
// InitBasic simply proxies to api4.InitBasic, while still returning a testHelper.
func (h *testHelper) InitBasic(tb testing.TB) *testHelper {
h.TestHelper.InitBasic(tb)
return h
}
// TemporaryDirectory returns the temporary directory created for user by the test helper.
func (h *testHelper) TemporaryDirectory() string {
return h.tempDir
}
// Config returns the configuration passed to a running command.
func (h *testHelper) Config() *model.Config {
return h.config.Clone()
}
// ConfigPath returns the path to the temporary config file passed to a running command.
func (h *testHelper) ConfigPath() string {
return h.configFilePath
}
// SetConfig replaces the configuration passed to a running command.
func (h *testHelper) SetConfig(config *model.Config) {
if !testing.Short() {
config.SqlSettings = *mainHelper.GetSQLSettings()
}
h.config = config
buf, err := json.Marshal(config)
if err != nil {
panic("failed to marshal config: " + err.Error())
}
if err := os.WriteFile(h.configFilePath, buf, 0600); err != nil {
panic("failed to write file " + h.configFilePath + ": " + err.Error())
}
}
// SetAutoConfig configures whether the --config flag is automatically passed to a running command.
func (h *testHelper) SetAutoConfig(autoConfig bool) {
h.disableAutoConfig = !autoConfig
}
func (h *testHelper) execArgs(t *testing.T, args []string) []string {
ret := []string{"-test.v", "-test.run", "ExecCommand"}
if coverprofile := flag.Lookup("test.coverprofile").Value.String(); coverprofile != "" {
dir := filepath.Dir(coverprofile)
base := filepath.Base(coverprofile)
baseParts := strings.SplitN(base, ".", 2)
name := strings.Replace(t.Name(), "/", "_", -1)
coverprofileCounters[name] = coverprofileCounters[name] + 1
baseParts[0] = fmt.Sprintf("%v-%v-%v", baseParts[0], name, coverprofileCounters[name])
ret = append(ret, "-test.coverprofile", filepath.Join(dir, strings.Join(baseParts, ".")))
}
ret = append(ret, "--")
// Unless the test passes a `--config` of its own, create a temporary one from the default
// configuration with the current test database applied.
hasConfig := h.disableAutoConfig
if slices.Contains(args, "--config") {
hasConfig = true
}
if !hasConfig {
ret = append(ret, "--config", h.configFilePath)
}
ret = append(ret, args...)
return ret
}
func (h *testHelper) cmd(t *testing.T, args []string) *exec.Cmd {
path, err := os.Executable()
require.NoError(t, err)
cmd := exec.Command(path, h.execArgs(t, args)...)
cmd.Env = []string{}
for _, env := range os.Environ() {
// Ignore MM_SQLSETTINGS_DATASOURCE from the environment, since we override.
if strings.HasPrefix(env, "MM_SQLSETTINGS_DATASOURCE=") {
continue
}
cmd.Env = append(cmd.Env, env)
}
return cmd
}
// CheckCommand invokes the test binary, returning the output modified for assertion testing.
func (h *testHelper) CheckCommand(t *testing.T, args ...string) string {
output, err := h.cmd(t, args).CombinedOutput()
require.NoError(t, err, string(output))
return strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(string(output)), "PASS"))
}
// RunCommand invokes the test binary, returning only any error.
func (h *testHelper) RunCommand(t *testing.T, args ...string) error {
return h.cmd(t, args).Run()
}
// RunCommandWithOutput is a variant of RunCommand that returns the unmodified output and any error.
func (h *testHelper) RunCommandWithOutput(t *testing.T, args ...string) (string, error) {
cmd := h.cmd(t, args)
var buf bytes.Buffer
reader, writer := io.Pipe()
cmd.Stdout = writer
cmd.Stderr = writer
done := make(chan bool)
go func() {
io.Copy(&buf, reader)
close(done)
}()
err := cmd.Run()
writer.Close()
<-done
return buf.String(), err
}

View File

@@ -0,0 +1,368 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore"
"github.com/mattermost/mattermost/server/v8/config"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
"github.com/mattermost/morph"
"github.com/mattermost/morph/models"
)
var DbCmd = &cobra.Command{
Use: "db",
Short: "Commands related to the database",
}
var InitDbCmd = &cobra.Command{
Use: "init",
Short: "Initialize the database",
Long: `Initialize the database for a given DSN, executing the migrations and loading the custom defaults if any.
This command should be run using a database configuration DSN.`,
Example: ` # you can use the config flag to pass the DSN
$ mattermost db init --config postgres://localhost/mattermost
# or you can use the MM_CONFIG environment variable
$ MM_CONFIG=postgres://localhost/mattermost mattermost db init
# and you can set a custom defaults file to be loaded into the database
$ MM_CUSTOM_DEFAULTS_PATH=custom.json MM_CONFIG=postgres://localhost/mattermost mattermost db init`,
Args: cobra.NoArgs,
RunE: initDbCmdF,
}
var ResetCmd = &cobra.Command{
Use: "reset",
Short: "Reset the database to initial state",
Long: "Completely erases the database causing the loss of all data. This will reset Mattermost to its initial state.",
RunE: resetCmdF,
}
var MigrateCmd = &cobra.Command{
Use: "migrate",
Short: "Migrate the database if there are any unapplied migrations",
Long: "Run the missing migrations from the migrations table.",
RunE: migrateCmdF,
}
var DowngradeCmd = &cobra.Command{
Use: "downgrade",
Short: "Downgrade the database with the given plan or migration numbers",
Long: "Downgrade the database with the given plan or migration numbers. " +
"The plan will be read from filestore hence the path should be relative to file store root.",
RunE: downgradeCmdF,
Args: cobra.ExactArgs(1),
}
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")
MigrateCmd.Flags().Bool("auto-recover", false, "Recover the database to it's existing state after a failed migration.")
MigrateCmd.Flags().Bool("save-plan", false, "Saves the migration plan into file store so that it can be used in the future.")
MigrateCmd.Flags().Bool("dry-run", false, "Runs the migration plan without applying it.")
DowngradeCmd.Flags().Bool("auto-recover", false, "Recover the database to it's existing state after a failed migration.")
DowngradeCmd.Flags().Bool("dry-run", false, "Runs the migration plan without applying it.")
DbCmd.AddCommand(
InitDbCmd,
ResetCmd,
MigrateCmd,
DowngradeCmd,
DBVersionCmd,
)
RootCmd.AddCommand(
DbCmd,
)
}
func initDbCmdF(command *cobra.Command, _ []string) error {
logger := mlog.CreateConsoleLogger()
dsn := getConfigDSN(command, config.GetEnvironment())
if !config.IsDatabaseDSN(dsn) {
return errors.New("this command should be run using a database configuration DSN")
}
customDefaults, err := loadCustomDefaults()
if err != nil {
return errors.Wrap(err, "error loading custom configuration defaults")
}
configStore, err := config.NewStoreFromDSN(getConfigDSN(command, config.GetEnvironment()), false, customDefaults, true)
if err != nil {
return errors.Wrap(err, "failed to load configuration")
}
defer configStore.Close()
sqlStore, err := sqlstore.New(configStore.Get().SqlSettings, logger, nil)
if err != nil {
return errors.Wrap(err, "failed to initialize store")
}
defer sqlStore.Close()
CommandPrettyPrintln("Database store correctly initialised")
return nil
}
func resetCmdF(command *cobra.Command, args []string) error {
logger := mlog.CreateConsoleLogger()
ss, err := initStoreCommandContextCobra(logger, command)
if err != nil {
return errors.Wrap(err, "could not initialize store")
}
defer ss.Close()
confirmFlag, _ := command.Flags().GetBool("confirm")
if !confirmFlag {
var confirm string
CommandPrettyPrintln("Have you performed a database backup? (YES/NO): ")
fmt.Scanln(&confirm)
if confirm != "YES" {
return errors.New("ABORTED: You did not answer YES exactly, in all capitals.")
}
CommandPrettyPrintln("Are you sure you want to delete everything? All data will be permanently deleted? (YES/NO): ")
fmt.Scanln(&confirm)
if confirm != "YES" {
return errors.New("ABORTED: You did not answer YES exactly, in all capitals.")
}
}
ss.DropAllTables()
CommandPrettyPrintln("Database successfully reset")
return nil
}
func migrateCmdF(command *cobra.Command, args []string) error {
logger := mlog.CreateConsoleLogger()
defer logger.Shutdown()
cfgDSN := getConfigDSN(command, config.GetEnvironment())
recoverFlag, _ := command.Flags().GetBool("auto-recover")
savePlan, _ := command.Flags().GetBool("save-plan")
dryRun, _ := command.Flags().GetBool("dry-run")
cfgStore, err := config.NewStoreFromDSN(cfgDSN, true, nil, true)
if err != nil {
return errors.Wrap(err, "failed to load configuration")
}
config := cfgStore.Get()
migrator, err := sqlstore.NewMigrator(config.SqlSettings, logger, dryRun)
if err != nil {
return errors.Wrap(err, "failed to create migrator")
}
defer migrator.Close()
plan, err := migrator.GeneratePlan(recoverFlag)
if err != nil {
return errors.Wrap(err, "failed to generate migration plan")
}
if len(plan.Migrations) == 0 {
CommandPrettyPrintln("No migrations to apply.")
return nil
}
if savePlan || recoverFlag {
backend, err2 := filestore.NewFileBackend(ConfigToFileBackendSettings(&config.FileSettings, false, true))
if err2 != nil {
return fmt.Errorf("failed to initialize filebackend: %w", err2)
}
b, mErr := json.MarshalIndent(plan, "", " ")
if mErr != nil {
return fmt.Errorf("failed to marshal plan: %w", mErr)
}
fileName, err2 := migrator.GetFileName(plan)
if err2 != nil {
return fmt.Errorf("failed to generate plan file: %w", err2)
}
_, err = backend.WriteFile(bytes.NewReader(b), fileName+".json")
if err != nil {
return fmt.Errorf("failed to write migration plan: %w", err)
}
CommandPrettyPrintln(
fmt.Sprintf("%s\nThe migration plan has been saved. File: %q.\nNote that "+
" migration plan is saved into file store, so the filepath will be relative to root of file store\n%s",
strings.Repeat("*", 80), fileName+".json", strings.Repeat("*", 80)))
}
if !dryRun {
if preMigrationError := migrator.PreMigrate(); preMigrationError != nil {
return errors.Wrap(preMigrationError, "failed to run pre-migrations")
}
}
err = migrator.MigrateWithPlan(plan, dryRun)
if err != nil {
return errors.Wrap(err, "failed to migrate with the plan")
}
CommandPrettyPrintln("Database successfully migrated")
return nil
}
func downgradeCmdF(command *cobra.Command, args []string) error {
logger := mlog.CreateConsoleLogger()
defer logger.Shutdown()
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()
dryRun, _ := command.Flags().GetBool("dry-run")
recoverFlag, _ := command.Flags().GetBool("auto-recover")
backend, err2 := filestore.NewFileBackend(ConfigToFileBackendSettings(&config.FileSettings, false, true))
if err2 != nil {
return fmt.Errorf("failed to initialize filebackend: %w", err2)
}
migrator, err := sqlstore.NewMigrator(config.SqlSettings, logger, dryRun)
if err != nil {
return errors.Wrap(err, "failed to create migrator")
}
defer migrator.Close()
// check if the input is version numbers or a file
// if the input is given as a file, we assume it's a migration plan
versions := strings.Split(args[0], ",")
if _, sErr := strconv.Atoi(versions[0]); sErr == nil {
CommandPrettyPrintln("Database will be downgraded with the following versions: ", versions)
err = migrator.DowngradeMigrations(dryRun, versions...)
if err != nil {
return errors.Wrap(err, "failed to downgrade migrations")
}
CommandPrettyPrintln("Database successfully downgraded")
return nil
}
b, err := backend.ReadFile(args[0])
if err != nil {
return fmt.Errorf("failed to read plan: %w", err)
}
var plan models.Plan
err = json.Unmarshal(b, &plan)
if err != nil {
return fmt.Errorf("failed to unmarshal plan: %w", err)
}
morph.SwapPlanDirection(&plan)
plan.Auto = recoverFlag
err = migrator.MigrateWithPlan(&plan, dryRun)
if err != nil {
return errors.Wrap(err, "failed to migrate with the plan")
}
CommandPrettyPrintln("Database successfully downgraded")
return nil
}
func dbVersionCmdF(command *cobra.Command, args []string) error {
logger := mlog.CreateConsoleLogger()
defer logger.Shutdown()
ss, err := initStoreCommandContextCobra(logger, command)
if err != nil {
return errors.Wrap(err, "could not initialize store")
}
defer ss.Close()
allFlag, _ := command.Flags().GetBool("all")
if allFlag {
applied, err2 := ss.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 := ss.GetDBSchemaVersion()
if err != nil {
return errors.Wrap(err, "failed to get schema version")
}
CommandPrettyPrintln("Current database schema version is: " + strconv.Itoa(v))
return nil
}
func ConfigToFileBackendSettings(s *model.FileSettings, enableComplianceFeature bool, skipVerify bool) filestore.FileBackendSettings {
if *s.DriverName == model.ImageDriverLocal {
return filestore.FileBackendSettings{
DriverName: *s.DriverName,
Directory: *s.Directory,
}
}
if *s.DriverName == model.ImageDriverAzure {
return filestore.FileBackendSettings{
DriverName: *s.DriverName,
AzureStorageAccount: *s.AzureStorageAccount,
AzureAuthMode: *s.AzureAuthMode,
AzureAccessKey: *s.AzureAccessKey,
AzureContainer: *s.AzureContainer,
AzurePathPrefix: *s.AzurePathPrefix,
AzureCloud: *s.AzureCloud,
AzureEndpoint: *s.AzureEndpoint,
AzureSSL: s.AzureSSL == nil || *s.AzureSSL,
AzureRequestTimeoutMilliseconds: *s.AzureRequestTimeoutMilliseconds,
SkipVerify: skipVerify,
}
}
return filestore.FileBackendSettings{
DriverName: *s.DriverName,
AmazonS3AccessKeyId: *s.AmazonS3AccessKeyId,
AmazonS3SecretAccessKey: *s.AmazonS3SecretAccessKey,
AmazonS3Bucket: *s.AmazonS3Bucket,
AmazonS3PathPrefix: *s.AmazonS3PathPrefix,
AmazonS3Region: *s.AmazonS3Region,
AmazonS3Endpoint: *s.AmazonS3Endpoint,
AmazonS3SSL: s.AmazonS3SSL == nil || *s.AmazonS3SSL,
AmazonS3SignV2: s.AmazonS3SignV2 != nil && *s.AmazonS3SignV2,
AmazonS3SSE: s.AmazonS3SSE != nil && *s.AmazonS3SSE && enableComplianceFeature,
AmazonS3Trace: s.AmazonS3Trace != nil && *s.AmazonS3Trace,
AmazonS3RequestTimeoutMilliseconds: *s.AmazonS3RequestTimeoutMilliseconds,
SkipVerify: skipVerify,
}
}

View File

@@ -0,0 +1,181 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
dbsql "database/sql"
stdErrors "errors"
"net/url"
"time"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/config"
)
const (
dbPingDefaultTimeout = 5 * time.Minute
dbPingDefaultRetryInterval = 2 * time.Second
// dbPingAttemptTimeout caps a single PingContext call so a hung connection
// doesn't block the whole timeout budget on one attempt.
dbPingAttemptTimeout = 10 * time.Second
)
var DBPingCmd = &cobra.Command{
Use: "ping",
Short: "Wait for the database to become reachable",
Long: `Pings the configured Mattermost database, retrying until --timeout expires.
Exits 0 once the database accepts a ping. Exits non-zero on timeout or fatal error.
Intended for use as a readiness probe (e.g. a Kubernetes init container).
Resolves the DSN exactly like 'mattermost db migrate' / 'mattermost db init':
the --config flag, then MM_CONFIG, then config.json (which is then loaded as
a config store and SqlSettings.DataSource is used).`,
Example: ` # Database DSN passed via --config (preferred for readiness probes)
$ mattermost db ping --config postgres://mmuser:mostest_password@localhost/mattermost --timeout 2m
# Or via MM_CONFIG
$ MM_CONFIG=postgres://localhost/mattermost mattermost db ping`,
Args: cobra.NoArgs,
RunE: dbPingCmdF,
}
func init() {
DBPingCmd.Flags().Duration("timeout", dbPingDefaultTimeout,
"Maximum total time to wait for the DB to become reachable.")
DBPingCmd.Flags().Duration("retry-interval", dbPingDefaultRetryInterval,
"Sleep between ping attempts.")
DbCmd.AddCommand(DBPingCmd)
}
func dbPingCmdF(command *cobra.Command, _ []string) error {
logger := mlog.CreateConsoleLogger()
defer func() {
_ = logger.Shutdown()
}()
timeout, _ := command.Flags().GetDuration("timeout")
retryInterval, _ := command.Flags().GetDuration("retry-interval")
if timeout <= 0 {
return errors.New("--timeout must be > 0")
}
if retryInterval <= 0 {
return errors.New("--retry-interval must be > 0")
}
dsn, err := resolvePingDataSource(command)
if err != nil {
return err
}
sanitized, err := sanitizePingDataSource(dsn)
if err != nil {
return err
}
db, err := dbsql.Open(model.DatabaseDriverPostgres, dsn)
if err != nil {
return errors.Wrap(err, "failed to open SQL connection")
}
defer db.Close()
// Minimal pool — this is a one-shot readiness probe.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
ctx, cancel := context.WithTimeout(command.Context(), timeout)
defer cancel()
return pingWithRetry(ctx, db, retryInterval, logger.With(
mlog.String("data_source", sanitized),
))
}
func sanitizePingDataSource(dsn string) (string, error) {
sanitized, err := model.SanitizeDataSource(model.DatabaseDriverPostgres, dsn)
if err != nil {
return "", safeDataSourceSanitizationError(err)
}
return sanitized, nil
}
func safeDataSourceSanitizationError(err error) error {
var urlErr *url.Error
if stdErrors.As(err, &urlErr) {
if urlErr.Err != nil {
return errors.Errorf("invalid database DSN: %v", urlErr.Err)
}
return errors.New("invalid database DSN")
}
return errors.New("invalid database DSN")
}
// resolvePingDataSource returns a postgres DSN to ping.
//
// If the configured DSN is a postgres:// / postgresql:// URL it is returned as-is
// (fast path: no config store load required). Otherwise it is treated as a file
// path: a config.Store is loaded read-only (createFileIfNotExist=false so the
// command never has a side effect of creating a config file) and
// SqlSettings.DataSource is returned.
func resolvePingDataSource(command *cobra.Command) (string, error) {
cfgDSN := getConfigDSN(command, config.GetEnvironment())
if config.IsDatabaseDSN(cfgDSN) {
return cfgDSN, nil
}
cfgStore, err := config.NewStoreFromDSN(cfgDSN, true /*readOnly*/, nil /*customDefaults*/, false /*createFileIfNotExist*/)
if err != nil {
return "", errors.Wrapf(err, "failed to load configuration from %q", cfgDSN)
}
defer cfgStore.Close()
sqlSettings := cfgStore.Get().SqlSettings
if sqlSettings.DataSource == nil || *sqlSettings.DataSource == "" {
return "", errors.New("no database DSN configured: set --config or MM_CONFIG to a postgres:// URL, or ensure SqlSettings.DataSource is set in your configuration")
}
if !config.IsDatabaseDSN(*sqlSettings.DataSource) {
// Defensive: the loaded config has a non-postgres DataSource. Mattermost is postgres-only.
return "", errors.New("configured SqlSettings.DataSource is not a postgres DSN")
}
return *sqlSettings.DataSource, nil
}
// pingWithRetry pings db every retryInterval until it succeeds or ctx is done.
// Each individual PingContext call is capped at dbPingAttemptTimeout so a hung
// network connection cannot consume the entire timeout budget on a single try.
func pingWithRetry(ctx context.Context, db *dbsql.DB, retryInterval time.Duration, logger mlog.LoggerIFace) error {
attempt := 0
for {
attempt++
attemptCtx, cancel := context.WithTimeout(ctx, dbPingAttemptTimeout)
err := db.PingContext(attemptCtx)
cancel()
if err == nil {
logger.Info("Database is reachable", mlog.Int("attempt", attempt))
return nil
}
// Surface progress on every attempt so operators can see the probe is alive.
// Intentionally omit the raw error: lib/pq error strings can echo DSN fragments.
logger.Info("Waiting for database",
mlog.Int("attempt", attempt),
mlog.Duration("retry_interval", retryInterval),
mlog.String("status", "ping_failed"),
)
// Wait retryInterval, but bail early if ctx is done.
select {
case <-ctx.Done():
return errors.Wrapf(ctx.Err(), "timed out waiting for database after %d attempts", attempt)
case <-time.After(retryInterval):
}
}
}

View File

@@ -0,0 +1,322 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
dbsql "database/sql"
"errors"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
// dsnFromHelper builds a postgres:// DSN from the test main helper's SqlSettings.
// The helper itself stores the live test postgres DSN.
func dsnFromHelper(t *testing.T) string {
t.Helper()
require.NotNil(t, mainHelper, "mainHelper must be initialized; do not run with -short")
settings := mainHelper.GetSQLSettings()
require.NotNil(t, settings.DataSource)
require.NotEmpty(t, *settings.DataSource)
return *settings.DataSource
}
// --- subprocess (CLI integration) tests ---
func TestDBPingHappyPath(t *testing.T) {
if testing.Short() {
t.Skip("requires live test database")
}
th := SetupWithStoreMock(t)
output := th.CheckCommand(t, "db", "ping", "--timeout", "30s")
require.Contains(t, output, "Database is reachable",
"expected success log line in command output, got: %s", output)
}
func TestDBPingDirectDSN(t *testing.T) {
if testing.Short() {
t.Skip("requires live test database")
}
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
dsn := dsnFromHelper(t)
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping", "--timeout", "30s")
require.NoError(t, err, "command should succeed when DSN is direct postgres URL; output: %s", output)
require.Contains(t, output, "Database is reachable")
}
func TestDBPingTimeoutOnUnreachableDB(t *testing.T) {
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
// Loopback to a port nothing listens on; connect_timeout=1 keeps each
// attempt short. We allow 2s total with 500ms between attempts so we get
// multiple "Waiting for database" lines.
dsn := "postgres://nobody@127.0.0.1:1/mattermost?sslmode=disable&connect_timeout=1"
start := time.Now()
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "2s", "--retry-interval", "500ms")
elapsed := time.Since(start)
require.Error(t, err, "command should fail on unreachable DB; output: %s", output)
require.Contains(t, output, "timed out waiting for database",
"expected timeout message in output, got: %s", output)
require.LessOrEqual(t, elapsed, 30*time.Second,
"command should not exceed a generous upper bound; took %s", elapsed)
}
func TestDBPingInvalidDSN(t *testing.T) {
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
// Passes IsDatabaseDSN (postgres:// prefix) so it takes the direct path.
dsn := "postgres://leakyuser:supersecret@[invalid"
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "2s", "--retry-interval", "500ms")
require.Error(t, err, "command should fail on malformed DSN; output: %s", output)
require.Contains(t, output, "invalid database DSN",
"expected sanitized DSN parse error; got: %s", output)
require.Contains(t, output, "missing ']' in host",
"expected malformed DSN reason; got: %s", output)
require.NotContains(t, output, "supersecret",
"malformed DSN errors must not leak credentials; got: %s", output)
require.NotContains(t, output, "leakyuser",
"malformed DSN errors must not leak credentials; got: %s", output)
}
func TestDBPingMissingConfigFile(t *testing.T) {
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
// Point --config at a path that does not exist; createFileIfNotExist=false
// inside resolvePingDataSource means NewStoreFromDSN will return an error.
missing := th.TemporaryDirectory() + "/does-not-exist.json"
output, err := th.RunCommandWithOutput(t, "--config", missing, "db", "ping",
"--timeout", "2s", "--retry-interval", "500ms")
require.Error(t, err, "command should fail when --config file does not exist; output: %s", output)
require.Contains(t, output, "failed to load configuration",
"expected config-load error message; got: %s", output)
}
func TestDBPingFlagValidation(t *testing.T) {
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
dsn := "postgres://localhost:1/mattermost?sslmode=disable&connect_timeout=1"
t.Run("zero timeout", func(t *testing.T) {
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "0s")
require.Error(t, err)
require.Contains(t, output, "--timeout must be > 0",
"expected timeout validation error; got: %s", output)
})
t.Run("zero retry interval", func(t *testing.T) {
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "1s", "--retry-interval", "0s")
require.Error(t, err)
require.Contains(t, output, "--retry-interval must be > 0",
"expected retry-interval validation error; got: %s", output)
})
t.Run("negative timeout", func(t *testing.T) {
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "-1s")
require.Error(t, err)
require.Contains(t, output, "--timeout must be > 0",
"expected timeout validation error for negative value; got: %s", output)
})
t.Run("garbage timeout value", func(t *testing.T) {
// cobra refuses to parse "garbage" as a duration; subcommand never runs.
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "garbage")
require.Error(t, err)
// Don't pin to exact text — cobra owns the error string here. Just
// confirm the subcommand's success log is absent.
require.NotContains(t, output, "Database is reachable",
"command should not have run successfully; got: %s", output)
})
}
func TestDBPingRetryIntervalHonored(t *testing.T) {
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
dsn := "postgres://nobody@127.0.0.1:1/mattermost?sslmode=disable&connect_timeout=1"
start := time.Now()
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "2s", "--retry-interval", "500ms")
elapsed := time.Since(start)
require.Error(t, err)
// Loose lower bound: at 500ms intervals we expect at least 2 retries
// (~1s wall clock minimum) before the 2s timeout strikes.
require.GreaterOrEqual(t, elapsed, 1*time.Second,
"expected retries to span at least 1s; got %s", elapsed)
// Loose upper bound: don't exceed several multiples of the configured
// timeout — accommodates CI variance.
require.LessOrEqual(t, elapsed, 30*time.Second,
"expected command to bail close to --timeout; took %s", elapsed)
waitingCount := strings.Count(output, "Waiting for database")
require.GreaterOrEqual(t, waitingCount, 2,
"expected at least 2 'Waiting for database' lines; got %d in output:\n%s",
waitingCount, output)
}
// TestDBPingShortRetryIntervalProducesMoreAttempts verifies that the
// --retry-interval flag actually controls the cadence (not just the timeout).
func TestDBPingShortRetryIntervalProducesMoreAttempts(t *testing.T) {
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
dsn := "postgres://nobody@127.0.0.1:1/mattermost?sslmode=disable&connect_timeout=1"
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "3s", "--retry-interval", "200ms")
require.Error(t, err)
waitingCount := strings.Count(output, "Waiting for database")
// At 200ms intervals over 3s we expect well more than 3 attempts even
// accounting for per-attempt connection overhead.
require.GreaterOrEqual(t, waitingCount, 3,
"expected several retries with short interval; got %d in output:\n%s",
waitingCount, output)
}
// TestDBPingCmdRegistered confirms the new subcommand is wired into the
// existing DbCmd group, so users actually get `mattermost db ping`.
func TestDBPingCmdRegistered(t *testing.T) {
require.Contains(t, DbCmd.Commands(), DBPingCmd,
"DBPingCmd should be registered as a subcommand of DbCmd")
require.Equal(t, "ping", DBPingCmd.Use)
// Flags exist with sensible defaults.
timeoutFlag := DBPingCmd.Flags().Lookup("timeout")
require.NotNil(t, timeoutFlag)
require.Equal(t, dbPingDefaultTimeout.String(), timeoutFlag.DefValue)
intervalFlag := DBPingCmd.Flags().Lookup("retry-interval")
require.NotNil(t, intervalFlag)
require.Equal(t, dbPingDefaultRetryInterval.String(), intervalFlag.DefValue)
}
// --- in-process tests of pingWithRetry / resolvePingDataSource ---
func TestPingWithRetry_SuccessOnFirstAttempt(t *testing.T) {
if testing.Short() {
t.Skip("requires live test database")
}
dsn := dsnFromHelper(t)
db, err := dbsql.Open(model.DatabaseDriverPostgres, dsn)
require.NoError(t, err)
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
logger := mlog.CreateConsoleTestLogger(t)
err = pingWithRetry(ctx, db, 100*time.Millisecond, logger)
require.NoError(t, err)
}
func TestPingWithRetry_TimeoutAgainstUnreachable(t *testing.T) {
dsn := "postgres://nobody@127.0.0.1:1/mattermost?sslmode=disable&connect_timeout=1"
db, err := dbsql.Open(model.DatabaseDriverPostgres, dsn)
require.NoError(t, err)
defer db.Close()
start := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
defer cancel()
logger := mlog.CreateConsoleTestLogger(t)
err = pingWithRetry(ctx, db, 200*time.Millisecond, logger)
elapsed := time.Since(start)
require.Error(t, err)
require.True(t,
errors.Is(err, context.DeadlineExceeded) ||
strings.Contains(err.Error(), "timed out waiting for database"),
"expected deadline-exceeded or timeout error; got %v", err)
// Must have honored the timeout, not just returned immediately.
require.LessOrEqual(t, elapsed, 30*time.Second,
"expected reasonable upper bound; took %s", elapsed)
}
func TestPingWithRetry_ContextCancelImmediately(t *testing.T) {
dsn := "postgres://nobody@127.0.0.1:1/mattermost?sslmode=disable&connect_timeout=1"
db, err := dbsql.Open(model.DatabaseDriverPostgres, dsn)
require.NoError(t, err)
defer db.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel before we even start
logger := mlog.CreateConsoleTestLogger(t)
err = pingWithRetry(ctx, db, 1*time.Second, logger)
require.Error(t, err, "cancelled context should produce an error")
}
// In-process tests of resolvePingDataSource. We drive DSN selection via the
// MM_CONFIG environment variable rather than the --config persistent flag
// because the persistent flag is only merged into a subcommand's local
// flagset during cobra's Execute() pipeline; calling resolvePingDataSource
// directly outside Execute means the flag would not be visible.
// MM_CONFIG is consumed by getConfigDSN as the second-precedence source.
func TestResolvePingDataSource_DirectDSN(t *testing.T) {
wanted := "postgres://user:pw@example.invalid:5432/mm?sslmode=disable"
t.Setenv("MM_CONFIG", wanted)
got, err := resolvePingDataSource(DBPingCmd)
require.NoError(t, err)
require.Equal(t, wanted, got)
}
func TestResolvePingDataSource_DirectDSN_PostgresqlScheme(t *testing.T) {
wanted := "postgresql://user:pw@example.invalid:5432/mm?sslmode=disable"
t.Setenv("MM_CONFIG", wanted)
got, err := resolvePingDataSource(DBPingCmd)
require.NoError(t, err)
require.Equal(t, wanted, got)
}
func TestResolvePingDataSource_MissingFile(t *testing.T) {
missing := t.TempDir() + "/no-such-config.json"
t.Setenv("MM_CONFIG", missing)
_, err := resolvePingDataSource(DBPingCmd)
require.Error(t, err)
require.Contains(t, err.Error(), "failed to load configuration")
}
// TestResolvePingDataSource_PointsAtDirectory verifies that pointing --config
// at a directory (not a JSON file) surfaces a clear, wrapped error. Catches
// regressions where we silently fall through instead of returning the load error.
func TestResolvePingDataSource_PointsAtDirectory(t *testing.T) {
dir := t.TempDir()
t.Setenv("MM_CONFIG", dir)
_, err := resolvePingDataSource(DBPingCmd)
require.Error(t, err)
require.Contains(t, err.Error(), "failed to load configuration",
"expected wrapped error; got %v", err)
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"flag"
"testing"
"github.com/stretchr/testify/require"
)
func TestExecCommand(t *testing.T) {
if filter := flag.Lookup("test.run").Value.String(); filter != "ExecCommand" {
t.Skip("use -run ExecCommand to execute a command via the test executable")
}
RootCmd.SetArgs(flag.Args())
require.NoError(t, RootCmd.Execute())
}

View File

@@ -0,0 +1,187 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"os"
"path/filepath"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
var ExportCmd = &cobra.Command{
Use: "export",
Short: "Export data from Mattermost",
Long: "Export data from Mattermost in a format suitable for import into a third-party application or another Mattermost instance",
}
var ScheduleExportCmd = &cobra.Command{
Use: "schedule",
Short: "Schedule an export data job in Mattermost",
Long: "Schedule an export data job in Mattermost (this will run asynchronously via a background worker)",
Example: "export schedule --format=actiance --exportFrom=12345 --timeoutSeconds=12345",
RunE: scheduleExportCmdF,
}
var BulkExportCmd = &cobra.Command{
Use: "bulk [file]",
Short: "Export bulk data.",
Long: "Export data to a file compatible with the Mattermost Bulk Import format.",
Example: "export bulk bulk_data.json",
RunE: bulkExportCmdF,
Args: cobra.ExactArgs(1),
}
func init() {
ScheduleExportCmd.Flags().String("format", "actiance", "The format to export data")
ScheduleExportCmd.Flags().Int64("exportFrom", -1, "The timestamp of the earliest post to export, expressed in seconds since the unix epoch.")
ScheduleExportCmd.Flags().Int("timeoutSeconds", -1, "The maximum number of seconds to wait for the job to complete before timing out.")
BulkExportCmd.Flags().Bool("all-teams", true, "Export all teams from the server.")
BulkExportCmd.Flags().Bool("with-archived-channels", false, "Also exports archived channels.")
BulkExportCmd.Flags().Bool("with-profile-pictures", false, "Also exports profile pictures.")
BulkExportCmd.Flags().Bool("attachments", false, "Also export file attachments.")
BulkExportCmd.Flags().Bool("archive", false, "Outputs a single archive file.")
ExportCmd.AddCommand(ScheduleExportCmd)
ExportCmd.AddCommand(BulkExportCmd)
RootCmd.AddCommand(ExportCmd)
}
func scheduleExportCmdF(command *cobra.Command, args []string) error {
a, err := InitDBCommandContextCobra(command, app.SkipPostInitialization())
if err != nil {
return err
}
defer a.Srv().Shutdown()
if !*a.Config().MessageExportSettings.EnableExport {
return errors.New("ERROR: The message export feature is not enabled")
}
var rctx request.CTX = request.EmptyContext(a.Log())
// for now, format is hard-coded to actiance. In time, we'll have to support other formats and inject them into job data
format, err := command.Flags().GetString("format")
if err != nil {
return errors.New("format flag error")
}
if format != "actiance" {
return errors.New("unsupported export format")
}
startTime, err := command.Flags().GetInt64("exportFrom")
if err != nil {
return errors.New("exportFrom flag error")
}
if startTime < 0 {
return errors.New("exportFrom must be a positive integer")
}
timeoutSeconds, err := command.Flags().GetInt("timeoutSeconds")
if err != nil {
return errors.New("timeoutSeconds error")
}
if timeoutSeconds < 0 {
return errors.New("timeoutSeconds must be a positive integer")
}
if messageExportI := a.MessageExport(); messageExportI != nil {
ctx := context.Background()
if timeoutSeconds > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Second*time.Duration(timeoutSeconds))
defer cancel()
}
rctx = rctx.WithContext(ctx)
job, err := messageExportI.StartSynchronizeJob(rctx, startTime)
if err != nil || job.Status == model.JobStatusError || job.Status == model.JobStatusCanceled {
CommandPrintErrorln("ERROR: Message export job failed. Please check the server logs")
} else {
CommandPrettyPrintln("SUCCESS: Message export job complete")
auditRec := a.MakeAuditRecord(rctx, model.AuditEventScheduleExport, model.AuditStatusSuccess)
auditRec.AddMeta("format", format)
auditRec.AddMeta("start", startTime)
a.LogAuditRec(rctx, auditRec, nil)
}
}
return nil
}
func bulkExportCmdF(command *cobra.Command, args []string) error {
a, err := InitDBCommandContextCobra(command, app.SkipPostInitialization())
if err != nil {
return err
}
defer a.Srv().Shutdown()
rctx := request.EmptyContext(a.Log())
allTeams, err := command.Flags().GetBool("all-teams")
if err != nil {
return errors.Wrap(err, "all-teams flag error")
}
if !allTeams {
return errors.New("Nothing to export. Please specify the --all-teams flag to export all teams.")
}
attachments, err := command.Flags().GetBool("attachments")
if err != nil {
return errors.Wrap(err, "attachments flag error")
}
archive, err := command.Flags().GetBool("archive")
if err != nil {
return errors.Wrap(err, "archive flag error")
}
withArchivedChannels, err := command.Flags().GetBool("with-archived-channels")
if err != nil {
return errors.Wrap(err, "with-archived-channels flag error")
}
includeProfilePictures, err := command.Flags().GetBool("with-profile-pictures")
if err != nil {
return errors.Wrap(err, "with-profile-pictures flag error")
}
fileWriter, err := os.Create(args[0])
if err != nil {
return err
}
defer fileWriter.Close()
outPath, err := filepath.Abs(args[0])
if err != nil {
return err
}
var opts model.BulkExportOpts
opts.IncludeAttachments = attachments
opts.CreateArchive = archive
opts.IncludeArchivedChannels = withArchivedChannels
opts.IncludeProfilePictures = includeProfilePictures
if err := a.BulkExport(rctx, fileWriter, filepath.Dir(outPath), nil /* nil job since it's spawned from CLI */, opts); err != nil {
CommandPrintErrorln(err.Error())
return err
}
auditRec := a.MakeAuditRecord(rctx, model.AuditEventBulkExport, model.AuditStatusSuccess)
auditRec.AddMeta("all_teams", allTeams)
auditRec.AddMeta("file", args[0])
a.LogAuditRec(rctx, auditRec, nil)
return nil
}

View File

@@ -0,0 +1,114 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"errors"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
)
var ImportCmd = &cobra.Command{
Use: "import",
Short: "Import data.",
}
var BulkImportCmd = &cobra.Command{
Use: "bulk [file]",
Short: "Import bulk data.",
Long: "Import data from a Mattermost Bulk Import File.",
Example: " import bulk bulk_data.json",
RunE: bulkImportCmdF,
}
func init() {
BulkImportCmd.Flags().Bool("apply", false, "Save the import data to the database. Use with caution - this cannot be reverted.")
BulkImportCmd.Flags().Bool("validate", false, "Validate the import data without making any changes to the system.")
BulkImportCmd.Flags().Int("workers", 2, "How many workers to run whilst doing the import.")
BulkImportCmd.Flags().String("import-path", "", "A path to the data directory to import files from.")
ImportCmd.AddCommand(
BulkImportCmd,
)
RootCmd.AddCommand(ImportCmd)
}
func bulkImportCmdF(command *cobra.Command, args []string) error {
a, err := InitDBCommandContextCobra(command)
if err != nil {
return err
}
defer a.Srv().Shutdown()
rctx := request.EmptyContext(a.Log())
apply, err := command.Flags().GetBool("apply")
if err != nil {
return errors.New("Apply flag error")
}
validate, err := command.Flags().GetBool("validate")
if err != nil {
return errors.New("Validate flag error")
}
workers, err := command.Flags().GetInt("workers")
if err != nil {
return errors.New("Workers flag error")
}
importPath, err := command.Flags().GetString("import-path")
if err != nil {
return errors.New("import-path flag error")
}
if len(args) != 1 {
return errors.New("Incorrect number of arguments.")
}
fileReader, err := os.Open(args[0])
if err != nil {
return err
}
defer fileReader.Close()
if apply && validate {
CommandPrettyPrintln("Use only one of --apply or --validate.")
return nil
}
if apply && !validate {
CommandPrettyPrintln("Running Bulk Import. This may take a long time.")
} else {
CommandPrettyPrintln("Running Bulk Import Data Validation.")
CommandPrettyPrintln("** This checks the validity of the entities in the data file, but does not persist any changes **")
CommandPrettyPrintln("Use the --apply flag to perform the actual data import.")
}
CommandPrettyPrintln("")
if lineNumber, err := a.BulkImportWithPath(rctx, fileReader, nil, true, !apply, workers, importPath); err != nil {
CommandPrintErrorln(err.Error())
if lineNumber != 0 {
CommandPrintErrorln(fmt.Sprintf("Error occurred on data file line %v", lineNumber))
}
return err
}
if apply {
CommandPrettyPrintln("Finished Bulk Import.")
auditRec := a.MakeAuditRecord(rctx, model.AuditEventBulkImport, model.AuditStatusSuccess)
auditRec.AddMeta("file", args[0])
a.LogAuditRec(rctx, auditRec, nil)
} else {
CommandPrettyPrintln("Validation complete. You can now perform the import by rerunning this command with the --apply flag.")
}
return nil
}

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/config"
)
func initDBCommandContextCobra(command *cobra.Command, readOnlyConfigStore bool, options ...app.Option) (*app.App, error) {
a, err := initDBCommandContext(getConfigDSN(command, config.GetEnvironment()), readOnlyConfigStore, options...)
if err != nil {
// Returning an error just prints the usage message, so actually panic
panic(err)
}
a.InitPlugins(request.EmptyContext(a.Log()), *a.Config().PluginSettings.Directory, *a.Config().PluginSettings.ClientDirectory)
a.DoAppMigrations()
return a, nil
}
func InitDBCommandContextCobra(command *cobra.Command, options ...app.Option) (*app.App, error) {
return initDBCommandContextCobra(command, true, options...)
}
func initDBCommandContext(configDSN string, readOnlyConfigStore bool, options ...app.Option) (*app.App, error) {
if err := utils.TranslationsPreInit(); err != nil {
return nil, err
}
model.AppErrorInit(i18n.T)
// The option order is important as app.Config option reads app.StartMetrics option.
options = append(options, app.Config(configDSN, readOnlyConfigStore, nil))
s, err := app.NewServer(options...)
if err != nil {
return nil, err
}
a := app.New(app.ServerConnector(s.Channels()))
if model.BuildEnterpriseReady == "true" {
a.Srv().LoadLicense()
}
return a, nil
}
func initStoreCommandContextCobra(logger mlog.LoggerIFace, command *cobra.Command) (store.Store, error) {
cfgDSN := getConfigDSN(command, config.GetEnvironment())
cfgStore, err := config.NewStoreFromDSN(cfgDSN, true, nil, true)
if err != nil {
return nil, errors.Wrap(err, "failed to load configuration")
}
config := cfgStore.Get()
return sqlstore.New(config.SqlSettings, logger, nil)
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"os"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/config"
)
var JobserverCmd = &cobra.Command{
Use: "jobserver",
Short: "Start the Mattermost job server",
RunE: jobserverCmdF,
}
func init() {
JobserverCmd.Flags().Bool("nojobs", false, "Do not run jobs on this jobserver.")
JobserverCmd.Flags().Bool("noschedule", false, "Do not schedule jobs from this jobserver.")
RootCmd.AddCommand(JobserverCmd)
}
func jobserverCmdF(command *cobra.Command, args []string) error {
// Options
noJobs, _ := command.Flags().GetBool("nojobs")
noSchedule, _ := command.Flags().GetBool("noschedule")
// Initialize
a, err := initDBCommandContext(getConfigDSN(command, config.GetEnvironment()), false, app.StartMetrics)
if err != nil {
return err
}
defer a.Srv().Shutdown()
a.Srv().LoadLicense()
rctx := request.EmptyContext(a.Log())
// Run jobs
rctx.Logger().Info("Starting Mattermost job server")
defer rctx.Logger().Info("Stopped Mattermost job server")
if !noJobs {
a.Srv().Jobs.StartWorkers()
defer a.Srv().Jobs.StopWorkers()
}
if !noSchedule {
a.Srv().Jobs.StartSchedulers()
defer a.Srv().Jobs.StopSchedulers()
}
if !noJobs || !noSchedule {
auditRec := a.MakeAuditRecord(rctx, model.AuditEventJobServer, model.AuditStatusSuccess)
a.LogAuditRec(rctx, auditRec, nil)
}
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
<-signalChan
// Cleanup anything that isn't handled by a defer statement
rctx.Logger().Info("Stopping Mattermost job server")
return nil
}

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"flag"
"os"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
type TestConfig struct {
TestServiceSettings TestServiceSettings
TestTeamSettings TestTeamSettings
TestClientRequirements TestClientRequirements
TestMessageExportSettings TestMessageExportSettings
}
type TestMessageExportSettings struct {
Enableexport bool
Exportformat string
TestGlobalRelaySettings TestGlobalRelaySettings
}
type TestGlobalRelaySettings struct {
Customertype string
Smtpusername string
Smtppassword string
}
type TestServiceSettings struct {
Siteurl string
Websocketurl string
Licensedfieldlocation string
}
type TestTeamSettings struct {
Sitename string
Maxuserperteam int
}
type TestClientRequirements struct {
Androidlatestversion string
Androidminversion string
Desktoplatestversion string
}
type TestNewConfig struct {
TestNewServiceSettings TestNewServiceSettings
TestNewTeamSettings TestNewTeamSettings
}
type TestNewServiceSettings struct {
SiteUrl *string
UseLetsEncrypt *bool
TLSStrictTransportMaxAge *int64
AllowedThemes []string
}
type TestNewTeamSettings struct {
SiteName *string
MaxUserPerTeam *int
}
type TestPluginSettings struct {
Enable *bool
Directory *string `restricted:"true"`
Plugins map[string]map[string]any
PluginStates map[string]*model.PluginState
SignaturePublicKeyFiles []string
}
func TestMain(m *testing.M) {
// Command tests are run by re-invoking the test binary in question, so avoid creating
// another container when we detect same.
flag.Parse()
if filter := flag.Lookup("test.run").Value.String(); filter == "ExecCommand" {
status := m.Run()
os.Exit(status)
return
}
var options = testlib.HelperOptions{
EnableStore: true,
EnableResources: true,
}
mainHelper = testlib.NewMainHelperWithOptions(&options)
defer mainHelper.Close()
api4.SetMainHelper(mainHelper)
mainHelper.Main(m)
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"fmt"
"os"
)
func CommandPrintln(a ...any) (int, error) {
return fmt.Println(a...)
}
func CommandPrintErrorln(a ...any) (int, error) {
return fmt.Fprintln(os.Stderr, a...)
}
func CommandPrettyPrintln(a ...any) (int, error) {
return fmt.Fprintln(os.Stdout, a...)
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"os"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/spf13/cobra"
)
type Command = cobra.Command
func Run(args []string) error {
RootCmd.SetArgs(args)
return RootCmd.Execute()
}
var RootCmd = &cobra.Command{
Use: "mattermost",
Short: "Open source, self-hosted Slack-alternative",
Long: `Mattermost offers workplace messaging across web, PC and phones with archiving, search and integration with your existing systems. Documentation available at https://docs.mattermost.com`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
checkForRootUser()
},
}
func init() {
RootCmd.PersistentFlags().StringP("config", "c", "", "Configuration file to use.")
}
// checkForRootUser logs a warning if the process is running as root
func checkForRootUser() {
if os.Geteuid() == 0 {
mlog.Warn("Running Mattermost as root is not recommended. Please use a non-root user.")
}
}

View File

@@ -0,0 +1,151 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"bytes"
"net"
"os"
"os/signal"
"runtime/debug"
"runtime/pprof"
"syscall"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/channels/web"
"github.com/mattermost/mattermost/server/v8/channels/wsapi"
"github.com/mattermost/mattermost/server/v8/config"
)
var serverCmd = &cobra.Command{
Use: "server",
Short: "Run the Mattermost server",
RunE: serverCmdF,
SilenceUsage: true,
}
func init() {
RootCmd.AddCommand(serverCmd)
RootCmd.RunE = serverCmdF
}
func serverCmdF(command *cobra.Command, args []string) error {
interruptChan := make(chan os.Signal, 1)
if err := utils.TranslationsPreInit(); err != nil {
return errors.Wrap(err, "unable to load Mattermost translation files")
}
customDefaults, err := loadCustomDefaults()
if err != nil {
mlog.Warn("Error loading custom configuration defaults: " + err.Error())
}
configStore, err := config.NewStoreFromDSN(getConfigDSN(command, config.GetEnvironment()), false, customDefaults, true)
if err != nil {
return errors.Wrap(err, "failed to load configuration")
}
defer configStore.Close()
return runServer(configStore, interruptChan)
}
func runServer(configStore *config.Store, interruptChan chan os.Signal) error {
// Setting the highest traceback level from the code.
// This is done to print goroutines from all threads (see golang.org/issue/13161)
// and also preserve a crash dump for later investigation.
debug.SetTraceback("crash")
options := []app.Option{
// The option order is important as app.Config option reads app.StartMetrics option.
app.StartMetrics,
app.ConfigStore(configStore),
app.RunEssentialJobs,
app.JoinCluster,
}
server, err := app.NewServer(options...)
if err != nil {
mlog.Error(err.Error())
return err
}
defer server.Shutdown()
// We add this after shutdown so that it can be called
// before server shutdown happens as it can close
// the advanced logger and prevent the mlog call from working properly.
defer func() {
// A panic pass-through layer which just logs it
// and sends it upwards.
if x := recover(); x != nil {
var buf bytes.Buffer
pprof.Lookup("goroutine").WriteTo(&buf, 2)
mlog.Error("A panic occurred",
mlog.Any("error", x),
mlog.String("stack", buf.String()))
panic(x)
}
}()
_, err = api4.Init(server)
if err != nil {
mlog.Error(err.Error())
return err
}
wsapi.Init(server)
web.New(server)
err = server.Start()
if err != nil {
mlog.Error(err.Error())
return err
}
notifyReady()
// Wiping off any signal handlers set before.
// This may come from intermediary signal handlers requiring to clean
// up resources before server.Start can finish.
signal.Reset(syscall.SIGINT, syscall.SIGTERM)
// wait for kill signal before attempting to gracefully shutdown
// the running service
signal.Notify(interruptChan, syscall.SIGINT, syscall.SIGTERM)
<-interruptChan
return nil
}
func notifyReady() {
// If the environment vars provide a systemd notification socket,
// notify systemd that the server is ready.
systemdSocket := os.Getenv("NOTIFY_SOCKET")
if systemdSocket != "" {
mlog.Info("Sending systemd READY notification.")
err := sendSystemdReadyNotification(systemdSocket)
if err != nil {
mlog.Error(err.Error())
}
}
}
func sendSystemdReadyNotification(socketPath string) error {
msg := "READY=1"
addr := &net.UnixAddr{
Name: socketPath,
Net: "unixgram",
}
conn, err := net.DialUnix(addr.Net, nil, addr)
if err != nil {
return err
}
defer conn.Close()
_, err = conn.Write([]byte(msg))
return err
}

View File

@@ -0,0 +1,145 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"net"
"os"
"syscall"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/v8/channels/jobs"
"github.com/mattermost/mattermost/server/v8/config"
)
const (
unitTestListeningPort = "localhost:0"
)
//nolint:golint,unused
type ServerTestHelper struct {
disableConfigWatch bool
interruptChan chan os.Signal
originalInterval int
}
//nolint:golint,unused
func SetupServerTest(tb testing.TB) *ServerTestHelper {
if testing.Short() {
tb.SkipNow()
}
// Build a channel that will be used by the server to receive system signals...
interruptChan := make(chan os.Signal, 1)
// ...and sent it immediately a SIGINT value.
// This will make the server loop stop as soon as it started successfully.
interruptChan <- syscall.SIGINT
// Let jobs poll for termination every 0.2s (instead of every 15s by default)
// Otherwise we would have to wait the whole polling duration before the test
// terminates.
originalInterval := jobs.DefaultWatcherPollingInterval
jobs.DefaultWatcherPollingInterval = 200
th := &ServerTestHelper{
disableConfigWatch: true,
interruptChan: interruptChan,
originalInterval: originalInterval,
}
return th
}
//nolint:golint,unused
func (th *ServerTestHelper) TearDownServerTest() {
jobs.DefaultWatcherPollingInterval = th.originalInterval
}
func TestRunServerSuccess(t *testing.T) {
th := SetupServerTest(t)
defer th.TearDownServerTest()
configStore := config.NewTestMemoryStore()
// Use non-default listening port in case another server instance is already running.
cfg := configStore.Get()
*cfg.ServiceSettings.ListenAddress = unitTestListeningPort
cfg.SqlSettings = *mainHelper.GetSQLSettings()
configStore.Set(cfg)
err := runServer(configStore, th.interruptChan)
require.NoError(t, err)
}
func TestRunServerSystemdNotification(t *testing.T) {
th := SetupServerTest(t)
defer th.TearDownServerTest()
// Get a random temporary filename for using as a mock systemd socket
socketFile, err := os.CreateTemp("", "mattermost-systemd-mock-socket-")
require.NoError(t, err)
socketPath := socketFile.Name()
os.Remove(socketPath)
// t.Setenv prevents t.Parallel — env var has no config equivalent
t.Setenv("NOTIFY_SOCKET", socketPath)
// Open the socket connection
addr := &net.UnixAddr{
Name: socketPath,
Net: "unixgram",
}
connection, err := net.ListenUnixgram("unixgram", addr)
require.NoError(t, err)
defer connection.Close()
defer os.Remove(socketPath)
// Listen for socket data
socketReader := make(chan string)
go func(ch chan string) {
buffer := make([]byte, 512)
count, readErr := connection.Read(buffer)
require.NoError(t, readErr)
data := buffer[0:count]
ch <- string(data)
}(socketReader)
configStore := config.NewTestMemoryStore()
// Use non-default listening port in case another server instance is already running.
cfg := configStore.Get()
*cfg.ServiceSettings.ListenAddress = unitTestListeningPort
cfg.SqlSettings = *mainHelper.GetSQLSettings()
configStore.Set(cfg)
// Start and stop the server
err = runServer(configStore, th.interruptChan)
require.NoError(t, err)
// Ensure the notification has been sent on the socket and is correct
notification := <-socketReader
require.Equal(t, notification, "READY=1")
}
func TestRunServerNoSystemd(t *testing.T) {
th := SetupServerTest(t)
defer th.TearDownServerTest()
// Temporarily remove any Systemd socket defined in the environment
// t.Setenv prevents t.Parallel — env var has no config equivalent
t.Setenv("NOTIFY_SOCKET", "")
configStore := config.NewTestMemoryStore()
// Use non-default listening port in case another server instance is already running.
cfg := configStore.Get()
*cfg.ServiceSettings.ListenAddress = unitTestListeningPort
cfg.SqlSettings = *mainHelper.GetSQLSettings()
configStore.Set(cfg)
err := runServer(configStore, th.interruptChan)
require.NoError(t, err)
}

View File

@@ -0,0 +1,154 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"bufio"
"fmt"
"os"
"os/exec"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/v8/channels/api4"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/wsapi"
)
var TestCmd = &cobra.Command{
Use: "test",
Short: "Testing Commands",
Hidden: true,
}
var RunWebClientTestsCmd = &cobra.Command{
Use: "web_client_tests",
Short: "Run the web client tests",
RunE: webClientTestsCmdF,
}
var RunServerForWebClientTestsCmd = &cobra.Command{
Use: "web_client_tests_server",
Short: "Run the server configured for running the web client tests against it",
RunE: serverForWebClientTestsCmdF,
}
func init() {
TestCmd.AddCommand(
RunWebClientTestsCmd,
RunServerForWebClientTestsCmd,
)
RootCmd.AddCommand(TestCmd)
}
func webClientTestsCmdF(command *cobra.Command, args []string) error {
a, err := InitDBCommandContextCobra(command, app.StartMetrics)
if err != nil {
return err
}
defer a.Srv().Shutdown()
i18n.InitTranslations(*a.Config().LocalizationSettings.DefaultServerLocale, *a.Config().LocalizationSettings.DefaultClientLocale)
serverErr := a.Srv().Start()
if serverErr != nil {
return serverErr
}
_, err = api4.Init(a.Srv())
if err != nil {
return err
}
wsapi.Init(a.Srv())
a.UpdateConfig(setupClientTests)
runWebClientTests()
return nil
}
func serverForWebClientTestsCmdF(command *cobra.Command, args []string) error {
a, err := InitDBCommandContextCobra(command, app.StartMetrics)
if err != nil {
return err
}
defer a.Srv().Shutdown()
i18n.InitTranslations(*a.Config().LocalizationSettings.DefaultServerLocale, *a.Config().LocalizationSettings.DefaultClientLocale)
serverErr := a.Srv().Start()
if serverErr != nil {
return serverErr
}
_, err = api4.Init(a.Srv())
if err != nil {
return err
}
wsapi.Init(a.Srv())
a.UpdateConfig(setupClientTests)
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
<-c
return nil
}
func setupClientTests(cfg *model.Config) {
*cfg.TeamSettings.EnableOpenServer = true
*cfg.ServiceSettings.EnableCommands = false
*cfg.ServiceSettings.EnableCustomEmoji = true
*cfg.ServiceSettings.EnableIncomingWebhooks = false
*cfg.ServiceSettings.EnableOutgoingWebhooks = false
*cfg.ServiceSettings.EnableOutgoingOAuthConnections = false
}
func executeTestCommand(command *exec.Cmd) {
cmdOutPipe, err := command.StdoutPipe()
if err != nil {
CommandPrintErrorln("Failed to run tests")
os.Exit(1)
return
}
cmdErrOutPipe, err := command.StderrPipe()
if err != nil {
CommandPrintErrorln("Failed to run tests")
os.Exit(1)
return
}
cmdOutReader := bufio.NewScanner(cmdOutPipe)
cmdErrOutReader := bufio.NewScanner(cmdErrOutPipe)
go func() {
for cmdOutReader.Scan() {
fmt.Println(cmdOutReader.Text())
}
}()
go func() {
for cmdErrOutReader.Scan() {
fmt.Println(cmdErrOutReader.Text())
}
}()
if err := command.Run(); err != nil {
CommandPrintErrorln("Client Tests failed")
os.Exit(1)
return
}
}
func runWebClientTests() {
if webappDir := os.Getenv("WEBAPP_DIR"); webappDir != "" {
os.Chdir(webappDir)
} else {
os.Chdir("../mattermost-webapp")
}
cmd := exec.Command("npm", "test")
executeTestCommand(cmd)
}

View File

@@ -0,0 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"bytes"
"encoding/json"
"fmt"
"os"
"reflect"
"slices"
"strings"
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/model"
)
const CustomDefaultsEnvVar = "MM_CUSTOM_DEFAULTS_PATH"
// printStringMap takes a reflect.Value and prints it out alphabetically based on key values, which must be strings.
// This is done recursively if it's a map, and uses the given tab settings.
func printStringMap(value reflect.Value, tabVal int) string {
out := &bytes.Buffer{}
var sortedKeys []string
stringToKeyMap := make(map[string]reflect.Value)
for _, k := range value.MapKeys() {
sortedKeys = append(sortedKeys, k.String())
stringToKeyMap[k.String()] = k
}
slices.Sort(sortedKeys)
for _, keyString := range sortedKeys {
key := stringToKeyMap[keyString]
val := value.MapIndex(key)
if newVal, ok := val.Interface().(map[string]any); !ok {
fmt.Fprintf(out, "%s", strings.Repeat("\t", tabVal))
fmt.Fprintf(out, "%v: \"%v\"\n", key.Interface(), val.Interface())
} else {
fmt.Fprintf(out, "%s", strings.Repeat("\t", tabVal))
fmt.Fprintf(out, "%v:\n", key.Interface())
// going one level in, increase the tab
tabVal++
fmt.Fprintf(out, "%s", printStringMap(reflect.ValueOf(newVal), tabVal))
// coming back one level, decrease the tab
tabVal--
}
}
return out.String()
}
func getConfigDSN(command *cobra.Command, env map[string]string) string {
configDSN, _ := command.Flags().GetString("config")
// Config not supplied in flag, check env
if configDSN == "" {
configDSN = env["MM_CONFIG"]
}
// Config not supplied in env or flag use default
if configDSN == "" {
configDSN = "config.json"
}
return configDSN
}
func loadCustomDefaults() (*model.Config, error) {
customDefaultsPath := os.Getenv(CustomDefaultsEnvVar)
if customDefaultsPath == "" {
return nil, nil
}
file, err := os.Open(customDefaultsPath)
if err != nil {
return nil, fmt.Errorf("unable to open custom defaults file at %q: %w", customDefaultsPath, err)
}
defer file.Close()
var customDefaults *model.Config
err = json.NewDecoder(file).Decode(&customDefaults)
if err != nil {
return nil, fmt.Errorf("unable to decode custom defaults configuration: %w", err)
}
return customDefaults, nil
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"reflect"
"slices"
"strings"
"testing"
)
func TestPrintMap(t *testing.T) {
inputCases := []any{
map[string]any{
"CustomerType": "A9",
"SmtpUsername": "",
"SmtpPassword": "",
"EmailAddress": "",
},
map[string]any{
"EnableExport": false,
"ExportFormat": "actiance",
"DailyRunTime": "01:00",
"GlobalRelaySettings": map[string]any{
"CustomerType": "A9",
"SmtpUsername": "",
"SmtpPassword": "",
"EmailAddress": "",
},
},
}
outputCases := []string{
"CustomerType: \"A9\"\nSmtpUsername: \"\"\nSmtpPassword: \"\"\nEmailAddress: \"\"\n",
"EnableExport: \"false\"\nExportFormat: \"actiance\"\nDailyRunTime: \"01:00\"\nGlobalRelaySettings:\n\t CustomerType: \"A9\"\n\tSmtpUsername: \"\"\n\tSmtpPassword: \"\"\n\tEmailAddress: \"\"\n",
}
cases := []struct {
Name string
Input reflect.Value
Expected string
}{
{
Name: "Basic print",
Input: reflect.ValueOf(inputCases[0]),
Expected: outputCases[0],
},
{
Name: "Complex print",
Input: reflect.ValueOf(inputCases[1]),
Expected: outputCases[1],
},
}
for _, test := range cases {
t.Run(test.Name, func(t *testing.T) {
res := printStringMap(test.Input, 0)
// create two slice of string formed by splitting our strings on \n
slice1 := strings.Split(res, "\n")
slice2 := strings.Split(res, "\n")
slices.Sort(slice1)
slices.Sort(slice2)
if !reflect.DeepEqual(slice1, slice2) {
t.Errorf("got '%#v' want '%#v", slice1, slice2)
}
})
}
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"github.com/spf13/cobra"
"github.com/mattermost/mattermost/server/public/model"
)
var VersionCmd = &cobra.Command{
Use: "version",
Short: "Display version information",
RunE: versionCmdF,
}
func init() {
RootCmd.AddCommand(VersionCmd)
}
func versionCmdF(command *cobra.Command, args []string) error {
CommandPrintln("Version: " + model.CurrentVersion)
CommandPrintln("Build Number: " + model.BuildNumber)
CommandPrintln("Build Date: " + model.BuildDate)
CommandPrintln("Build Hash: " + model.BuildHash)
CommandPrintln("Build Enterprise Ready: " + model.BuildEnterpriseReady)
return nil
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"testing"
)
func TestVersion(t *testing.T) {
if testing.Short() {
t.Skip("skipping version test in short mode")
}
th := SetupWithStoreMock(t)
th.CheckCommand(t, "version")
}