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
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:
193
server/cmd/mattermost/commands/cmdtestlib.go
Normal file
193
server/cmd/mattermost/commands/cmdtestlib.go
Normal 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
|
||||
}
|
||||
368
server/cmd/mattermost/commands/db.go
Normal file
368
server/cmd/mattermost/commands/db.go
Normal 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,
|
||||
}
|
||||
}
|
||||
181
server/cmd/mattermost/commands/db_ping.go
Normal file
181
server/cmd/mattermost/commands/db_ping.go
Normal 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):
|
||||
}
|
||||
}
|
||||
}
|
||||
322
server/cmd/mattermost/commands/db_ping_test.go
Normal file
322
server/cmd/mattermost/commands/db_ping_test.go
Normal 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)
|
||||
}
|
||||
19
server/cmd/mattermost/commands/exec_command_test.go
Normal file
19
server/cmd/mattermost/commands/exec_command_test.go
Normal 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())
|
||||
}
|
||||
187
server/cmd/mattermost/commands/export.go
Normal file
187
server/cmd/mattermost/commands/export.go
Normal 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
|
||||
}
|
||||
114
server/cmd/mattermost/commands/import.go
Normal file
114
server/cmd/mattermost/commands/import.go
Normal 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
|
||||
}
|
||||
69
server/cmd/mattermost/commands/init.go
Normal file
69
server/cmd/mattermost/commands/init.go
Normal 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)
|
||||
}
|
||||
74
server/cmd/mattermost/commands/jobserver.go
Normal file
74
server/cmd/mattermost/commands/jobserver.go
Normal 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
|
||||
}
|
||||
100
server/cmd/mattermost/commands/main_test.go
Normal file
100
server/cmd/mattermost/commands/main_test.go
Normal 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)
|
||||
}
|
||||
21
server/cmd/mattermost/commands/output.go
Normal file
21
server/cmd/mattermost/commands/output.go
Normal 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...)
|
||||
}
|
||||
38
server/cmd/mattermost/commands/root.go
Normal file
38
server/cmd/mattermost/commands/root.go
Normal 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.")
|
||||
}
|
||||
}
|
||||
151
server/cmd/mattermost/commands/server.go
Normal file
151
server/cmd/mattermost/commands/server.go
Normal 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
|
||||
}
|
||||
145
server/cmd/mattermost/commands/server_test.go
Normal file
145
server/cmd/mattermost/commands/server_test.go
Normal 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)
|
||||
}
|
||||
154
server/cmd/mattermost/commands/test.go
Normal file
154
server/cmd/mattermost/commands/test.go
Normal 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)
|
||||
}
|
||||
91
server/cmd/mattermost/commands/utils.go
Normal file
91
server/cmd/mattermost/commands/utils.go
Normal 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
|
||||
}
|
||||
72
server/cmd/mattermost/commands/utils_test.go
Normal file
72
server/cmd/mattermost/commands/utils_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
30
server/cmd/mattermost/commands/version.go
Normal file
30
server/cmd/mattermost/commands/version.go
Normal 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
|
||||
}
|
||||
18
server/cmd/mattermost/commands/version_test.go
Normal file
18
server/cmd/mattermost/commands/version_test.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user