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

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

View File

@@ -0,0 +1,11 @@
with-expecter: false
packages:
github.com/mattermost/mattermost/server/v8/channels/app/platform:
config:
dir: "channels/app/platform/mocks"
filename: "{{.InterfaceName}}.go"
mockname: "{{.InterfaceName}}"
outpkg: mocks
note: "Regenerate this file using `make platform-mocks`."
interfaces:
SuiteIFace:

View File

@@ -0,0 +1,155 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"encoding/json"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
const (
TimestampFormat = "Mon Jan 2 15:04:05 -0700 MST 2006"
)
// Busy represents the busy state of the server. A server marked busy
// will have non-critical services disabled. If a Cluster is provided
// any changes will be propagated to each node.
type Busy struct {
busy int32 // protected via atomic for fast IsBusy calls
mux sync.RWMutex
timer *time.Timer
expires time.Time
cluster einterfaces.ClusterInterface
}
// NewBusy creates a new Busy instance with optional cluster which will
// be notified of busy state changes.
func NewBusy(cluster einterfaces.ClusterInterface) *Busy {
return &Busy{cluster: cluster}
}
// IsBusy returns true if the server has been marked as busy.
func (b *Busy) IsBusy() bool {
if b == nil {
return false
}
return atomic.LoadInt32(&b.busy) != 0
}
// Set marks the server as busy for dur duration and notifies cluster nodes.
func (b *Busy) Set(dur time.Duration) {
b.mux.Lock()
defer b.mux.Unlock()
// minimum 1 second
if dur < (time.Second * 1) {
dur = time.Second * 1
}
b.setWithoutNotify(dur)
if b.cluster != nil {
sbs := &model.ServerBusyState{Busy: true, Expires: b.expires.Unix(), ExpiresTS: b.expires.UTC().Format(TimestampFormat)}
b.notifyServerBusyChange(sbs)
}
}
// must hold mutex
func (b *Busy) setWithoutNotify(dur time.Duration) {
b.clearWithoutNotify()
atomic.StoreInt32(&b.busy, 1)
b.expires = time.Now().Add(dur)
b.timer = time.AfterFunc(dur, func() {
b.mux.Lock()
b.clearWithoutNotify()
b.mux.Unlock()
})
}
// ClearBusy marks the server as not busy and notifies cluster nodes.
func (b *Busy) Clear() {
b.mux.Lock()
defer b.mux.Unlock()
b.clearWithoutNotify()
if b.cluster != nil {
sbs := &model.ServerBusyState{Busy: false, Expires: time.Time{}.Unix(), ExpiresTS: ""}
b.notifyServerBusyChange(sbs)
}
}
// must hold mutex
func (b *Busy) clearWithoutNotify() {
if b.timer != nil {
b.timer.Stop() // don't drain timer.C channel for AfterFunc timers.
}
b.timer = nil
b.expires = time.Time{}
atomic.StoreInt32(&b.busy, 0)
}
// Expires returns the expected time that the server
// will be marked not busy. This expiry can be extended
// via additional calls to SetBusy.
func (b *Busy) Expires() time.Time {
b.mux.RLock()
defer b.mux.RUnlock()
return b.expires
}
// notifyServerBusyChange informs all cluster members of a server busy state change.
func (b *Busy) notifyServerBusyChange(sbs *model.ServerBusyState) {
if b.cluster == nil {
return
}
buf, _ := json.Marshal(sbs)
msg := &model.ClusterMessage{
Event: model.ClusterEventBusyStateChanged,
SendType: model.ClusterSendReliable,
WaitForAllToSend: true,
Data: buf,
}
b.cluster.SendClusterMessage(msg)
}
// ClusterEventChanged is called when a CLUSTER_EVENT_BUSY_STATE_CHANGED is received.
func (b *Busy) ClusterEventChanged(sbs *model.ServerBusyState) {
b.mux.Lock()
defer b.mux.Unlock()
if sbs.Busy {
expires := time.Unix(sbs.Expires, 0)
dur := time.Until(expires)
if dur > 0 {
b.setWithoutNotify(dur)
}
} else {
b.clearWithoutNotify()
}
}
func (b *Busy) ToJSON() ([]byte, error) {
b.mux.RLock()
defer b.mux.RUnlock()
sbs := &model.ServerBusyState{
Busy: atomic.LoadInt32(&b.busy) != 0,
Expires: b.expires.Unix(),
ExpiresTS: b.expires.UTC().Format(TimestampFormat),
}
sbsJSON, jsonErr := json.Marshal(sbs)
if jsonErr != nil {
return []byte{}, fmt.Errorf("failed to encode server busy state to JSON: %w", jsonErr)
}
return sbsJSON, nil
}

View File

@@ -0,0 +1,181 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
func TestBusySet(t *testing.T) {
mainHelper.Parallel(t)
cluster := &ClusterMock{Busy: &Busy{}, t: t}
busy := NewBusy(cluster)
isNotBusy := func() bool {
return !busy.IsBusy()
}
require.False(t, busy.IsBusy())
busy.Set(5 * time.Second)
require.True(t, busy.IsBusy())
require.True(t, compareBusyState(t, busy, cluster.Busy))
// should automatically expire after 5s.
require.Eventually(t, isNotBusy, time.Second*10, time.Millisecond*100)
// allow a moment for cluster to sync.
require.Eventually(t, func() bool { return compareBusyState(t, busy, cluster.Busy) }, time.Second*5, time.Millisecond*20)
// test set after auto expiry.
busy.Set(time.Second * 30)
require.True(t, busy.IsBusy())
require.True(t, compareBusyState(t, busy, cluster.Busy))
expire := busy.Expires()
require.Greater(t, expire.Unix(), time.Now().Add(time.Second*10).Unix())
// test extending existing expiry
busy.Set(time.Minute * 5)
require.True(t, busy.IsBusy())
require.True(t, compareBusyState(t, busy, cluster.Busy))
expire = busy.Expires()
require.Greater(t, expire.Unix(), time.Now().Add(time.Minute*2).Unix())
busy.Clear()
require.False(t, busy.IsBusy())
require.True(t, compareBusyState(t, busy, cluster.Busy))
}
func TestBusyExpires(t *testing.T) {
mainHelper.Parallel(t)
cluster := &ClusterMock{Busy: &Busy{}, t: t}
busy := NewBusy(cluster)
isNotBusy := func() bool {
return !busy.IsBusy()
}
// get expiry before it is set
expire := busy.Expires()
// should be time.Time zero value
require.Equal(t, time.Time{}.Unix(), expire.Unix())
// get expiry after it is set
busy.Set(time.Minute * 5)
expire = busy.Expires()
require.Greater(t, expire.Unix(), time.Now().Add(time.Minute*2).Unix())
require.True(t, compareBusyState(t, busy, cluster.Busy))
// get expiry after clear
busy.Clear()
expire = busy.Expires()
// should be time.Time zero value
require.Equal(t, time.Time{}.Unix(), expire.Unix())
require.True(t, compareBusyState(t, busy, cluster.Busy))
// get expiry after auto-expire
busy.Set(time.Millisecond * 100)
require.Eventually(t, isNotBusy, time.Second*5, time.Millisecond*20)
expire = busy.Expires()
// should be time.Time zero value
require.Equal(t, time.Time{}.Unix(), expire.Unix())
// allow a moment for cluster to sync
require.Eventually(t, func() bool { return compareBusyState(t, busy, cluster.Busy) }, time.Second*5, time.Millisecond*20)
}
func TestBusyRace(t *testing.T) {
mainHelper.Parallel(t)
cluster := &ClusterMock{Busy: &Busy{}, t: t}
busy := NewBusy(cluster)
busy.Set(500 * time.Millisecond)
// We are sleeping in order to let the race trigger.
time.Sleep(time.Second)
}
func compareBusyState(t *testing.T, busy1 *Busy, busy2 *Busy) bool {
t.Helper()
if busy1.IsBusy() != busy2.IsBusy() {
busy1JSON, _ := busy1.ToJSON()
busy2JSON, _ := busy2.ToJSON()
t.Logf("busy1:%s; busy2:%s\n", busy1JSON, busy2JSON)
return false
}
if busy1.Expires().Unix() != busy2.Expires().Unix() {
busy1JSON, _ := busy1.ToJSON()
busy2JSON, _ := busy2.ToJSON()
t.Logf("busy1:%s; busy2:%s\n", busy1JSON, busy2JSON)
return false
}
return true
}
// ClusterMock simulates the busy state of a cluster.
type ClusterMock struct {
Busy *Busy
t *testing.T
}
func (c *ClusterMock) SendClusterMessage(msg *model.ClusterMessage) {
var sbs model.ServerBusyState
err := json.Unmarshal(msg.Data, &sbs)
if err != nil {
require.NoError(c.t, err)
}
c.Busy.ClusterEventChanged(&sbs)
}
func (c *ClusterMock) SendClusterMessageToNode(nodeID string, msg *model.ClusterMessage) error {
return nil
}
func (c *ClusterMock) StartInterNodeCommunication() {}
func (c *ClusterMock) StopInterNodeCommunication() {}
func (c *ClusterMock) RegisterClusterMessageHandler(event model.ClusterEvent, crm einterfaces.ClusterMessageHandler) {
}
func (c *ClusterMock) GetClusterId() string { return "cluster_mock" }
func (c *ClusterMock) IsLeader() bool { return false }
func (c *ClusterMock) GetMyClusterInfo() *model.ClusterInfo { return nil }
func (c *ClusterMock) GetClusterInfos() ([]*model.ClusterInfo, error) { return nil, nil }
func (c *ClusterMock) NotifyMsg(buf []byte) {}
func (c *ClusterMock) GetClusterStats(rctx request.CTX) ([]*model.ClusterStats, *model.AppError) {
return nil, nil
}
func (c *ClusterMock) GetLogs(rctx request.CTX, page, perPage int) ([]string, *model.AppError) {
return nil, nil
}
func (c *ClusterMock) QueryLogs(rctx request.CTX, page, perPage int) (map[string][]string, *model.AppError) {
return nil, nil
}
func (c *ClusterMock) GenerateSupportPacket(rctx request.CTX, options *model.SupportPacketOptions) (map[string][]model.FileData, error) {
return nil, nil
}
func (c *ClusterMock) GetPluginStatuses() (model.PluginStatuses, *model.AppError) { return nil, nil }
func (c *ClusterMock) ConfigChanged(previousConfig *model.Config, newConfig *model.Config, sendToOtherServer bool) *model.AppError {
return nil
}
func (c *ClusterMock) HealthScore() int { return 0 }
func (c *ClusterMock) WebConnCountForUser(userID string) (int, *model.AppError) {
return 0, nil
}
func (c *ClusterMock) GetWSQueues(userID, connectionID string, seqNum int64) (map[string]*model.WSQueues, error) {
return nil, nil
}
func (c *ClusterMock) Shutdown() {}

View File

@@ -0,0 +1,259 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"errors"
"fmt"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
func (ps *PlatformService) Cluster() einterfaces.ClusterInterface {
return ps.clusterIFace
}
func (ps *PlatformService) NewClusterDiscoveryService() *ClusterDiscoveryService {
ds := &ClusterDiscoveryService{
ClusterDiscovery: model.ClusterDiscovery{},
platform: ps,
stop: make(chan bool),
}
return ds
}
// PlatformService.IsLeader returns true if this server is the leader of its cluster. If the server isn't in a cluster
// (because it's not supported by the server or its license), this will always return true.
func (ps *PlatformService) IsLeader() bool {
license := ps.License()
if license == nil || license.Features == nil || license.Features.Cluster == nil || !*license.Features.Cluster {
// Clustering can't be enabled without a valid license that supports it
return true
}
if !*ps.Config().ClusterSettings.Enable {
// Clustering is disabled
return true
}
if ps.clusterIFace == nil {
// Clustering isn't supported by this server
return true
}
// Check with the clustering code
return ps.clusterIFace.IsLeader()
}
func (ps *PlatformService) SetCluster(impl einterfaces.ClusterInterface) { //nolint:unused
ps.clusterIFace = impl
}
func (ps *PlatformService) PublishPluginClusterEvent(productID string, ev model.PluginClusterEvent, opts model.PluginClusterEventSendOptions) error {
if ps.clusterIFace == nil {
return nil
}
msg := &model.ClusterMessage{
Event: model.ClusterEventPluginEvent,
SendType: opts.SendType,
WaitForAllToSend: false,
Props: map[string]string{
"EventID": ev.Id,
},
Data: ev.Data,
}
// If TargetId is empty we broadcast to all other cluster nodes.
if opts.TargetId == "" {
ps.clusterIFace.SendClusterMessage(msg)
} else {
if err := ps.clusterIFace.SendClusterMessageToNode(opts.TargetId, msg); err != nil {
return fmt.Errorf("failed to send message to cluster node %q: %w", opts.TargetId, err)
}
}
return nil
}
func (ps *PlatformService) PublishWebSocketEvent(productID string, event string, payload map[string]any, broadcast *model.WebsocketBroadcast) {
ev := model.NewWebSocketEvent(model.WebsocketEventType(fmt.Sprintf("custom_%v_%v", productID, event)), "", "", "", nil, "")
ev = ev.SetBroadcast(broadcast).SetData(payload)
ps.Publish(ev)
}
func (ps *PlatformService) SetPluginKeyWithOptions(productID string, key string, value []byte, options model.PluginKVSetOptions) (bool, *model.AppError) {
if err := options.IsValid(); err != nil {
mlog.Debug("Failed to set plugin key value with options", mlog.String("plugin_id", productID), mlog.String("key", key), mlog.Err(err))
return false, err
}
updated, err := ps.Store.Plugin().SetWithOptions(productID, key, value, options)
if err != nil {
mlog.Error("Failed to set plugin key value with options", mlog.String("plugin_id", productID), mlog.String("key", key), mlog.Err(err))
var appErr *model.AppError
switch {
case errors.As(err, &appErr):
return false, appErr
default:
return false, model.NewAppError("SetPluginKeyWithOptions", "app.plugin_store.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
// Clean up a previous entry using the hashed key, if it exists.
if err := ps.Store.Plugin().Delete(productID, getKeyHash(key)); err != nil {
mlog.Warn("Failed to clean up previously hashed plugin key value", mlog.String("plugin_id", productID), mlog.String("key", key), mlog.Err(err))
}
return updated, nil
}
func (ps *PlatformService) KVGet(productID, key string) ([]byte, *model.AppError) {
if kv, err := ps.Store.Plugin().Get(productID, key); err == nil {
return kv.Value, nil
} else if nfErr := new(store.ErrNotFound); !errors.As(err, &nfErr) {
mlog.Error("Failed to query plugin key value", mlog.String("plugin_id", productID), mlog.String("key", key), mlog.Err(err))
return nil, model.NewAppError("GetPluginKey", "app.plugin_store.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Lookup using the hashed version of the key for keys written prior to v5.6.
if kv, err := ps.Store.Plugin().Get(productID, getKeyHash(key)); err == nil {
return kv.Value, nil
} else if nfErr := new(store.ErrNotFound); !errors.As(err, &nfErr) {
mlog.Error("Failed to query plugin key value using hashed key", mlog.String("plugin_id", productID), mlog.String("key", key), mlog.Err(err))
return nil, model.NewAppError("GetPluginKey", "app.plugin_store.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil, nil
}
func (ps *PlatformService) KVDelete(productID, key string) *model.AppError {
if err := ps.Store.Plugin().Delete(productID, getKeyHash(key)); err != nil {
ps.logger.Error("Failed to delete plugin key value", mlog.String("plugin_id", productID), mlog.String("key", key), mlog.Err(err))
return model.NewAppError("DeletePluginKey", "app.plugin_store.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Also delete the key without hashing
if err := ps.Store.Plugin().Delete(productID, key); err != nil {
ps.logger.Error("Failed to delete plugin key value using hashed key", mlog.String("plugin_id", productID), mlog.String("key", key), mlog.Err(err))
return model.NewAppError("DeletePluginKey", "app.plugin_store.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
func (ps *PlatformService) KVList(productID string, page, perPage int) ([]string, *model.AppError) {
data, err := ps.Store.Plugin().List(productID, page*perPage, perPage)
if err != nil {
ps.logger.Error("Failed to list plugin key values", mlog.Int("page", page), mlog.Int("per_page", perPage), mlog.Err(err))
return nil, model.NewAppError("ListPluginKeys", "app.plugin_store.list.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return data, nil
}
// Registers a given function to be called when the cluster leader may have changed. Returns a unique ID for the
// listener which can later be used to remove it. If clustering is not enabled in this build, the callback will never
// be called.
func (ps *PlatformService) AddClusterLeaderChangedListener(listener func()) string {
id := model.NewId()
ps.clusterLeaderListeners.Store(id, listener)
return id
}
// Removes a listener function by the unique ID returned when AddConfigListener was called
func (ps *PlatformService) RemoveClusterLeaderChangedListener(id string) {
ps.clusterLeaderListeners.Delete(id)
}
func (ps *PlatformService) InvokeClusterLeaderChangedListeners() {
ps.logger.Info("Cluster leader changed. Invoking ClusterLeaderChanged listeners.")
// This needs to be run in a separate goroutine otherwise a recursive lock happens
// because the listener function eventually ends up calling .IsLeader().
// Fixing this would require the changed event to pass the leader directly, but that
// requires a lot of work.
ps.Go(func() {
ps.clusterLeaderListeners.Range(func(_, listener any) bool {
listener.(func())()
return true
})
})
}
func (ps *PlatformService) Publish(message *model.WebSocketEvent) {
if ps.metricsIFace != nil {
ps.metricsIFace.IncrementWebsocketEvent(message.EventType())
}
ps.PublishSkipClusterSend(message)
if ps.clusterIFace != nil {
data, err := message.ToJSON()
if err != nil {
mlog.Warn("Failed to encode message to JSON", mlog.Err(err))
}
cm := &model.ClusterMessage{
Event: model.ClusterEventPublish,
SendType: model.ClusterSendBestEffort,
Data: data,
}
if message.EventType() == model.WebsocketEventPosted ||
message.EventType() == model.WebsocketEventPostEdited ||
message.EventType() == model.WebsocketEventDirectAdded ||
message.EventType() == model.WebsocketEventGroupAdded ||
message.EventType() == model.WebsocketEventAddedToTeam ||
message.GetBroadcast().ReliableClusterSend {
cm.SendType = model.ClusterSendReliable
}
ps.clusterIFace.SendClusterMessage(cm)
}
}
func (ps *PlatformService) PublishSkipClusterSend(event *model.WebSocketEvent) {
if event.GetBroadcast().UserId != "" {
hub := ps.GetHubForUserId(event.GetBroadcast().UserId)
if hub != nil {
hub.Broadcast(event)
}
} else {
for _, hub := range ps.hubs {
hub.Broadcast(event)
}
}
// Notify shared channel sync service
ps.SharedChannelSyncHandler(event)
}
func (ps *PlatformService) ListPluginKeys(pluginID string, page, perPage int) ([]string, *model.AppError) {
data, err := ps.Store.Plugin().List(pluginID, page*perPage, perPage)
if err != nil {
mlog.Error("Failed to list plugin key values", mlog.Int("page", page), mlog.Int("per_page", perPage), mlog.Err(err))
return nil, model.NewAppError("ListPluginKeys", "app.plugin_store.list.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return data, nil
}
func (ps *PlatformService) DeletePluginKey(pluginID string, key string) *model.AppError {
if err := ps.Store.Plugin().Delete(pluginID, getKeyHash(key)); err != nil {
mlog.Error("Failed to delete plugin key value", mlog.String("plugin_id", pluginID), mlog.String("key", key), mlog.Err(err))
return model.NewAppError("DeletePluginKey", "app.plugin_store.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Also delete the key without hashing
if err := ps.Store.Plugin().Delete(pluginID, key); err != nil {
mlog.Error("Failed to delete plugin key value using hashed key", mlog.String("plugin_id", pluginID), mlog.String("key", key), mlog.Err(err))
return model.NewAppError("DeletePluginKey", "app.plugin_store.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
const (
DiscoveryServiceWritePing = 60 * time.Second
)
type ClusterDiscoveryService struct {
model.ClusterDiscovery
platform *PlatformService
stop chan bool
}
func (cds *ClusterDiscoveryService) Start() {
err := cds.platform.Store.ClusterDiscovery().Cleanup()
if err != nil {
mlog.Warn("ClusterDiscoveryService failed to cleanup the outdated cluster discovery information", mlog.Err(err))
}
exists, err := cds.platform.Store.ClusterDiscovery().Exists(&cds.ClusterDiscovery)
if err != nil {
mlog.Warn("ClusterDiscoveryService failed to check if row exists", mlog.String("cluster_discovery_id", cds.ClusterDiscovery.Id), mlog.Err(err))
} else if exists {
if _, err := cds.platform.Store.ClusterDiscovery().Delete(&cds.ClusterDiscovery); err != nil {
mlog.Warn("ClusterDiscoveryService failed to start clean", mlog.String("cluster_discovery_id", cds.ClusterDiscovery.Id), mlog.Err(err))
}
}
if err := cds.platform.Store.ClusterDiscovery().Save(&cds.ClusterDiscovery); err != nil {
mlog.Error("ClusterDiscoveryService failed to save", mlog.String("cluster_discovery_id", cds.ClusterDiscovery.Id), mlog.Err(err))
return
}
go func() {
mlog.Debug("ClusterDiscoveryService ping writer started", mlog.String("cluster_discovery_id", cds.ClusterDiscovery.Id))
ticker := time.NewTicker(DiscoveryServiceWritePing)
defer func() {
ticker.Stop()
if _, err := cds.platform.Store.ClusterDiscovery().Delete(&cds.ClusterDiscovery); err != nil {
mlog.Warn("ClusterDiscoveryService failed to cleanup", mlog.String("cluster_discovery_id", cds.ClusterDiscovery.Id), mlog.Err(err))
}
mlog.Debug("ClusterDiscoveryService ping writer stopped", mlog.String("cluster_discovery_id", cds.ClusterDiscovery.Id))
}()
for {
select {
case <-ticker.C:
if err := cds.platform.Store.ClusterDiscovery().SetLastPingAt(&cds.ClusterDiscovery); err != nil {
mlog.Error("ClusterDiscoveryService failed to write ping", mlog.String("cluster_discovery_id", cds.ClusterDiscovery.Id), mlog.Err(err))
}
case <-cds.stop:
return
}
}
}()
}
func (cds *ClusterDiscoveryService) Stop() {
cds.stop <- true
}
func (ps *PlatformService) GetClusterId() string {
if ps.Cluster() == nil {
return ""
}
return ps.Cluster().GetClusterId()
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"testing"
"time"
"github.com/mattermost/mattermost/server/public/model"
)
func TestClusterDiscoveryService(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
ds := th.Service.NewClusterDiscoveryService()
ds.Type = model.CDSTypeApp
ds.ClusterName = "ClusterA"
ds.AutoFillHostname()
ds.Start()
time.Sleep(2 * time.Second)
ds.Stop()
time.Sleep(2 * time.Second)
}

View File

@@ -0,0 +1,176 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"bytes"
"encoding/json"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
func (ps *PlatformService) RegisterClusterHandlers() {
ps.clusterIFace.RegisterClusterMessageHandler(model.ClusterEventPublish, ps.ClusterPublishHandler)
ps.clusterIFace.RegisterClusterMessageHandler(model.ClusterEventUpdateStatus, ps.ClusterUpdateStatusHandler)
ps.clusterIFace.RegisterClusterMessageHandler(model.ClusterEventInvalidateAllCaches, ps.ClusterInvalidateAllCachesHandler)
ps.clusterIFace.RegisterClusterMessageHandler(model.ClusterEventInvalidateWebConnCacheForUser, ps.clusterInvalidateWebConnSessionCacheForUserHandler)
ps.clusterIFace.RegisterClusterMessageHandler(model.ClusterEventBusyStateChanged, ps.clusterBusyStateChgHandler)
ps.clusterIFace.RegisterClusterMessageHandler(model.ClusterEventUpdateSessionAttributes, ps.ClusterUpdateSessionAttributesHandler)
ps.clusterIFace.RegisterClusterMessageHandler(model.ClusterEventClearSessionCacheForUser, ps.clusterClearSessionCacheForUserHandler)
ps.clusterIFace.RegisterClusterMessageHandler(model.ClusterEventClearSessionCacheForAllUsers, ps.clusterClearSessionCacheForAllUsersHandler)
for e, h := range ps.additionalClusterHandlers {
ps.clusterIFace.RegisterClusterMessageHandler(e, h)
}
}
func (ps *PlatformService) RegisterClusterMessageHandler(ev model.ClusterEvent, h einterfaces.ClusterMessageHandler) {
ps.additionalClusterHandlers[ev] = h
}
func (ps *PlatformService) ClusterPublishHandler(msg *model.ClusterMessage) {
event, err := model.WebSocketEventFromJSON(bytes.NewReader(msg.Data))
if err != nil {
ps.logger.Warn("Failed to decode event from JSON", mlog.Err(err))
return
}
ps.PublishSkipClusterSend(event)
}
func (ps *PlatformService) ClusterUpdateStatusHandler(msg *model.ClusterMessage) {
var status model.Status
if err := json.Unmarshal(msg.Data, &status); err != nil {
ps.logger.Warn("Failed to decode status from JSON", mlog.Err(err))
}
if err := ps.statusCache.SetWithDefaultExpiry(status.UserId, status); err != nil {
ps.logger.Warn("Failed to store the status in the cache", mlog.String("user_id", status.UserId), mlog.Err(err))
}
}
func (ps *PlatformService) ClusterUpdateSessionAttributesHandler(msg *model.ClusterMessage) {
var payload model.SessionAttributesClusterPayload
if err := json.Unmarshal(msg.Data, &payload); err != nil {
ps.logger.Warn("Failed to decode session attributes from JSON", mlog.Err(err))
return
}
if err := ps.Store.SessionAttribute().Refresh(payload.SessionID, payload.Attrs, payload.Timestamp); err != nil {
ps.logger.Warn("Failed to merge session attributes from cluster", mlog.String("session_id", payload.SessionID), mlog.Err(err))
}
}
func (ps *PlatformService) ClusterInvalidateAllCachesHandler(msg *model.ClusterMessage) {
if err := ps.InvalidateAllCachesSkipSend(); err != nil {
ps.logger.Error("Error validating caches from cluster message", mlog.Err(err))
}
}
func (ps *PlatformService) clusterInvalidateWebConnSessionCacheForUserHandler(msg *model.ClusterMessage) {
ps.invalidateWebConnSessionCacheForUserSkipClusterSend(string(msg.Data))
}
func (ps *PlatformService) ClearSessionCacheForUserSkipClusterSend(userID string) {
ps.ClearUserSessionCacheLocal(userID)
ps.invalidateWebConnSessionCacheForUserSkipClusterSend(userID)
}
// ClearSessionCacheForAllUsersSkipClusterSend purges the in-memory
// session cache and invalidates every WebConn on this node. The hub
// fan-out runs even if the cache purge fails; the purge error is
// returned so wrappers can propagate it.
func (ps *PlatformService) ClearSessionCacheForAllUsersSkipClusterSend() error {
ps.logger.Info("Purging sessions cache")
err := ps.ClearAllUsersSessionCacheLocal()
if err != nil {
ps.logger.Error("Failed to purge session cache", mlog.Err(err))
}
ps.invalidateWebConnSessionCacheForAllUsersSkipClusterSend()
return err
}
func (ps *PlatformService) clusterClearSessionCacheForUserHandler(msg *model.ClusterMessage) {
ps.ClearSessionCacheForUserSkipClusterSend(string(msg.Data))
}
func (ps *PlatformService) clusterClearSessionCacheForAllUsersHandler(msg *model.ClusterMessage) {
if err := ps.ClearSessionCacheForAllUsersSkipClusterSend(); err != nil {
ps.logger.Error("Failed to clear session cache for all users from cluster handler", mlog.Err(err))
}
}
func (ps *PlatformService) clusterBusyStateChgHandler(msg *model.ClusterMessage) {
var sbs model.ServerBusyState
if err := json.Unmarshal(msg.Data, &sbs); err != nil {
ps.logger.Warn("Failed to decode server busy state from JSON", mlog.Err(err))
}
ps.Busy.ClusterEventChanged(&sbs)
if sbs.Busy {
ps.logger.Warn("server busy state activated via cluster event - non-critical services disabled", mlog.Int("expires_sec", sbs.Expires))
} else {
ps.logger.Info("server busy state cleared via cluster event - non-critical services enabled")
}
}
func (ps *PlatformService) invalidateWebConnSessionCacheForUserSkipClusterSend(userID string) {
hub := ps.GetHubForUserId(userID)
if hub != nil {
hub.InvalidateUser(userID)
}
}
// invalidateWebConnSessionCacheForAllUsersSkipClusterSend signals
// every hub on this node to invalidate the cached session state of
// all of its WebConns. Companion to ClearAllUsersSessionCacheLocal.
func (ps *PlatformService) invalidateWebConnSessionCacheForAllUsersSkipClusterSend() {
for _, hub := range ps.hubs {
if hub != nil {
hub.InvalidateAll()
}
}
}
func (ps *PlatformService) InvalidateAllCachesSkipSend() *model.AppError {
ps.logger.Info("Purging all caches")
if err := ps.ClearAllUsersSessionCacheLocal(); err != nil {
ps.logger.Error("Failed to purge session cache", mlog.Err(err))
}
if err := ps.statusCache.Purge(); err != nil {
ps.logger.Warn("Failed to clear the status cache", mlog.Err(err))
}
ps.Store.Team().ClearCaches()
ps.Store.Channel().ClearCaches()
ps.Store.User().ClearCaches()
ps.Store.Post().ClearCaches()
ps.Store.FileInfo().ClearCaches()
ps.Store.Webhook().ClearCaches()
ps.Store.DeliveryTracking().ClearCaches()
if err := linkCache.Purge(); err != nil {
ps.logger.Warn("Failed to clear the link cache", mlog.Err(err))
}
ps.LoadLicense()
return nil
}
func (ps *PlatformService) InvalidateAllCaches() *model.AppError {
if err := ps.InvalidateAllCachesSkipSend(); err != nil {
return err
}
if ps.clusterIFace != nil {
msg := &model.ClusterMessage{
Event: model.ClusterEventInvalidateAllCaches,
SendType: model.ClusterSendReliable,
WaitForAllToSend: true,
}
ps.clusterIFace.SendClusterMessage(msg)
}
return nil
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
// TestRevokeSessionsFromAllUsersInvalidatesWebConnSession asserts that
// after the public RevokeSessionsFromAllUsers entry point returns,
// every live WebConn on this node — across multiple users (hashed to
// different hubs) and multiple connections per user — has its cached
// session reset to the authenticated-as-no-one state.
func TestRevokeSessionsFromAllUsersInvalidatesWebConnSession(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
s := httptest.NewServer(dummyWebsocketHandler(t))
defer s.Close()
// Spread connections across multiple hubs via GetHubForUserId's
// hash(userID) mod len(hubs) sharding, and use multiple conns per
// user to cover the multi-device case.
type userConns struct {
userID string
wcs []*WebConn
}
users := []*userConns{
{userID: th.BasicUser.Id},
{userID: th.BasicUser2.Id},
}
for range 4 {
users = append(users, &userConns{userID: model.NewId()})
}
for _, u := range users {
preWarmStatusOnline(th, u.userID)
}
const connsPerUser = 2
for _, u := range users {
for range connsPerUser {
session, err := th.Service.CreateSession(th.Context, &model.Session{
UserId: u.userID,
})
require.NoError(t, err)
session.ExpiresAt = model.GetMillis() + time.Hour.Milliseconds()
wc := registerDummyWebConn(t, th, s.Listener.Addr(), session)
t.Cleanup(func() { wc.Close() })
u.wcs = append(u.wcs, wc)
waitForWebConnRegistered(t, th, session)
}
}
for _, u := range users {
for _, wc := range u.wcs {
require.NotNil(t, wc.GetSession(),
"precondition: webconn for user %q must have a cached session before revoke", u.userID)
require.Greater(t, wc.GetSessionExpiresAt(), model.GetMillis(),
"precondition: cached expiry for user %q must be in the future before revoke", u.userID)
require.NotEmpty(t, wc.GetSessionToken(),
"precondition: webconn for user %q must have a cached session token before revoke", u.userID)
}
}
require.NoError(t, th.Service.RevokeSessionsFromAllUsers(),
"RevokeSessionsFromAllUsers should not error")
require.Eventually(t, func() bool {
for _, u := range users {
for _, wc := range u.wcs {
if wc.GetSession() != nil {
return false
}
if wc.GetSessionExpiresAt() != 0 {
return false
}
if wc.GetSessionToken() != "" {
return false
}
}
}
return true
}, 5*time.Second, 25*time.Millisecond,
"RevokeSessionsFromAllUsers did not invalidate every live WebConn across all hubs")
}

View File

@@ -0,0 +1,111 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
// waitForWebConnRegistered blocks until the hub has processed the
// WebConn registration. Without it, a test can race past the async
// register channel and signal invalidation against an empty connIndex.
func waitForWebConnRegistered(t *testing.T, th *TestHelper, session *model.Session) {
t.Helper()
require.Eventually(t, func() bool {
return th.Service.SessionIsRegistered(*session)
}, 2*time.Second, 10*time.Millisecond,
"WebConn for session %q (user %q) was not registered with the hub in time",
session.Id, session.UserId)
}
// preWarmStatusOnline marks the user online before any WebConn is
// created so the async SetStatusOnline goroutine in NewWebConn skips
// the broadcast path. Otherwise the broadcast can race with the
// invalidation and re-populate the WebConn's cached session via
// IsBasicAuthenticated, flipping the post-invalidate assertions.
func preWarmStatusOnline(th *TestHelper, userID string) {
th.Service.AddStatusCacheSkipClusterSend(&model.Status{
UserId: userID,
Status: model.StatusOnline,
LastActivityAt: model.GetMillis(),
})
}
// TestClearSessionCacheInvalidatesWebConnSession asserts that after either
// the per-user or the global session-cache clear runs, every matching
// active WebSocket connection has its cached session reset to the
// authenticated-as-no-one state (GetSession() == nil and
// GetSessionExpiresAt() == 0).
func TestClearSessionCacheInvalidatesWebConnSession(t *testing.T) {
mainHelper.Parallel(t)
tests := []struct {
name string
revoke func(ps *PlatformService, userID string)
}{
{
name: "PerUserRevokeInvalidatesWebConnSession",
revoke: func(ps *PlatformService, userID string) {
ps.ClearSessionCacheForUserSkipClusterSend(userID)
},
},
{
name: "GlobalRevokeInvalidatesWebConnSession",
revoke: func(ps *PlatformService, _ string) {
_ = ps.ClearSessionCacheForAllUsersSkipClusterSend()
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
th := Setup(t).InitBasic(t)
s := httptest.NewServer(dummyWebsocketHandler(t))
defer s.Close()
session, err := th.Service.CreateSession(th.Context, &model.Session{
UserId: th.BasicUser.Id,
})
require.NoError(t, err)
// Pin a future expiry so IsBasicAuthenticated trusts the
// cached session and doesn't re-validate against the store.
session.ExpiresAt = model.GetMillis() + time.Hour.Milliseconds()
preWarmStatusOnline(th, th.BasicUser.Id)
wc := registerDummyWebConn(t, th, s.Listener.Addr(), session)
defer wc.Close()
waitForWebConnRegistered(t, th, session)
require.NotNil(t, wc.GetSession(),
"precondition: webconn must have a cached session before revoke")
require.Greater(t, wc.GetSessionExpiresAt(), model.GetMillis(),
"precondition: webconn cached session expiry must be in the future before revoke")
tt.revoke(th.Service, th.BasicUser.Id)
// Hub invalidation is async, so poll for the end state.
require.Eventually(t, func() bool {
return wc.GetSession() == nil && wc.GetSessionExpiresAt() == 0
}, 2*time.Second, 25*time.Millisecond,
"webconn cached session was not invalidated after %s; "+
"expected GetSession()==nil and GetSessionExpiresAt()==0, "+
"but got GetSession()!=nil=%t, GetSessionExpiresAt()=%d, GetSessionToken()=%q",
tt.name,
wc.GetSession() != nil,
wc.GetSessionExpiresAt(),
wc.GetSessionToken(),
)
})
}
}

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)
func TestIsLeader(t *testing.T) {
mainHelper.Parallel(t)
t.Run("no license returns true", func(t *testing.T) {
th := Setup(t)
th.Service.SetLicense(nil)
assert.True(t, th.Service.IsLeader())
})
t.Run("license with cluster enabled and cluster interface returns cluster leader", func(t *testing.T) {
fakeCluster := &testlib.FakeClusterInterface{}
th := SetupWithCluster(t, fakeCluster)
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.ClusterSettings.Enable = true
})
// This is the only case where IsLeader returns false because it's the only case where we actually call
// FakeClusterInterface.IsLeader() which returns false
assert.False(t, th.Service.IsLeader())
})
t.Run("license without cluster feature but cluster enabled returns true", func(t *testing.T) {
fakeCluster := &testlib.FakeClusterInterface{}
th := SetupWithCluster(t, fakeCluster)
// Set a license without the Cluster feature (like an Entry license)
license := model.NewTestLicenseWithFalseDefaults("cluster")
th.Service.SetLicense(license)
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.ClusterSettings.Enable = true
})
// Even though ClusterSettings.Enable is true and clusterIFace is set, the license doesn't support clustering,
// so this node should be leader
assert.True(t, th.Service.IsLeader())
})
t.Run("cluster settings disabled returns true", func(t *testing.T) {
fakeCluster := &testlib.FakeClusterInterface{}
th := SetupWithCluster(t, fakeCluster)
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.ClusterSettings.Enable = false
})
assert.True(t, th.Service.IsLeader())
})
}

View File

@@ -0,0 +1,418 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"maps"
"net/http"
"reflect"
"strconv"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/public/utils"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/config"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
// ServiceConfig is used to initialize the PlatformService.
// The mandatory fields will be checked during the initialization of the service.
type ServiceConfig struct {
// Mandatory fields
Store store.Store
// Optional fields
Cluster einterfaces.ClusterInterface
}
func (ps *PlatformService) Config() *model.Config {
return ps.configStore.Get()
}
// getSanitizedConfig gets the configuration without any secrets.
func (ps *PlatformService) getSanitizedConfig(rctx request.CTX, opts *model.SanitizeOptions) *model.Config {
cfg := ps.Config().Clone()
manifests, err := ps.getPluginManifests()
if err != nil {
// getPluginManifests might error, e.g. when plugins are disabled.
// Sanitize all plugin settings in this case.
rctx.Logger().Warn("Failed to get plugin manifests for config sanitization. Will sanitize all plugin settings.", mlog.Err(err))
cfg.Sanitize(nil, opts)
} else {
cfg.Sanitize(manifests, opts)
}
return cfg
}
// Registers a function with a given listener to be called when the config is reloaded and may have changed. The function
// will be called with two arguments: the old config and the new config. AddConfigListener returns a unique ID
// for the listener that can later be used to remove it.
func (ps *PlatformService) AddConfigListener(listener func(*model.Config, *model.Config)) string {
return ps.configStore.AddListener(listener)
}
func (ps *PlatformService) RemoveConfigListener(id string) {
ps.configStore.RemoveListener(id)
}
func (ps *PlatformService) UpdateConfig(f func(*model.Config)) {
if ps.configStore.IsReadOnly() {
return
}
old := ps.Config()
updated := old.Clone()
f(updated)
if _, _, err := ps.configStore.Set(updated); err != nil {
ps.logger.Error("Failed to update config", mlog.Err(err))
}
}
// IsConfigReadOnly returns true if the underlying configstore is readonly.
func (ps *PlatformService) IsConfigReadOnly() bool {
return ps.configStore.IsReadOnly()
}
// SaveConfig replaces the active configuration, optionally notifying cluster peers.
// It returns both the previous and current configs.
func (ps *PlatformService) SaveConfig(newCfg *model.Config, sendConfigChangeClusterMessage bool) (*model.Config, *model.Config, *model.AppError) {
if ps.pluginEnv != nil {
var hookErr error
ps.pluginEnv.RunMultiHook(func(hooks plugin.Hooks, _ *model.Manifest) bool {
var cfg *model.Config
cfg, hookErr = hooks.ConfigurationWillBeSaved(newCfg)
if hookErr == nil && cfg != nil {
newCfg = cfg
}
return hookErr == nil
}, plugin.ConfigurationWillBeSavedID)
if hookErr != nil {
if appErr, ok := hookErr.(*model.AppError); ok {
return nil, nil, appErr
}
return nil, nil, model.NewAppError("saveConfig", "app.save_config.plugin_hook_error", nil, "", http.StatusBadRequest).Wrap(hookErr)
}
}
// Validate log file paths (logs errors for now, will block server startup in future version)
config.WarnIfLogPathsOutsideRoot(newCfg)
oldCfg, newCfg, err := ps.configStore.Set(newCfg)
if errors.Is(err, config.ErrReadOnlyConfiguration) {
return nil, nil, model.NewAppError("saveConfig", "ent.cluster.save_config.error", nil, "", http.StatusForbidden).Wrap(err)
} else if err != nil {
return nil, nil, model.NewAppError("saveConfig", "app.save_config.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if ps.clusterIFace != nil {
err := ps.clusterIFace.ConfigChanged(ps.configStore.RemoveEnvironmentOverrides(oldCfg),
ps.configStore.RemoveEnvironmentOverrides(newCfg), sendConfigChangeClusterMessage)
if err != nil {
return nil, nil, err
}
}
return oldCfg, newCfg, nil
}
func (ps *PlatformService) ReloadConfig() error {
if err := ps.configStore.Load(); err != nil {
return err
}
return nil
}
func (ps *PlatformService) GetEnvironmentOverridesWithFilter(filter func(reflect.StructField) bool) map[string]any {
return ps.configStore.GetEnvironmentOverridesWithFilter(filter)
}
func (ps *PlatformService) GetEnvironmentOverrides() map[string]any {
return ps.configStore.GetEnvironmentOverrides()
}
func (ps *PlatformService) DescribeConfig() string {
return ps.configStore.String()
}
func (ps *PlatformService) CleanUpConfig() error {
return ps.configStore.CleanUp()
}
// ConfigureLogger applies the specified configuration to a logger.
func (ps *PlatformService) ConfigureLogger(name string, logger *mlog.Logger, logSettings *model.LogSettings, getPath func(string) string) error {
// Advanced logging is E20 only, however logging must be initialized before the license
// file is loaded. If no valid E20 license exists then advanced logging will be
// shutdown once license is loaded/checked.
var resultLevel = mlog.LvlInfo
var resultMsg string
var err error
var logConfigSrc config.LogConfigSrc
dsn := logSettings.GetAdvancedLoggingConfig()
if !utils.IsEmptyJSON(dsn) {
logConfigSrc, err = config.NewLogConfigSrc(dsn, ps.configStore)
if err != nil {
return fmt.Errorf("invalid config source for %s, %w", name, err)
}
resultMsg = fmt.Sprintf("Loaded Advanced Logging configuration for %s: %s", name, string(dsn))
} else {
resultLevel = mlog.LvlDebug
resultMsg = fmt.Sprintf("Advanced logging config not provided for %s", name)
}
cfg, err := config.MloggerConfigFromLoggerConfig(logSettings, logConfigSrc, getPath)
if err != nil {
return fmt.Errorf("invalid config source for %s, %w", name, err)
}
// this will remove any existing targets and replace with those defined in cfg.
if err := logger.ConfigureTargets(cfg, nil); err != nil {
return fmt.Errorf("invalid config for %s, %w", name, err)
}
if resultMsg != "" {
ps.Log().Log(resultLevel, resultMsg)
}
return nil
}
func (ps *PlatformService) GetConfigStore() *config.Store {
return ps.configStore
}
func (ps *PlatformService) GetConfigFile(name string) ([]byte, error) {
return ps.configStore.GetFile(name)
}
func (ps *PlatformService) SetConfigFile(name string, data []byte) error {
return ps.configStore.SetFile(name, data)
}
func (ps *PlatformService) RemoveConfigFile(name string) error {
return ps.configStore.RemoveFile(name)
}
func (ps *PlatformService) HasConfigFile(name string) (bool, error) {
return ps.configStore.HasFile(name)
}
func (ps *PlatformService) SetConfigReadOnlyFF(readOnly bool) {
ps.configStore.SetReadOnlyFF(readOnly)
}
func (ps *PlatformService) ClientConfigHash() string {
return ps.clientConfigHash.Load().(string)
}
func (ps *PlatformService) regenerateClientConfig() {
clientConfig := config.GenerateClientConfig(ps.Config(), ps.telemetryId, ps.License())
limitedClientConfig := config.GenerateLimitedClientConfig(ps.Config(), ps.telemetryId, ps.License())
if clientConfig["EnableCustomTermsOfService"] == "true" {
termsOfService, err := ps.Store.TermsOfService().GetLatest(true)
if err != nil {
mlog.Err(err)
} else {
clientConfig["CustomTermsOfServiceId"] = termsOfService.Id
limitedClientConfig["CustomTermsOfServiceId"] = termsOfService.Id
}
}
if key := ps.AsymmetricSigningKey(); key != nil {
der, _ := x509.MarshalPKIXPublicKey(&key.PublicKey)
clientConfig["AsymmetricSigningPublicKey"] = base64.StdEncoding.EncodeToString(der)
limitedClientConfig["AsymmetricSigningPublicKey"] = base64.StdEncoding.EncodeToString(der)
}
clientConfigJSON, _ := json.Marshal(clientConfig)
ps.clientConfig.Store(clientConfig)
ps.limitedClientConfig.Store(limitedClientConfig)
ps.clientConfigHash.Store(fmt.Sprintf("%x", sha256.Sum256(clientConfigJSON)))
}
// AsymmetricSigningKey will return a private key that can be used for asymmetric signing.
func (ps *PlatformService) AsymmetricSigningKey() *ecdsa.PrivateKey {
return ps.asymmetricSigningKey.Load()
}
// EnsureAsymmetricSigningKey ensures that an asymmetric signing key exists and future calls to
// AsymmetricSigningKey will always return a valid signing key.
func (ps *PlatformService) EnsureAsymmetricSigningKey() error {
if ps.AsymmetricSigningKey() != nil {
return nil
}
var key *model.SystemAsymmetricSigningKey
value, err := ps.Store.System().GetByName(model.SystemAsymmetricSigningKeyKey)
if err == nil {
if err := json.Unmarshal([]byte(value.Value), &key); err != nil {
return err
}
}
// If we don't already have a key, try to generate one.
if key == nil {
newECDSAKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return err
}
newKey := &model.SystemAsymmetricSigningKey{
ECDSAKey: &model.SystemECDSAKey{
Curve: "P-256",
X: newECDSAKey.X,
Y: newECDSAKey.Y,
D: newECDSAKey.D,
},
}
system := &model.System{
Name: model.SystemAsymmetricSigningKeyKey,
}
v, err := json.Marshal(newKey)
if err != nil {
return err
}
system.Value = string(v)
// If we were able to save the key, use it, otherwise log the error.
if err = ps.Store.System().Save(system); err != nil {
mlog.Warn("Failed to save AsymmetricSigningKey", mlog.Err(err))
} else {
key = newKey
}
}
// If we weren't able to save a new key above, another server must have beat us to it. Get the
// key from the database, and if that fails, error out.
if key == nil {
value, err := ps.Store.System().GetByName(model.SystemAsymmetricSigningKeyKey)
if err != nil {
return err
}
if err := json.Unmarshal([]byte(value.Value), &key); err != nil {
return err
}
}
var curve elliptic.Curve
switch key.ECDSAKey.Curve {
case "P-256":
curve = elliptic.P256()
default:
return fmt.Errorf("unknown curve: %s", key.ECDSAKey.Curve)
}
ps.asymmetricSigningKey.Store(&ecdsa.PrivateKey{
PublicKey: ecdsa.PublicKey{
Curve: curve,
X: key.ECDSAKey.X,
Y: key.ECDSAKey.Y,
},
D: key.ECDSAKey.D,
})
ps.regenerateClientConfig()
return nil
}
// LimitedClientConfigWithComputed gets the configuration in a format suitable for sending to the client.
func (ps *PlatformService) LimitedClientConfigWithComputed() map[string]string {
respCfg := maps.Clone(ps.LimitedClientConfig())
// These properties are not configurable, but nevertheless represent configuration expected
// by the client.
respCfg["NoAccounts"] = strconv.FormatBool(ps.IsFirstUserAccount())
return respCfg
}
// ClientConfigWithComputed gets the configuration in a format suitable for sending to the client.
func (ps *PlatformService) ClientConfigWithComputed() map[string]string {
respCfg := maps.Clone(ps.ClientConfig())
// These properties are not configurable, but nevertheless represent configuration expected
// by the client.
respCfg["NoAccounts"] = strconv.FormatBool(ps.IsFirstUserAccount())
respCfg["MaxPostSize"] = strconv.Itoa(ps.MaxPostSize())
respCfg["UpgradedFromTE"] = strconv.FormatBool(ps.isUpgradedFromTE())
respCfg["InstallationDate"] = ""
if installationDate, err := ps.GetSystemInstallDate(); err == nil {
respCfg["InstallationDate"] = strconv.FormatInt(installationDate, 10)
}
if ver, err := ps.Store.GetDBSchemaVersion(); err != nil {
mlog.Error("Could not get the schema version", mlog.Err(err))
} else {
respCfg["SchemaVersion"] = strconv.Itoa(ver)
}
return respCfg
}
func (ps *PlatformService) LimitedClientConfig() map[string]string {
return ps.limitedClientConfig.Load().(map[string]string)
}
func (ps *PlatformService) IsFirstUserAccount() bool {
if !ps.isFirstUserAccount.Load() {
return false
}
ps.isFirstUserAccountLock.Lock()
defer ps.isFirstUserAccountLock.Unlock()
// Retry under lock as another call might have already succeeded.
if !ps.isFirstUserAccount.Load() {
return false
}
ps.logger.Debug("Fetching user count for first user account check")
count, err := ps.Store.User().Count(model.UserCountOptions{IncludeDeleted: true})
if err != nil {
return false
}
// Avoid calling the user count query in future if we get a count > 0
if count > 0 {
ps.isFirstUserAccount.Store(false)
return false
}
return true
}
func (ps *PlatformService) MaxPostSize() int {
return ps.Store.Post().GetMaxPostSize()
}
func (ps *PlatformService) isUpgradedFromTE() bool {
val, err := ps.Store.System().GetByName(model.SystemUpgradedFromTeId)
if err != nil {
return false
}
return val.Value == "true"
}
func (ps *PlatformService) GetSystemInstallDate() (int64, *model.AppError) {
systemData, err := ps.Store.System().GetByName(model.SystemInstallationDateKey)
if err != nil {
return 0, model.NewAppError("getSystemInstallDate", "app.system.get_by_name.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
value, err := strconv.ParseInt(systemData.Value, 10, 64)
if err != nil {
return 0, model.NewAppError("getSystemInstallDate", "app.system_install_date.parse_int.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return value, nil
}
func (ps *PlatformService) ClientConfig() map[string]string {
return ps.clientConfig.Load().(map[string]string)
}

View File

@@ -0,0 +1,193 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"errors"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
smocks "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
)
func TestConfigListener(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
originalSiteName := th.Service.Config().TeamSettings.SiteName
listenerCalled := false
listener := func(oldConfig *model.Config, newConfig *model.Config) {
assert.False(t, listenerCalled, "listener called twice")
assert.Equal(t, *originalSiteName, *oldConfig.TeamSettings.SiteName, "old config contains incorrect site name")
assert.Equal(t, "test123", *newConfig.TeamSettings.SiteName, "new config contains incorrect site name")
listenerCalled = true
}
listenerId := th.Service.AddConfigListener(listener)
defer th.Service.RemoveConfigListener(listenerId)
listener2Called := false
listener2 := func(oldConfig *model.Config, newConfig *model.Config) {
assert.False(t, listener2Called, "listener2 called twice")
listener2Called = true
}
listener2Id := th.Service.AddConfigListener(listener2)
defer th.Service.RemoveConfigListener(listener2Id)
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.TeamSettings.SiteName = "test123"
})
assert.True(t, listenerCalled, "listener should've been called")
assert.True(t, listener2Called, "listener 2 should've been called")
}
func TestConfigSave(t *testing.T) {
mainHelper.Parallel(t)
cm := &mocks.ClusterInterface{}
cm.On("SendClusterMessage", mock.AnythingOfType("*model.ClusterMessage")).Return(nil)
cm.On("Shutdown").Return()
th := SetupWithCluster(t, cm)
t.Run("trigger a config changed event for the cluster", func(t *testing.T) {
oldCfg := th.Service.Config()
newCfg := oldCfg.Clone()
newCfg.ServiceSettings.SiteURL = new("http://newhost.me")
sanitizedOldCfg := th.Service.configStore.RemoveEnvironmentOverrides(oldCfg)
sanitizedNewCfg := th.Service.configStore.RemoveEnvironmentOverrides(newCfg)
cm.On("ConfigChanged", sanitizedOldCfg, sanitizedNewCfg, true).Return(nil)
_, _, appErr := th.Service.SaveConfig(newCfg, true)
require.Nil(t, appErr)
updatedCfg := th.Service.Config()
assert.Equal(t, "http://newhost.me", *updatedCfg.ServiceSettings.SiteURL)
})
t.Run("do not restart the metrics server on a different type of config change", func(t *testing.T) {
th := Setup(t, StartMetrics())
metricsMock := &mocks.MetricsInterface{}
metricsMock.On("IncrementWebsocketEvent", model.WebsocketEventConfigChanged).Return()
metricsMock.On("IncrementWebSocketBroadcastBufferSize", mock.AnythingOfType("string"), mock.AnythingOfType("float64")).Return()
metricsMock.On("DecrementWebSocketBroadcastBufferSize", mock.AnythingOfType("string"), mock.AnythingOfType("float64")).Return()
metricsMock.On("Register").Return()
th.Service.metricsIFace = metricsMock
// Change a random config setting
cfg := th.Service.Config().Clone()
cfg.ThemeSettings.EnableThemeSelection = new(!*cfg.ThemeSettings.EnableThemeSelection)
_, _, appErr := th.Service.SaveConfig(cfg, false)
require.Nil(t, appErr)
metricsMock.AssertNumberOfCalls(t, "Register", 0)
// Disable metrics
cfg.MetricsSettings.Enable = new(false)
_, _, appErr = th.Service.SaveConfig(cfg, false)
require.Nil(t, appErr)
// Change the metrics setting
cfg.MetricsSettings.Enable = new(true)
_, _, appErr = th.Service.SaveConfig(cfg, false)
require.Nil(t, appErr)
metricsMock.AssertNumberOfCalls(t, "Register", 1)
})
}
func TestIsFirstUserAccount(t *testing.T) {
mainHelper.Parallel(t)
th := SetupWithStoreMock(t)
storeMock := th.Service.Store.(*smocks.Store)
userStoreMock := &smocks.UserStore{}
storeMock.On("User").Return(userStoreMock)
type test struct {
name string
count int64
err error
result bool
shouldCallStore bool
}
tests := []test{
{"failed request", 0, errors.New("error"), false, true},
{"success negative users", -100, nil, true, true},
{"success no users", 0, nil, true, true},
{"success one user", 1, nil, false, true},
{"success multiple users - no store call", 42, nil, false, false},
}
// create a session, this should not affect IsFirstUserAccount
err := th.Service.sessionCache.SetWithDefaultExpiry("mock_session", 1)
require.NoError(t, err)
for _, te := range tests {
t.Run(te.name, func(t *testing.T) {
*userStoreMock = smocks.UserStore{}
if te.shouldCallStore {
userStoreMock.On("Count", model.UserCountOptions{IncludeDeleted: true}).Return(te.count, te.err).Once()
} else {
userStoreMock.On("Count", model.UserCountOptions{IncludeDeleted: true}).Unset()
}
require.Equal(t, te.result, th.Service.IsFirstUserAccount())
})
}
}
func TestIsFirstUserAccountThunderingHerd(t *testing.T) {
mainHelper.Parallel(t)
th := SetupWithStoreMock(t)
storeMock := th.Service.Store.(*smocks.Store)
userStoreMock := &smocks.UserStore{}
storeMock.On("User").Return(userStoreMock)
tests := []struct {
name string
count int64
err error
concurrentRequest int
result bool
numberOfStoreCalls int
}{
{"failed request", 0, errors.New("error"), 10, false, 10},
{"success negative users", -100, nil, 10, true, 10},
{"success no users", 0, nil, 10, true, 10},
{"success one user - lot of requests", 1, nil, 1000, false, 1},
{"success multiple users - no store call", 42, nil, 10, false, 0},
}
for _, te := range tests {
t.Run(te.name, func(t *testing.T) {
*userStoreMock = smocks.UserStore{}
if te.numberOfStoreCalls != 0 {
userStoreMock.On("Count", model.UserCountOptions{IncludeDeleted: true}).Return(te.count, te.err).Times(te.numberOfStoreCalls)
} else {
userStoreMock.On("Count", model.UserCountOptions{IncludeDeleted: true}).Unset()
}
defer userStoreMock.AssertExpectations(t)
var wg sync.WaitGroup
for range te.concurrentRequest {
wg.Go(func() {
require.Equal(t, te.result, th.Service.IsFirstUserAccount())
})
}
wg.Wait()
})
}
}

View File

@@ -0,0 +1,89 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build linux
package platform
import (
"errors"
"os"
"strconv"
"strings"
)
// cgroupPaths holds the file paths used to read cgroup v2 resource limits.
// These are package-level variables so they can be overridden in tests.
var cgroupPaths = struct {
v2MemoryMax string
v2CPUMax string
}{
v2MemoryMax: "/sys/fs/cgroup/memory.max",
v2CPUMax: "/sys/fs/cgroup/cpu.max",
}
// ContainerLimits holds effective CPU and memory limits for the current container.
// Zero values indicate no limit is set (fields will be omitted from YAML output).
type ContainerLimits struct {
CPULimit float64
MemoryLimitMB uint64
}
// getContainerLimits reads container resource limits from cgroups v2.
// Returns zero values when no limits are detected (bare metal, unlimited, or non-v2 system).
func getContainerLimits() (ContainerLimits, error) {
var limits ContainerLimits
// Memory limit: "max" means unlimited, otherwise bytes
memStr, err := readTrimmed(cgroupPaths.v2MemoryMax)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return limits, nil
}
return limits, err
}
if memStr != "max" {
var memBytes uint64
memBytes, err = strconv.ParseUint(memStr, 10, 64)
if err != nil {
return limits, err
}
limits.MemoryLimitMB = (memBytes + 1024*1024 - 1) / (1024 * 1024)
}
// CPU limit: format is "quota period"; quota is "max" when unlimited
cpuStr, err := readTrimmed(cgroupPaths.v2CPUMax)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return limits, nil
}
return limits, err
}
parts := strings.Fields(cpuStr)
if len(parts) != 2 {
return limits, errors.New("unexpected format in cpu.max")
}
if parts[0] != "max" {
quota, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return limits, err
}
period, err := strconv.ParseFloat(parts[1], 64)
if err != nil {
return limits, err
}
if period > 0 {
limits.CPULimit = quota / period
}
}
return limits, nil
}
func readTrimmed(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return strings.TrimSpace(string(data)), nil
}

View File

@@ -0,0 +1,87 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build linux
package platform
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func writeTempCgroupFile(t *testing.T, dir, name, content string) string {
t.Helper()
path := filepath.Join(dir, name)
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
return path
}
func TestGetContainerLimits(t *testing.T) {
dir := t.TempDir()
originalPaths := cgroupPaths
t.Cleanup(func() { cgroupPaths = originalPaths })
t.Run("limited CPU and memory", func(t *testing.T) {
cgroupPaths.v2MemoryMax = writeTempCgroupFile(t, dir, "mem_limited", "536870912\n") // 512 MB
cgroupPaths.v2CPUMax = writeTempCgroupFile(t, dir, "cpu_limited", "200000 100000\n") // 2 CPUs
limits, err := getContainerLimits()
require.NoError(t, err)
assert.Equal(t, uint64(512), limits.MemoryLimitMB)
assert.InDelta(t, 2.0, limits.CPULimit, 0.001)
})
t.Run("unlimited CPU and memory", func(t *testing.T) {
cgroupPaths.v2MemoryMax = writeTempCgroupFile(t, dir, "mem_unlimited", "max\n")
cgroupPaths.v2CPUMax = writeTempCgroupFile(t, dir, "cpu_unlimited", "max 100000\n")
limits, err := getContainerLimits()
require.NoError(t, err)
assert.Equal(t, uint64(0), limits.MemoryLimitMB)
assert.Equal(t, float64(0), limits.CPULimit)
})
t.Run("fractional CPU limit", func(t *testing.T) {
cgroupPaths.v2MemoryMax = writeTempCgroupFile(t, dir, "mem_frac", "max\n")
cgroupPaths.v2CPUMax = writeTempCgroupFile(t, dir, "cpu_frac", "50000 100000\n") // 0.5 CPUs
limits, err := getContainerLimits()
require.NoError(t, err)
assert.InDelta(t, 0.5, limits.CPULimit, 0.001)
})
t.Run("sub-MB memory rounds up to 1MB", func(t *testing.T) {
cgroupPaths.v2MemoryMax = writeTempCgroupFile(t, dir, "mem_tiny", "524288\n") // 0.5 MB
cgroupPaths.v2CPUMax = writeTempCgroupFile(t, dir, "cpu_tiny", "max 100000\n")
limits, err := getContainerLimits()
require.NoError(t, err)
assert.Equal(t, uint64(1), limits.MemoryLimitMB)
})
t.Run("missing cgroup files returns zero values", func(t *testing.T) {
cgroupPaths.v2MemoryMax = filepath.Join(dir, "nonexistent_memory")
cgroupPaths.v2CPUMax = writeTempCgroupFile(t, dir, "cpu_present", "max 100000\n")
limits, err := getContainerLimits()
require.NoError(t, err)
assert.Equal(t, uint64(0), limits.MemoryLimitMB)
assert.Equal(t, float64(0), limits.CPULimit)
})
t.Run("missing cpu.max returns zero values", func(t *testing.T) {
cgroupPaths.v2MemoryMax = writeTempCgroupFile(t, dir, "mem_no_cpu", "max\n")
cgroupPaths.v2CPUMax = filepath.Join(dir, "nonexistent_cpu")
limits, err := getContainerLimits()
require.NoError(t, err)
assert.Equal(t, uint64(0), limits.MemoryLimitMB)
assert.Equal(t, float64(0), limits.CPULimit)
})
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build !linux
package platform
// ContainerLimits holds effective CPU and memory limits for the current container.
// Zero values indicate no limit is set (fields will be omitted from YAML output).
type ContainerLimits struct {
CPULimit float64
MemoryLimitMB uint64
}
// getContainerLimits returns zero values on non-Linux platforms.
// Container limit detection via cgroups is only supported on Linux.
func getContainerLimits() (ContainerLimits, error) {
return ContainerLimits{}, nil
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build darwin
package platform
import (
"errors"
"time"
"golang.org/x/sys/unix"
)
type diskInfo struct {
TotalMB uint64
AvailableMB uint64
FilesystemType string
}
var errDiskInfoTimeout = errors.New("timed out getting disk space info")
func getDiskInfo(path string) (diskInfo, error) {
type result struct {
info diskInfo
err error
}
ch := make(chan result, 1)
go func() {
var stat unix.Statfs_t
if err := unix.Statfs(path, &stat); err != nil {
ch <- result{err: err}
return
}
bsize := uint64(stat.Bsize)
fsType := unix.ByteSliceToString(stat.Fstypename[:])
ch <- result{info: diskInfo{
TotalMB: stat.Blocks * bsize / (1024 * 1024),
AvailableMB: stat.Bavail * bsize / (1024 * 1024),
FilesystemType: fsType,
}}
}()
select {
case r := <-ch:
return r.info, r.err
case <-time.After(5 * time.Second):
return diskInfo{}, errDiskInfoTimeout
}
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build linux
package platform
import (
"errors"
"syscall"
"time"
"golang.org/x/sys/unix"
)
type diskInfo struct {
TotalMB uint64
AvailableMB uint64
FilesystemType string
}
var errDiskInfoTimeout = errors.New("timed out getting disk space info")
func getDiskInfo(path string) (diskInfo, error) {
type result struct {
info diskInfo
err error
}
ch := make(chan result, 1)
go func() {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
ch <- result{err: err}
return
}
bsize := uint64(stat.Bsize)
ch <- result{info: diskInfo{
TotalMB: stat.Blocks * bsize / (1024 * 1024),
AvailableMB: stat.Bavail * bsize / (1024 * 1024),
FilesystemType: fsTypeToString(stat.Type),
}}
}()
select {
case r := <-ch:
return r.info, r.err
case <-time.After(5 * time.Second):
return diskInfo{}, errDiskInfoTimeout
}
}
func fsTypeToString(fsType int64) string {
switch fsType {
case unix.EXT4_SUPER_MAGIC:
return "ext4"
case unix.XFS_SUPER_MAGIC:
return "xfs"
case unix.NFS_SUPER_MAGIC:
return "nfs"
case unix.BTRFS_SUPER_MAGIC:
return "btrfs"
case unix.TMPFS_MAGIC:
return "tmpfs"
case unix.SMB_SUPER_MAGIC:
return "smb"
case unix.FUSE_SUPER_MAGIC:
return "fuse"
case unix.OVERLAYFS_SUPER_MAGIC:
return "overlay"
default:
return "unknown"
}
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build !linux && !darwin
package platform
import "errors"
// ErrDiskSpaceUnsupportedPlatform is returned when disk space detection is not supported on the current platform.
var ErrDiskSpaceUnsupportedPlatform = errors.New("disk space detection not supported on this platform")
type diskInfo struct {
TotalMB uint64
AvailableMB uint64
FilesystemType string
}
func getDiskInfo(_ string) (diskInfo, error) {
return diskInfo{}, ErrDiskSpaceUnsupportedPlatform
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"github.com/mattermost/mattermost/server/v8/einterfaces"
"github.com/mattermost/mattermost/server/v8/platform/services/searchengine"
)
var clusterInterface func(*PlatformService) einterfaces.ClusterInterface
func RegisterClusterInterface(f func(*PlatformService) einterfaces.ClusterInterface) {
clusterInterface = f
}
var elasticsearchInterface func(*PlatformService) searchengine.SearchEngineInterface
func RegisterElasticsearchInterface(f func(*PlatformService) searchengine.SearchEngineInterface) {
elasticsearchInterface = f
}
var ldapDiagnosticInterface func(*PlatformService) einterfaces.LdapDiagnosticInterface
func RegisterLdapDiagnosticInterface(f func(*PlatformService) einterfaces.LdapDiagnosticInterface) {
ldapDiagnosticInterface = f
}
var samlDiagnosticInterface func(*PlatformService) einterfaces.SamlDiagnosticInterface
func RegisterSamlDiagnosticInterface(f func(*PlatformService) einterfaces.SamlDiagnosticInterface) {
samlDiagnosticInterface = f
}
var licenseInterface func(*PlatformService) einterfaces.LicenseInterface
func RegisterLicenseInterface(f func(*PlatformService) einterfaces.LicenseInterface) {
licenseInterface = f
}
var metricsInterfaceFn func(*PlatformService, string, string) einterfaces.MetricsInterface
func RegisterMetricsInterface(f func(*PlatformService, string, string) einterfaces.MetricsInterface) {
metricsInterfaceFn = f
}
var accessControlServiceInterface func(*PlatformService) einterfaces.AccessControlServiceInterface
func RegisterAccessControlServiceInterface(f func(*PlatformService) einterfaces.AccessControlServiceInterface) {
accessControlServiceInterface = f
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import "errors"
var (
AcceptedDomainError = errors.New("the email provided does not belong to an accepted domain")
VerifyUserError = errors.New("could not update verify email field")
UserCountError = errors.New("could not get the total number of the users.")
UserCreationDisabledError = errors.New("user creation is not allowed")
UserStoreIsEmptyError = errors.New("could not check if the user store is empty")
GetTokenError = errors.New("could not get token")
GetSessionError = errors.New("could not get session")
DeleteTokenError = errors.New("could not delete token")
DeleteSessionError = errors.New("could not delete session")
DeleteAllAccessDataError = errors.New("could not delete all access data")
DefaultFontError = errors.New("could not get default font")
UserInitialsError = errors.New("could not get user initials")
ImageEncodingError = errors.New("could not encode image")
)

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build darwin
package platform
import (
"os"
"syscall"
)
// getOpenFileDescriptors returns the number of open file descriptors for the current process.
func getOpenFileDescriptors() (int64, error) {
entries, err := os.ReadDir("/dev/fd")
if err != nil {
return -1, err
}
// Subtract 1 because the ReadDir call itself opens a file descriptor that appears in the listing.
return max(int64(len(entries))-1, 0), nil
}
// getMaxFileDescriptors returns the soft file descriptor limit for the current process.
func getMaxFileDescriptors() (int64, error) {
var rlimit syscall.Rlimit
if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rlimit); err != nil {
return -1, err
}
return int64(rlimit.Cur), nil
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build linux
package platform
import (
"fmt"
"math"
"os"
"syscall"
)
// getOpenFileDescriptors returns the number of open file descriptors for the current process.
func getOpenFileDescriptors() (int64, error) {
entries, err := os.ReadDir("/proc/self/fd")
if err != nil {
return -1, err
}
// Subtract 1 because the ReadDir call itself opens a file descriptor that appears in the listing.
return max(int64(len(entries))-1, 0), nil
}
// getMaxFileDescriptors returns the soft file descriptor limit for the current process.
func getMaxFileDescriptors() (int64, error) {
var rlimit syscall.Rlimit
if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rlimit); err != nil {
return -1, err
}
if rlimit.Cur > math.MaxInt64 {
return -1, fmt.Errorf("rlimit.Cur %d overflows int64", rlimit.Cur)
}
return int64(rlimit.Cur), nil
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build !linux && !darwin
package platform
// getOpenFileDescriptors returns -1 on unsupported platforms.
func getOpenFileDescriptors() (int64, error) {
return -1, nil
}
// getMaxFileDescriptors returns -1 on unsupported platforms.
func getMaxFileDescriptors() (int64, error) {
return -1, nil
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetOpenFileDescriptors(t *testing.T) {
count, err := getOpenFileDescriptors()
require.NoError(t, err)
if count == -1 {
return
}
assert.Positive(t, count)
}
func TestGetMaxFileDescriptors(t *testing.T) {
maxFDs, err := getMaxFileDescriptors()
require.NoError(t, err)
// -1 means unsupported platform; otherwise should be positive
assert.True(t, maxFDs == -1 || maxFDs > 0, "maxFDs should be -1 (unsupported) or positive, got %d", maxFDs)
}

View File

@@ -0,0 +1,121 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"encoding/json"
"os"
"time"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/app/featureflag"
)
// SetupFeatureFlags called on startup and when the cluster leader changes.
// Starts or stops the synchronization of feature flags from upstream management.
func (ps *PlatformService) SetupFeatureFlags() {
ps.featureFlagSynchronizerMutex.Lock()
defer ps.featureFlagSynchronizerMutex.Unlock()
splitKey := *ps.Config().ServiceSettings.SplitKey
splitConfigured := splitKey != ""
syncFeatureFlags := splitConfigured && ps.IsLeader()
ps.configStore.SetReadOnlyFF(!splitConfigured)
if syncFeatureFlags {
if err := ps.startFeatureFlagUpdateJob(); err != nil {
ps.logger.Warn("Unable to setup synchronization with feature flag management. Will fallback to cache.", mlog.Err(err))
}
} else {
ps.StopFeatureFlagUpdateJob()
}
if err := ps.configStore.Load(); err != nil {
ps.logger.Warn("Unable to load config store after feature flag setup.", mlog.Err(err))
}
}
func (ps *PlatformService) updateFeatureFlagValuesFromManagement() {
newCfg := ps.configStore.GetNoEnv().Clone()
oldFlags := *newCfg.FeatureFlags
newFlags := ps.featureFlagSynchronizer.UpdateFeatureFlagValues(oldFlags)
oldFlagsBytes, _ := json.Marshal(oldFlags)
newFlagsBytes, _ := json.Marshal(newFlags)
ps.logger.Debug("Checking feature flags from management service", mlog.String("old_flags", oldFlagsBytes), mlog.String("new_flags", newFlagsBytes))
if oldFlags != newFlags {
ps.logger.Debug("Feature flag change detected, updating config")
*newCfg.FeatureFlags = newFlags
_, _, appErr := ps.SaveConfig(newCfg, true)
if appErr != nil {
ps.logger.Error("Failed to save config with updated feature flags", mlog.Err(appErr))
}
}
}
func (ps *PlatformService) startFeatureFlagUpdateJob() error {
// Can be run multiple times
if ps.featureFlagSynchronizer != nil {
return nil
}
var log *mlog.Logger
if *ps.Config().ServiceSettings.DebugSplit {
log = ps.logger
}
attributes := map[string]any{}
// if we are part of a cloud installation, add its installation and group id
if installationId := os.Getenv("MM_CLOUD_INSTALLATION_ID"); installationId != "" {
attributes["installation_id"] = installationId
}
if groupId := os.Getenv("MM_CLOUD_GROUP_ID"); groupId != "" {
attributes["group_id"] = groupId
}
synchronizer, err := featureflag.NewSynchronizer(featureflag.SyncParams{
ServerID: ps.telemetryId,
SplitKey: *ps.Config().ServiceSettings.SplitKey,
Log: log,
Attributes: attributes,
})
if err != nil {
return err
}
ps.featureFlagStop = make(chan struct{})
ps.featureFlagStopped = make(chan struct{})
ps.featureFlagSynchronizer = synchronizer
syncInterval := *ps.Config().ServiceSettings.FeatureFlagSyncIntervalSeconds
go func() {
ticker := time.NewTicker(time.Duration(syncInterval) * time.Second)
defer ticker.Stop()
defer close(ps.featureFlagStopped)
if err := synchronizer.EnsureReady(); err != nil {
ps.logger.Warn("Problem connecting to feature flag management. Will fallback to cloud cache.", mlog.Err(err))
return
}
ps.updateFeatureFlagValuesFromManagement()
for {
select {
case <-ps.featureFlagStop:
return
case <-ticker.C:
ps.updateFeatureFlagValuesFromManagement()
}
}
}()
return nil
}
func (ps *PlatformService) StopFeatureFlagUpdateJob() {
if ps.featureFlagSynchronizer != nil {
close(ps.featureFlagStop)
<-ps.featureFlagStopped
ps.featureFlagSynchronizer.Close()
ps.featureFlagSynchronizer = nil
}
}

View File

@@ -0,0 +1,112 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"context"
"runtime"
"sync/atomic"
)
// GoContext returns a context that is cancelled when the server begins shutting down. It is
// suitable as the parent for long-running background requests that must not outlive the server.
func (ps *PlatformService) GoContext() context.Context {
if ps.goroutineCtx == nil {
return context.Background()
}
return ps.goroutineCtx
}
// Go creates a goroutine, but maintains a record of it to ensure that execution completes before
// the server is shutdown.
func (ps *PlatformService) Go(f func()) {
atomic.AddInt32(&ps.goroutineCount, 1)
go func() {
f()
atomic.AddInt32(&ps.goroutineCount, -1)
select {
case ps.goroutineExitSignal <- struct{}{}:
default:
}
}()
}
// waitForGoroutines blocks until all goroutines created by PlatformService.Go() exit.
func (ps *PlatformService) waitForGoroutines() {
for atomic.LoadInt32(&ps.goroutineCount) != 0 {
<-ps.goroutineExitSignal
}
}
func (ps *PlatformService) GoBuffered(f func()) {
ps.goroutineBuffered <- struct{}{}
atomic.AddInt32(&ps.goroutineCount, 1)
go func() {
f()
atomic.AddInt32(&ps.goroutineCount, -1)
select {
case ps.goroutineExitSignal <- struct{}{}:
default:
}
<-ps.goroutineBuffered
}()
}
// startExtractionWorkers launches the fixed-size pool of workers that run
// document extraction tasks submitted through GoExtraction.
func (ps *PlatformService) startExtractionWorkers() {
numWorkers := runtime.NumCPU()
for range numWorkers {
ps.extractionWG.Go(func() {
for {
select {
case <-ps.extractionStop:
return
case f := <-ps.extractionQueue:
f()
}
}
})
}
}
// stopExtractionWorkers signals the extraction workers to exit and waits for
// any in-flight extraction to finish. Queued-but-not-started tasks are drained
// and discarded so a worker cannot dequeue and run them after shutdown has been
// signaled.
func (ps *PlatformService) stopExtractionWorkers() {
close(ps.extractionStop)
drain:
for {
select {
case <-ps.extractionQueue:
default:
break drain
}
}
ps.extractionWG.Wait()
}
// GoExtraction submits f to the bounded document extraction worker pool. It
// never blocks the caller: if every worker is busy and the queue is full it
// returns false without running f. Skipped files stay unextracted until the
// scheduled ExtractContent catch-up job or an admin runs a content extraction job
// (e.g. mmctl extract). This keeps expensive extractions from stalling the
// from stalling the request goroutines that dispatch them.
func (ps *PlatformService) GoExtraction(f func()) bool {
select {
case ps.extractionQueue <- f:
return true
default:
return false
}
}

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestGoExtraction(t *testing.T) {
mainHelper.Parallel(t)
t.Run("runs submitted work on the pool", func(t *testing.T) {
const tasks = 5
ps := &PlatformService{
extractionQueue: make(chan func(), tasks),
extractionStop: make(chan struct{}),
}
ps.startExtractionWorkers()
defer ps.stopExtractionWorkers()
var wg sync.WaitGroup
wg.Add(tasks)
for range tasks {
require.True(t, ps.GoExtraction(func() {
wg.Done()
}))
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
require.Fail(t, "submitted extraction tasks did not run")
}
})
t.Run("never blocks and skips work once the queue is saturated", func(t *testing.T) {
// No workers are started, so nothing drains the queue.
ps := &PlatformService{
extractionQueue: make(chan func(), 2),
extractionStop: make(chan struct{}),
}
require.True(t, ps.GoExtraction(func() {}))
require.True(t, ps.GoExtraction(func() {}))
// The queue is now full; further submissions must be rejected rather
// than block the caller.
require.False(t, ps.GoExtraction(func() {}))
})
t.Run("stop waits for in-flight extraction to finish", func(t *testing.T) {
ps := &PlatformService{
extractionQueue: make(chan func(), 1),
extractionStop: make(chan struct{}),
}
ps.startExtractionWorkers()
var finished bool
started := make(chan struct{})
require.True(t, ps.GoExtraction(func() {
close(started)
time.Sleep(100 * time.Millisecond)
finished = true
}))
<-started
ps.stopExtractionWorkers()
require.True(t, finished, "stopExtractionWorkers should wait for the running task to complete")
})
}

View File

@@ -0,0 +1,325 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"os"
"path/filepath"
"sync"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
"github.com/mattermost/mattermost/server/v8/config"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
type TestHelper struct {
Context request.CTX
Service *PlatformService
Suite SuiteIFace
Store store.Store
BasicTeam *model.Team
BasicUser *model.User
BasicUser2 *model.User
BasicChannel *model.Channel
// BasicPost *model.Post
SystemAdminUser *model.User
runShutdown sync.Once
tempWorkspace string
}
type mockSuite struct{}
func (ms *mockSuite) SetStatusLastActivityAt(userID string, activityAt int64) {}
func (ms *mockSuite) SetStatusOffline(userID string, manual bool, force bool) {}
func (ms *mockSuite) IsUserAway(lastActivityAt int64) bool { return false }
func (ms *mockSuite) SetStatusOnline(userID string, manual bool) {}
func (ms *mockSuite) UpdateLastActivityAtIfNeeded(session model.Session) {}
func (ms *mockSuite) SetStatusAwayIfNeeded(userID string, manual bool) {}
func (ms *mockSuite) GetSession(token string) (*model.Session, *model.AppError) {
return &model.Session{}, nil
}
func (ms *mockSuite) RolesGrantPermission(roleNames []string, permissionId string) bool { return true }
func (ms *mockSuite) UserCanSeeOtherUser(rctx request.CTX, userID string, otherUserId string) (bool, *model.AppError) {
return true, nil
}
func (ms *mockSuite) HasPermissionToReadChannel(rctx request.CTX, userID string, channel *model.Channel) (bool, bool) {
return true, true
}
func (ms *mockSuite) HasPermissionToResolveChannelMention(rctx request.CTX, userID string, channel *model.Channel) bool {
return true
}
func (ms *mockSuite) HasPermissionToFileAction(rctx request.CTX, userID string, roles string, channelID string, action string) bool {
return true
}
func (ms *mockSuite) MFARequired(rctx request.CTX, method string) *model.AppError {
return nil
}
func (ms *mockSuite) MakeAuditRecord(rctx request.CTX, event string, initialStatus string) *model.AuditRecord {
return &model.AuditRecord{
Status: initialStatus,
EventName: event,
}
}
func (ms *mockSuite) LogAuditRec(rctx request.CTX, auditRec *model.AuditRecord, err error) {
}
func setupDBStore(tb testing.TB) (store.Store, *model.SqlSettings) {
var dbStore store.Store
var dbSettings *model.SqlSettings
if mainHelper.Options.RunParallel {
dbStore, _, dbSettings, _ = mainHelper.GetNewStores(tb)
tb.Cleanup(func() {
dbStore.Close()
})
} else {
dbStore = mainHelper.GetStore()
dbStore.DropAllTables()
dbStore.MarkSystemRanUnitTests()
dbSettings = mainHelper.Settings
mainHelper.PreloadMigrations()
}
return dbStore, dbSettings
}
func Setup(tb testing.TB, options ...Option) *TestHelper {
if testing.Short() {
tb.SkipNow()
}
dbStore, dbSettings := setupDBStore(tb)
return setupTestHelper(dbStore, dbSettings, false, true, tb, options...)
}
func (th *TestHelper) InitBasic(tb testing.TB) *TestHelper {
th.SystemAdminUser = th.CreateAdmin(tb)
th.BasicUser = th.CreateUserOrGuest(tb, false)
th.BasicUser2 = th.CreateUserOrGuest(tb, false)
th.BasicTeam = th.CreateTeam(tb)
// th.LinkUserToTeam(t, th.BasicUser, th.BasicTeam)
// th.LinkUserToTeam(t, th.BasicUser2, th.BasicTeam)
th.BasicChannel = th.CreateChannel(tb, th.BasicTeam)
// th.BasicPost = th.CreatePost(t, th.BasicChannel)
return th
}
func SetupWithStoreMock(tb testing.TB, options ...Option) *TestHelper {
mockStore := testlib.GetMockStoreForSetupFunctions()
options = append(options, StoreOverride(mockStore))
th := setupTestHelper(mockStore, &model.SqlSettings{}, false, false, tb, options...)
return th
}
func SetupWithCluster(tb testing.TB, cluster einterfaces.ClusterInterface) *TestHelper {
if testing.Short() {
tb.SkipNow()
}
dbStore, dbSettings := setupDBStore(tb)
th := setupTestHelper(dbStore, dbSettings, true, true, tb)
th.Service.clusterIFace = cluster
return th
}
func setupTestHelper(dbStore store.Store, dbSettings *model.SqlSettings, enterprise bool, includeCacheLayer bool, tb testing.TB, options ...Option) *TestHelper {
tempWorkspace, err := os.MkdirTemp("", "apptest")
require.NoError(tb, err)
configStore := config.NewTestMemoryStore()
memoryConfig := configStore.Get()
memoryConfig.SqlSettings = *dbSettings
// Disable connection pool cleanup goroutines to prevent DATA RACE with
// testify mock argument diffing under the race detector. The sql.DB
// connectionCleaner goroutine writes to internal fields while testify's
// mock.Called() → Arguments.Diff() → fmt.Sprintf reads them via reflect.
// Setting lifetime/idle to 0 prevents the cleaner from starting.
memoryConfig.SqlSettings.ConnMaxLifetimeMilliseconds = new(0)
memoryConfig.SqlSettings.ConnMaxIdleTimeMilliseconds = new(0)
*memoryConfig.PluginSettings.Directory = filepath.Join(tempWorkspace, "plugins")
*memoryConfig.PluginSettings.ClientDirectory = filepath.Join(tempWorkspace, "webapp")
*memoryConfig.PluginSettings.AutomaticPrepackagedPlugins = false
*memoryConfig.LogSettings.EnableSentry = false // disable error reporting during tests
*memoryConfig.AnnouncementSettings.AdminNoticesEnabled = false
*memoryConfig.AnnouncementSettings.UserNoticesEnabled = false
*memoryConfig.MetricsSettings.Enable = true
*memoryConfig.ServiceSettings.ListenAddress = "localhost:0"
*memoryConfig.MetricsSettings.ListenAddress = "localhost:0"
_, _, err = configStore.Set(memoryConfig)
require.NoError(tb, err)
options = append(options, ConfigStore(configStore))
ps, err := New(
ServiceConfig{
Store: dbStore,
}, options...)
if err != nil {
require.NoError(tb, err)
}
th := &TestHelper{
Context: request.TestContext(tb),
Service: ps,
Suite: &mockSuite{},
Store: dbStore,
tempWorkspace: tempWorkspace,
}
if _, ok := dbStore.(*mocks.Store); ok {
statusMock := mocks.StatusStore{}
statusMock.On("UpdateExpiredDNDStatuses").Return([]*model.Status{}, nil)
statusMock.On("Get", "user1").Return(&model.Status{UserId: "user1", Status: model.StatusOnline}, nil)
statusMock.On("UpdateLastActivityAt", "user1", mock.Anything).Return(nil)
statusMock.On("SaveOrUpdate", mock.AnythingOfType("*model.Status")).Return(nil)
emptyMockStore := mocks.Store{}
emptyMockStore.On("Close").Return(nil)
emptyMockStore.On("Status").Return(&statusMock)
th.Service.Store = &emptyMockStore
}
// Share same configuration with app.TestHelper
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.TeamSettings.MaxUsersPerTeam = 50
*cfg.RateLimitSettings.Enable = false
*cfg.TeamSettings.EnableOpenServer = true
})
if enterprise {
th.Service.SetLicense(model.NewTestLicense())
} else {
th.Service.SetLicense(nil)
}
err = th.Service.Start(nil)
require.NoError(tb, err)
tb.Cleanup(func() {
th.Shutdown(tb)
})
return th
}
// Shutdown may be called by tests to manually shut down the [TestHelper].
// If it's not called manually, it will get called automatically via [testing.TB.Cleanup].
func (th *TestHelper) Shutdown(tb testing.TB) {
th.runShutdown.Do(func() {
err := th.Service.ShutdownMetrics()
require.NoError(tb, err)
err = th.Service.Shutdown()
require.NoError(tb, err)
err = th.Service.ShutdownConfig()
require.NoError(tb, err)
if th.tempWorkspace != "" {
err := os.RemoveAll(th.tempWorkspace)
require.NoError(tb, err)
}
})
}
func (th *TestHelper) CreateTeam(tb testing.TB) *model.Team {
id := model.NewId()
team := &model.Team{
DisplayName: "dn_" + id,
Name: "name" + id,
Email: "success+" + id + "@simulator.amazonses.com",
Type: model.TeamOpen,
}
team, err := th.Service.Store.Team().Save(team)
require.NoError(tb, err)
return team
}
func (th *TestHelper) CreateUserOrGuest(tb testing.TB, guest bool) *model.User {
id := model.NewId()
user := &model.User{
Email: "success+" + id + "@simulator.amazonses.com",
Username: "un_" + id,
Nickname: "nn_" + id,
Password: model.NewTestPassword(),
EmailVerified: true,
Roles: model.SystemUserRoleId,
}
user, err := th.Service.Store.User().Save(th.Context, user)
require.NoError(tb, err)
return user
}
func (th *TestHelper) CreateAdmin(tb testing.TB) *model.User {
id := model.NewId()
user := &model.User{
Email: "success+" + id + "@simulator.amazonses.com",
Username: "un_" + id,
Nickname: "nn_" + id,
Password: model.NewTestPassword(),
EmailVerified: true,
Roles: model.SystemAdminRoleId + " " + model.SystemUserRoleId,
}
user, err := th.Service.Store.User().Save(th.Context, user)
require.NoError(tb, err)
return user
}
type ChannelOption func(*model.Channel)
func WithShared(v bool) ChannelOption {
return func(channel *model.Channel) {
channel.Shared = new(v)
}
}
func (th *TestHelper) CreateChannel(tb testing.TB, team *model.Team, options ...ChannelOption) *model.Channel {
id := model.NewId()
channel := &model.Channel{
TeamId: team.Id,
DisplayName: "dn_" + id,
Name: "name" + id,
Type: model.ChannelTypeOpen,
}
for _, option := range options {
option(channel)
}
channel, err := th.Service.Store.Channel().Save(th.Context, channel, 999)
require.NoError(tb, err)
return channel
}

View File

@@ -0,0 +1,444 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"github.com/golang-jwt/jwt/v5"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/jobs"
"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/einterfaces"
)
const (
LicenseEnv = "MM_LICENSE"
)
// JWTClaims custom JWT claims with the needed information for the
// renewal process
type JWTClaims struct {
LicenseID string `json:"license_id"`
ActiveUsers int64 `json:"active_users"`
jwt.RegisteredClaims
}
func (ps *PlatformService) LicenseManager() einterfaces.LicenseInterface {
return ps.licenseManager
}
func (ps *PlatformService) SetLicenseManager(impl einterfaces.LicenseInterface) {
ps.licenseManager = impl
}
func (ps *PlatformService) License() *model.License {
return ps.licenseValue.Load()
}
func (ps *PlatformService) LoadLicense() {
c := request.EmptyContext(ps.logger)
// ENV var overrides all other sources of license.
licenseStr := os.Getenv(LicenseEnv)
if licenseStr != "" {
license, appErr := utils.LicenseValidator.LicenseFromBytes([]byte(licenseStr))
if appErr != nil {
ps.logger.Error("Failed to read license set in environment.", mlog.Err(appErr))
return
}
// skip the restrictions if license is a sanctioned trial
if !license.IsSanctionedTrial() && license.IsTrialLicense() {
canStartTrialLicense, err := ps.licenseManager.CanStartTrial()
if err != nil {
ps.logger.Error("Failed to validate trial eligibility.", mlog.Err(err))
return
}
if !canStartTrialLicense {
ps.logger.Info("Cannot start trial multiple times.")
return
}
}
if err := ps.ValidateAndSetLicenseBytes([]byte(licenseStr)); err != nil {
ps.logger.Info("License key from ENV is invalid.", mlog.Err(err))
} else {
ps.logger.Info("License key from ENV is valid, unlocking enterprise features.")
}
return
}
licenseId := ""
// Read from master: SaveLicense writes the active license ID then calls
// LoadLicense, so a replica read can return a stale ID and revert the license.
activeLicense, nErr := ps.Store.System().GetByNameWithContext(sqlstore.RequestContextWithMaster(c), model.SystemActiveLicenseId)
if nErr == nil {
licenseId = activeLicense.Value
}
if !model.IsValidId(licenseId) {
// Lets attempt to load the file from disk since it was missing from the DB
license, licenseBytes, err := utils.GetAndValidateLicenseFileFromDisk(*ps.Config().ServiceSettings.LicenseFileLocation)
if err != nil {
ps.logger.Warn("Failed to get license from disk", mlog.Err(err))
} else {
if _, err := ps.SaveLicense(licenseBytes); err != nil {
ps.logger.Error("Failed to save license key loaded from disk.", mlog.Err(err))
} else {
licenseId = license.Id
}
}
}
record, nErr := ps.Store.License().Get(sqlstore.RequestContextWithMaster(c), licenseId)
if nErr != nil {
// LoadLicense reruns on a 24h timer, so clearing the license on a store
// failure would drop every entitlement, and deactivate add-on plugins,
// until the next successful read.
var nfErr *store.ErrNotFound
if !errors.As(nErr, &nfErr) {
ps.logger.Error("Failed to read the license from the database, keeping the current license.", mlog.Err(nErr))
return
}
if ps.Config().FeatureFlags.EnableMattermostEntry && model.BuildEnterpriseReady == "true" {
ps.logger.Info("Mattermost Entry is enabled. Unlocking enterprise features.")
if ps.LicenseManager() == nil {
ps.logger.Warn("License manager not available, setting license to nil.")
ps.SetLicense(nil)
return
}
ps.SetLicense(ps.LicenseManager().NewMattermostEntryLicense(ps.telemetryId))
} else {
ps.logger.Warn("License key from https://mattermost.com required to unlock enterprise features.", mlog.Err(nErr))
ps.SetLicense(nil)
}
return
}
err := ps.ValidateAndSetLicenseBytes([]byte(record.Bytes))
if err != nil {
ps.logger.Info("License key is invalid.")
}
ps.logger.Info("License key is valid, unlocking enterprise features.")
}
func (ps *PlatformService) SaveLicense(licenseBytes []byte) (*model.License, *model.AppError) {
licenseStr, err := utils.LicenseValidator.ValidateLicense(licenseBytes)
if err != nil {
return nil, utils.NewLicenseValidationAppError("addLicense", err)
}
var license model.License
if jsonErr := json.Unmarshal([]byte(licenseStr), &license); jsonErr != nil {
return nil, model.NewAppError("addLicense", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
}
if license.Features == nil {
return nil, model.NewAppError("addLicense", "api.license.add_license.invalid.app_error", nil, "", http.StatusBadRequest).Wrap(errors.New("license.Features is nil"))
}
if license.Features.Users == nil {
return nil, model.NewAppError("addLicense", "api.license.add_license.invalid.app_error", nil, "", http.StatusBadRequest).Wrap(errors.New("license.Features.Users is nil"))
}
uniqueUserCount, err := ps.Store.User().Count(model.UserCountOptions{})
if err != nil {
return nil, model.NewAppError("addLicense", "api.license.add_license.invalid_count.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
// Single-channel guests are free and should not count against licensed seats.
effectiveUserCount := uniqueUserCount
if scgCount, scgErr := ps.Store.User().AnalyticsGetSingleChannelGuestCount(); scgErr == nil {
effectiveUserCount = max(uniqueUserCount-scgCount, 0)
}
if effectiveUserCount > int64(*license.Features.Users) {
return nil, model.NewAppError("addLicense", "api.license.add_license.unique_users.app_error", map[string]any{"Users": *license.Features.Users, "Count": effectiveUserCount}, "", http.StatusBadRequest)
}
if license.IsExpired() {
return nil, model.NewAppError("addLicense", model.ExpiredLicenseError, nil, "", http.StatusBadRequest)
}
if *ps.Config().JobSettings.RunJobs && ps.Jobs != nil {
if err := ps.Jobs.StopWorkers(); err != nil && !errors.Is(err, jobs.ErrWorkersNotRunning) {
ps.logger.Warn("Stopping job server workers failed", mlog.Err(err))
}
}
if *ps.Config().JobSettings.RunScheduler && ps.Jobs != nil {
if err := ps.Jobs.StopSchedulers(); err != nil && !errors.Is(err, jobs.ErrSchedulersNotRunning) {
ps.logger.Error("Stopping job server schedulers failed", mlog.Err(err))
}
}
defer func() {
// restart job server workers - this handles the edge case where a license file is uploaded, but the job server
// doesn't start until the server is restarted, which prevents the 'run job now' buttons in system console from
// functioning as expected
if *ps.Config().JobSettings.RunJobs && ps.Jobs != nil {
if err := ps.Jobs.StartWorkers(); err != nil {
ps.logger.Error("Starting job server workers failed", mlog.Err(err))
}
}
if *ps.Config().JobSettings.RunScheduler && ps.Jobs != nil {
if err := ps.Jobs.StartSchedulers(); err != nil && !errors.Is(err, jobs.ErrSchedulersRunning) {
ps.logger.Error("Starting job server schedulers failed", mlog.Err(err))
}
}
}()
if ok := ps.SetLicense(&license); !ok {
return nil, model.NewAppError("addLicense", model.ExpiredLicenseError, nil, "", http.StatusBadRequest)
}
record := &model.LicenseRecord{}
record.Id = license.Id
record.Bytes = string(licenseBytes)
if err := ps.Store.License().Save(record); err != nil {
if appErr := ps.RemoveLicense(); appErr != nil {
ps.logger.Error("Failed to remove license after saving it to the license store failed", mlog.Err(appErr))
}
var appErr *model.AppError
switch {
case errors.As(err, &appErr):
return nil, appErr
default:
return nil, model.NewAppError("addLicense", "api.license.add_license.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
sysVar := &model.System{}
sysVar.Name = model.SystemActiveLicenseId
sysVar.Value = license.Id
if err := ps.Store.System().SaveOrUpdate(sysVar); err != nil {
appErr := ps.RemoveLicense()
if appErr != nil {
ps.logger.Error("Failed to remove license after saving it to the system store failed", mlog.Err(appErr))
}
return nil, model.NewAppError("addLicense", "api.license.add_license.save_active.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// only on prem licenses set this in the first place
if !license.IsCloud() {
_, err := ps.Store.System().PermanentDeleteByName(model.SystemHostedPurchaseNeedsScreening)
if err != nil {
ps.logger.Warn(fmt.Sprintf("Failed to remove %s system store key", model.SystemHostedPurchaseNeedsScreening))
}
}
if err := ps.ReloadConfig(); err != nil {
ps.logger.Warn("Failed to reload config after saving license", mlog.Err(err))
}
if appErr := ps.InvalidateAllCaches(); appErr != nil {
ps.logger.Warn("Failed to invalidate cache after saving license", mlog.Err(appErr))
}
return &license, nil
}
func (ps *PlatformService) SetLicense(license *model.License) bool {
oldLicense := ps.licenseValue.Load()
defer func() {
for _, listener := range ps.licenseListeners {
if oldLicense == nil {
listener(nil, license)
} else {
listener(oldLicense, license)
}
}
}()
if license != nil {
license.Features.SetDefaults()
ps.licenseValue.Store(license)
ps.clientLicenseValue.Store(utils.GetClientLicense(license))
if oldLicense == nil || oldLicense.Id != license.Id {
ps.logLicense("Set license", license)
}
return true
}
if oldLicense != nil {
ps.logLicense("Cleared license", oldLicense)
}
ps.licenseValue.Store((*model.License)(nil))
ps.clientLicenseValue.Store(map[string]string(nil))
return false
}
func (ps *PlatformService) ValidateAndSetLicenseBytes(b []byte) error {
licenseStr, err := utils.LicenseValidator.ValidateLicense(b)
if err != nil {
return errors.Wrap(err, "Failed to decode license from JSON")
}
var license model.License
if err := json.Unmarshal([]byte(licenseStr), &license); err != nil {
return errors.Wrap(err, "Failed to decode license from JSON")
}
ps.SetLicense(&license)
return nil
}
func (ps *PlatformService) SetClientLicense(m map[string]string) {
ps.clientLicenseValue.Store(m)
}
func (ps *PlatformService) ClientLicense() map[string]string {
if clientLicense, _ := ps.clientLicenseValue.Load().(map[string]string); clientLicense != nil {
return clientLicense
}
return map[string]string{"IsLicensed": "false"}
}
func (ps *PlatformService) RemoveLicense() *model.AppError {
if license := ps.licenseValue.Load(); license == nil {
return nil
}
ps.logger.Info("Remove license.", mlog.String("id", model.SystemActiveLicenseId))
sysVar := &model.System{}
sysVar.Name = model.SystemActiveLicenseId
sysVar.Value = ""
if err := ps.Store.System().SaveOrUpdate(sysVar); err != nil {
return model.NewAppError("RemoveLicense", "app.system.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
ps.SetLicense(nil)
if err := ps.ReloadConfig(); err != nil {
ps.logger.Warn("Failed to reload config after removing license", mlog.Err(err))
}
if appErr := ps.InvalidateAllCaches(); appErr != nil {
ps.logger.Warn("Failed to invalidate cache after removing license", mlog.Err(appErr))
}
return nil
}
func (ps *PlatformService) AddLicenseListener(listener func(oldLicense, newLicense *model.License)) string {
id := model.NewId()
ps.licenseListeners[id] = listener
return id
}
func (ps *PlatformService) RemoveLicenseListener(id string) {
delete(ps.licenseListeners, id)
}
func (ps *PlatformService) GetSanitizedClientLicense() map[string]string {
return utils.GetSanitizedClientLicense(ps.ClientLicense())
}
// RequestTrialLicense request a trial license from the mattermost official license server
func (ps *PlatformService) RequestTrialLicense(trialRequest *model.TrialLicenseRequest) *model.AppError {
trialRequestJSON, err := json.Marshal(trialRequest)
if err != nil {
return model.NewAppError("RequestTrialLicense", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
resp, err := http.Post(ps.getRequestTrialURL(), "application/json", bytes.NewBuffer(trialRequestJSON))
if err != nil {
return model.NewAppError("RequestTrialLicense", "api.license.request_trial_license.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
defer resp.Body.Close()
// CloudFlare sitting in front of the Customer Portal will block this request with a 451 response code in the event that the request originates from a country sanctioned by the U.S. Government.
if resp.StatusCode == http.StatusUnavailableForLegalReasons {
return model.NewAppError("RequestTrialLicense", "api.license.request_trial_license.embargoed", nil, "Request for trial license came from an embargoed country", http.StatusUnavailableForLegalReasons)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return model.NewAppError("RequestTrialLicense", "api.license.request_trial_license.app_error", nil,
fmt.Sprintf("Unexpected HTTP status code %q returned by server", resp.Status), http.StatusInternalServerError)
}
var licenseResponse map[string]string
err = json.NewDecoder(resp.Body).Decode(&licenseResponse)
if err != nil {
ps.logger.Warn("Error decoding license response", mlog.Err(err))
}
if _, ok := licenseResponse["license"]; !ok {
return model.NewAppError("RequestTrialLicense", "api.license.request_trial_license.app_error", nil, licenseResponse["message"], http.StatusBadRequest)
}
if _, err := ps.SaveLicense([]byte(licenseResponse["license"])); err != nil {
return err
}
if err := ps.ReloadConfig(); err != nil {
ps.logger.Warn("Failed to reload config after requesting trial license", mlog.Err(err))
}
if appErr := ps.InvalidateAllCaches(); appErr != nil {
ps.logger.Warn("Failed to invalidate cache after requesting trial license", mlog.Err(appErr))
}
return nil
}
func (ps *PlatformService) getRequestTrialURL() string {
return fmt.Sprintf("%s/api/v1/trials", *ps.Config().CloudSettings.CWSURL)
}
func (ps *PlatformService) logLicense(message string, license *model.License) {
if ps.logger == nil {
return
}
logger := ps.logger.With(
mlog.String("id", license.Id),
mlog.Time("issued_at", model.GetTimeForMillis(license.IssuedAt)),
mlog.Time("starts_at", model.GetTimeForMillis(license.StartsAt)),
mlog.Time("expires_at", model.GetTimeForMillis(license.ExpiresAt)),
mlog.String("sku_name", license.SkuName),
mlog.String("sku_short_name", license.SkuShortName),
mlog.Bool("is_trial", license.IsTrial),
mlog.Bool("is_gov_sku", license.IsGovSku),
mlog.Bool("is_non_production", license.IsNonProduction),
)
if license.Customer != nil {
logger = logger.With(mlog.String("customer_id", license.Customer.Id))
}
if license.Features != nil {
logger = logger.With(
mlog.Int("features_users", *license.Features.Users),
mlog.Map("features", license.Features.ToMap()),
)
}
if len(license.AddOns) > 0 {
logger = logger.With(mlog.Array("add_ons", license.AddOns))
}
logger.Info(message)
}

View File

@@ -0,0 +1,145 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/v8/channels/utils"
mocks2 "github.com/mattermost/mattermost/server/v8/channels/utils/mocks"
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
)
func TestLoadLicense(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
th.Service.LoadLicense()
require.Nil(t, th.Service.License(), "shouldn't have a valid license")
}
func TestSaveLicense(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
b1 := []byte("junk")
_, err := th.Service.SaveLicense(b1)
require.NotNil(t, err, "shouldn't have saved license")
}
func TestSaveLicenseWrongEnvironment(t *testing.T) {
// t.Setenv prevents t.Parallel — the service environment has no config equivalent.
th := Setup(t)
t.Setenv("MM_SERVICEENVIRONMENT", model.ServiceEnvironmentProduction)
// validTestLicense is signed with the test key, so uploading it to a server running
// in the production service environment must report an environment mismatch rather
// than the generic invalid-license error.
_, appErr := th.Service.SaveLicense(validTestLicense)
require.NotNil(t, appErr, "shouldn't have saved a license signed for another environment")
require.Equal(t, model.WrongEnvironmentTestLicenseError, appErr.Id)
require.Equal(t, http.StatusBadRequest, appErr.StatusCode)
}
func TestSaveEnterpriseAdvancedLicense(t *testing.T) {
th := Setup(t)
defer testutils.ResetLicenseValidator()
mockLicenseValidator := mocks2.LicenseValidatorIface{}
license := &model.License{
Id: model.NewId(),
Features: &model.Features{
Users: new(100),
},
Customer: &model.Customer{
Name: "TestName",
Email: "test@example.com",
},
SkuName: "SKU NAME",
SkuShortName: model.LicenseShortSkuEnterpriseAdvanced,
StartsAt: model.GetMillis() - 1000,
ExpiresAt: model.GetMillis() + 100000,
}
mockLicenseValidator.On("LicenseFromBytes", mock.Anything).Return(license, nil).Once()
licenseBytes, err := json.Marshal(license)
require.NoError(t, err)
mockLicenseValidator.On("ValidateLicense", mock.Anything).Return(string(licenseBytes), nil)
utils.LicenseValidator = &mockLicenseValidator
_, appErr := th.Service.SaveLicense(licenseBytes)
require.Nil(t, appErr, "should have saved license")
}
func TestRemoveLicense(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
err := th.Service.RemoveLicense()
require.Nil(t, err, "should have removed license")
}
func TestSetLicense(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
l1 := &model.License{}
l1.Features = &model.Features{}
l1.Customer = &model.Customer{}
l1.StartsAt = model.GetMillis() - 1000
l1.ExpiresAt = model.GetMillis() + 100000
ok := th.Service.SetLicense(l1)
require.True(t, ok, "license should have worked")
l3 := &model.License{}
l3.Features = &model.Features{}
l3.Customer = &model.Customer{}
l3.StartsAt = model.GetMillis() + 10000
l3.ExpiresAt = model.GetMillis() + 100000
ok = th.Service.SetLicense(l3)
require.True(t, ok, "license should have passed")
}
func TestGetSanitizedClientLicense(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
setLicense(th, nil)
m := th.Service.GetSanitizedClientLicense()
_, ok := m["Name"]
assert.False(t, ok)
_, ok = m["SkuName"]
assert.False(t, ok)
}
func setLicense(th *TestHelper, customer *model.Customer) {
l1 := &model.License{}
l1.Features = &model.Features{}
if customer != nil {
l1.Customer = customer
} else {
l1.Customer = &model.Customer{}
l1.Customer.Name = "TestName"
l1.Customer.Email = "test@example.com"
}
l1.SkuName = "SKU NAME"
l1.SkuShortName = "SKU SHORT NAME"
l1.StartsAt = model.GetMillis() - 1000
l1.ExpiresAt = model.GetMillis() + 100000
th.Service.SetLicense(l1)
}

View File

@@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"time"
"github.com/mattermost/mattermost/server/v8/platform/services/cache"
)
const LinkCacheSize = 10000
const LinkCacheDuration = 1 * time.Hour
var linkCache = cache.NewLRU(&cache.CacheOptions{
Size: LinkCacheSize,
})
func PurgeLinkCache() error {
return linkCache.Purge()
}
func LinkCache() cache.Cache {
return linkCache
}

View File

@@ -0,0 +1,353 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"context"
"encoding/json"
"io"
"net/http"
"os"
"path"
"path/filepath"
"slices"
"time"
"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/public/utils"
"github.com/mattermost/mattermost/server/v8/config"
)
func (ps *PlatformService) Log() mlog.LoggerIFace {
return ps.logger
}
func (ps *PlatformService) ReconfigureLogger() error {
return ps.initLogging()
}
// initLogging initializes and configures the logger(s). This may be called more than once.
func (ps *PlatformService) initLogging() error {
// create the app logger if needed
if ps.logger == nil {
var err error
ps.logger, err = mlog.NewLogger(
mlog.MaxFieldLen(*ps.Config().LogSettings.MaxFieldSize),
mlog.StackFilter("log"),
)
if err != nil {
return err
}
}
// configure app logger. This will replace any existing targets with new ones as defined in the config.
if err := ps.ConfigureLogger("logging", ps.logger, &ps.Config().LogSettings, config.GetLogFileLocation); err != nil {
// if the config is locked then a unit test has already configured and locked the logger; not an error.
if !errors.Is(err, mlog.ErrConfigurationLock) {
// revert to default logger if the config is invalid
mlog.InitGlobalLogger(nil)
return err
}
}
// redirect default Go logger to app logger.
ps.logger.RedirectStdLog(mlog.LvlWarn)
// use the app logger as the global logger (eventually remove all instances of global logging).
mlog.InitGlobalLogger(ps.logger)
return nil
}
func (ps *PlatformService) Logger() *mlog.Logger {
return ps.logger
}
func (ps *PlatformService) EnableLoggingMetrics() {
if ps.metrics == nil || ps.metricsIFace == nil {
return
}
ps.logger.SetMetricsCollector(ps.metricsIFace.GetLoggerMetricsCollector(), mlog.DefaultMetricsUpdateFreqMillis)
// logging config needs to be reloaded when metrics collector is added or changed.
if err := ps.initLogging(); err != nil {
mlog.Error("Error re-configuring logging for metrics")
return
}
mlog.Debug("Logging metrics enabled")
}
// RemoveUnlicensedLogTargets removes any unlicensed log target types.
func (ps *PlatformService) RemoveUnlicensedLogTargets(license *model.License) {
if license != nil && *license.Features.AdvancedLogging {
// advanced logging enabled via license; no need to remove any targets
return
}
timeoutCtx, cancelCtx := context.WithTimeout(context.Background(), time.Second*10)
defer cancelCtx()
if err := ps.logger.RemoveTargets(timeoutCtx, func(ti mlog.TargetInfo) bool {
return ti.Type != "*targets.Writer" && ti.Type != "*targets.File"
}); err != nil {
mlog.Error("Failed to remove log targets", mlog.Err(err))
}
}
func (ps *PlatformService) GetLogsSkipSend(rctx request.CTX, page, perPage int, logFilter *model.LogFilter) ([]string, *model.AppError) {
var lines []string
if *ps.Config().LogSettings.EnableFile {
ps.Log().Flush()
logFile := config.GetLogFileLocation(*ps.Config().LogSettings.FileLocation)
// Validate the file path to prevent arbitrary file reads
if err := ps.validateLogFilePath(logFile); err != nil {
rctx.Logger().Error("Blocked attempt to read log file outside allowed root",
mlog.String("path", logFile),
mlog.String("config_section", "LogSettings.FileLocation"),
mlog.Err(err))
return nil, model.NewAppError("getLogs", "api.admin.file_read_error", nil, "", http.StatusForbidden).Wrap(err)
}
file, err := os.Open(logFile)
if err != nil {
return nil, model.NewAppError("getLogs", "api.admin.file_read_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
defer file.Close()
var newLine = []byte{'\n'}
var lineCount int
const searchPos = -1
b := make([]byte, 1)
var endOffset int64
// if the file exists and it's last byte is '\n' - skip it
var stat os.FileInfo
if stat, err = os.Stat(logFile); err == nil {
if _, err = file.ReadAt(b, stat.Size()-1); err == nil && b[0] == newLine[0] {
endOffset = -1
}
}
lineEndPos, err := file.Seek(endOffset, io.SeekEnd)
if err != nil {
return nil, model.NewAppError("getLogs", "api.admin.file_read_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for {
pos, err := file.Seek(searchPos, io.SeekCurrent)
if err != nil {
return nil, model.NewAppError("getLogs", "api.admin.file_read_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
_, err = file.ReadAt(b, pos)
if err != nil {
return nil, model.NewAppError("getLogs", "api.admin.file_read_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if b[0] == newLine[0] || pos == 0 {
lineCount++
if lineCount > page*perPage {
line := make([]byte, lineEndPos-pos)
_, err := file.ReadAt(line, pos)
if err != nil {
return nil, model.NewAppError("getLogs", "api.admin.file_read_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
filtered := false
var entry *model.LogEntry
err = json.Unmarshal(line, &entry)
if err != nil {
rctx.Logger().Debug("Failed to parse line, skipping")
} else {
filtered = isLogFilteredByLevel(logFilter, entry) || filtered
filtered = isLogFilteredByDate(rctx, logFilter, entry) || filtered
}
if filtered {
lineCount--
} else {
lines = append(lines, string(line))
}
}
if pos == 0 {
break
}
lineEndPos = pos
}
if len(lines) == perPage {
break
}
}
for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
lines[i], lines[j] = lines[j], lines[i]
}
} else {
lines = append(lines, "")
}
return lines, nil
}
func (ps *PlatformService) GetLogFile(rctx request.CTX) (*model.FileData, error) {
if !*ps.Config().LogSettings.EnableFile {
return nil, errors.New("Unable to retrieve mattermost logs because LogSettings.EnableFile is set to false")
}
mattermostLog := config.GetLogFileLocation(*ps.Config().LogSettings.FileLocation)
// Validate the file path to prevent arbitrary file reads
if err := ps.validateLogFilePath(mattermostLog); err != nil {
rctx.Logger().Error("Blocked attempt to read log file outside allowed root",
mlog.String("path", mattermostLog),
mlog.String("config_section", "LogSettings.FileLocation"),
mlog.Err(err))
return nil, errors.Wrapf(err, "log file path %s is outside allowed logging directory", mattermostLog)
}
mattermostLogFileData, err := os.ReadFile(mattermostLog)
if err != nil {
return nil, errors.Wrapf(err, "failed read mattermost log file at path %s", mattermostLog)
}
return &model.FileData{
Filename: config.LogFilename,
Body: mattermostLogFileData,
}, nil
}
// getLogRootPath returns the log root directory, checking the instance override
// first, then delegating to config.GetLogRootPath (env var / default).
func (ps *PlatformService) getLogRootPath() string {
if ps.logRootPathOverride != "" {
if abs, err := filepath.Abs(ps.logRootPathOverride); err == nil {
return abs
}
return ps.logRootPathOverride
}
return config.GetLogRootPath()
}
// validateLogFilePath validates that a log file path is within the logging root directory.
// This prevents arbitrary file read/write vulnerabilities in logging configuration.
// The logging root is determined by MM_LOG_PATH environment variable or the default logs directory.
// Used to validate paths when reading logs via GetLogsSkipSend, GetLogFile, and GetAdvancedLogs.
// In future versions, this will also be used to validate paths when saving logging config.
func (ps *PlatformService) validateLogFilePath(filePath string) error {
loggingRoot := ps.getLogRootPath()
return config.ValidateLogFilePath(filePath, loggingRoot)
}
func (ps *PlatformService) GetAdvancedLogs(rctx request.CTX) ([]*model.FileData, error) {
var (
rErr *multierror.Error
ret []*model.FileData
)
rctx.Logger().Debug("Advanced logs access requested")
for name, loggingJSON := range map[string]json.RawMessage{
"LogSettings.AdvancedLoggingJSON": ps.Config().LogSettings.AdvancedLoggingJSON,
"ExperimentalAuditSettings.AdvancedLoggingJSON": ps.Config().ExperimentalAuditSettings.AdvancedLoggingJSON,
} {
if utils.IsEmptyJSON(loggingJSON) {
continue
}
cfg := make(mlog.LoggerConfiguration)
err := json.Unmarshal(loggingJSON, &cfg)
if err != nil {
rErr = multierror.Append(rErr, errors.Wrapf(err, "error decoding advanced logging configuration %s", name))
continue
}
for _, t := range cfg {
if t.Type != "file" {
continue
}
var fileOption struct {
Filename string `json:"filename"`
}
if err := json.Unmarshal(t.Options, &fileOption); err != nil {
rErr = multierror.Append(rErr, errors.Wrapf(err, "error decoding file target options in %s", name))
continue
}
// Validate the file path to prevent arbitrary file reads
if err := ps.validateLogFilePath(fileOption.Filename); err != nil {
rctx.Logger().Error("Blocked attempt to read log file outside allowed root",
mlog.String("path", fileOption.Filename),
mlog.String("config_section", name),
mlog.String("user_id", rctx.Session().UserId),
mlog.Err(err))
rErr = multierror.Append(rErr, errors.Wrapf(err, "log file path %s in %s is outside allowed logging directory", fileOption.Filename, name))
continue
}
data, err := os.ReadFile(fileOption.Filename)
if err != nil {
rErr = multierror.Append(rErr, errors.Wrapf(err, "failed to read advanced log file at path %s in %s", fileOption.Filename, name))
continue
}
fileName := path.Base(fileOption.Filename)
ret = append(ret, &model.FileData{
Filename: fileName,
Body: data,
})
}
}
return ret, rErr.ErrorOrNil()
}
func isLogFilteredByLevel(logFilter *model.LogFilter, entry *model.LogEntry) bool {
logLevels := logFilter.LogLevels
if len(logLevels) == 0 {
return false
}
return !slices.Contains(logLevels, entry.Level)
}
func isLogFilteredByDate(rctx request.CTX, logFilter *model.LogFilter, entry *model.LogEntry) bool {
if logFilter.DateFrom == "" && logFilter.DateTo == "" {
return false
}
// Keep parsing aligned with LogFilter.IsValid via the shared layout constant.
dateFrom, err := time.Parse(model.LogFilterDateLayout, logFilter.DateFrom)
if err != nil {
dateFrom = time.Time{}
}
dateTo, err := time.Parse(model.LogFilterDateLayout, logFilter.DateTo)
if err != nil {
dateTo = time.Now()
}
timestamp, err := time.Parse(model.LogFilterDateLayout, entry.Timestamp)
if err != nil {
rctx.Logger().Debug("Cannot parse timestamp, skipping")
return false
}
if timestamp.Equal(dateFrom) || timestamp.Equal(dateTo) {
return false
}
if timestamp.After(dateFrom) && timestamp.Before(dateTo) {
return false
}
return true
}

View File

@@ -0,0 +1,424 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"bytes"
"encoding/json"
"os"
"path"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
"github.com/mattermost/mattermost/server/v8/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetMattermostLog(t *testing.T) {
t.Skip("Skipped due to flakiness — tracked in https://mattermost.atlassian.net/browse/MM-70639")
mainHelper.Parallel(t)
th := Setup(t)
// disable mattermost log file setting in config so we should get an warning
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.LogSettings.EnableFile = false
})
fileData, err := th.Service.GetLogFile(th.Context)
assert.Nil(t, fileData)
assert.ErrorContains(t, err, "Unable to retrieve mattermost logs because LogSettings.EnableFile is set to false")
dir, err := os.MkdirTemp("", "")
require.NoError(t, err)
t.Cleanup(func() {
// MM-62438: Disable file target before cleaning up
// to avoid a race between removing the directory and the file
// getting written again.
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.LogSettings.EnableFile = false
})
th.Service.Logger().Flush()
err = os.RemoveAll(dir)
assert.NoError(t, err)
})
// Override log root path to allow log file reads from our temp directory
th.Service.SetLogRootPathOverride(dir)
// Enable log file but point to an empty directory to get an error trying to read the file
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.LogSettings.EnableFile = true
*cfg.LogSettings.FileLocation = dir
})
logLocation := config.GetLogFileLocation(dir)
// ReconfigureLogger may create mattermost.log as soon as file logging is enabled.
// Flush and remove it so the next GetLogFile exercises the missing-file path.
th.Service.Logger().Flush()
err = os.Remove(logLocation)
if err != nil && !os.IsNotExist(err) {
require.NoError(t, err)
}
// There is no mattermost.log file yet, so this fails
fileData, err = th.Service.GetLogFile(th.Context)
assert.Nil(t, fileData)
assert.ErrorContains(t, err, "failed read mattermost log file at path "+logLocation)
// Happy path where we get a log file and no warning
d1 := []byte("hello\ngo\n")
err = os.WriteFile(logLocation, d1, 0777)
require.NoError(t, err)
fileData, err = th.Service.GetLogFile(th.Context)
require.NoError(t, err)
require.NotNil(t, fileData)
assert.Equal(t, "mattermost.log", fileData.Filename)
assert.Positive(t, len(fileData.Body))
// Test path validation: FileLocation outside MM_LOG_PATH should be blocked
t.Run("path validation prevents reading files outside log directory", func(t *testing.T) {
// Create a directory outside the allowed log root
outsideDir, err := os.MkdirTemp("", "outside")
require.NoError(t, err)
t.Cleanup(func() {
// MM-62438: stop the file target before removing temp dirs.
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.LogSettings.EnableFile = false
*cfg.LogSettings.FileLocation = dir
})
th.Service.Logger().Flush()
err = os.RemoveAll(outsideDir)
require.NoError(t, err)
})
// Create a file that would be read if validation fails
outsideLogLocation := config.GetLogFileLocation(outsideDir)
err = os.WriteFile(outsideLogLocation, []byte("secret data"), 0644)
require.NoError(t, err)
// Set FileLocation to the outside directory (log root override is still 'dir')
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.LogSettings.FileLocation = outsideDir
})
th.Service.Logger().Flush()
// Should be blocked by path validation
fileData, err = th.Service.GetLogFile(th.Context)
assert.Nil(t, fileData)
assert.Error(t, err)
assert.Contains(t, err.Error(), "outside allowed logging directory")
})
}
// Covers PlatformService.validateLogFilePath (used by GetLogFile) without enabling
// file logging, which is the MM-70639 logger file-target race.
func TestValidateLogFilePathWithRootOverride(t *testing.T) {
ps := &PlatformService{}
logDir, err := os.MkdirTemp("", "logs")
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, os.RemoveAll(logDir))
})
ps.SetLogRootPathOverride(logDir)
t.Run("allows path within override root", func(t *testing.T) {
inRoot := path.Join(logDir, "mattermost.log")
require.NoError(t, os.WriteFile(inRoot, []byte("ok"), 0644))
assert.NoError(t, ps.validateLogFilePath(inRoot))
})
t.Run("rejects path outside override root", func(t *testing.T) {
outsideDir, err := os.MkdirTemp("", "outside")
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, os.RemoveAll(outsideDir))
})
outsideFile := path.Join(outsideDir, "secret.log")
require.NoError(t, os.WriteFile(outsideFile, []byte("secret"), 0644))
err = ps.validateLogFilePath(outsideFile)
require.Error(t, err)
assert.Contains(t, err.Error(), "outside logging root")
})
}
func TestGetLogsSkipSendPathValidation(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
t.Run("path validation prevents reading files outside log directory", func(t *testing.T) {
// Create a directory to use as the allowed log root
logDir, err := os.MkdirTemp("", "logs")
require.NoError(t, err)
t.Cleanup(func() {
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.LogSettings.EnableFile = false
})
th.Service.Logger().Flush()
err = os.RemoveAll(logDir)
require.NoError(t, err)
})
// Override log root path to restrict log file access to logDir
th.Service.SetLogRootPathOverride(logDir)
// Create a directory outside the allowed log root
outsideDir, err := os.MkdirTemp("", "outside")
require.NoError(t, err)
t.Cleanup(func() {
err = os.RemoveAll(outsideDir)
require.NoError(t, err)
})
// Create a log file outside the allowed root that should not be readable
outsideLogLocation := config.GetLogFileLocation(outsideDir)
err = os.WriteFile(outsideLogLocation, []byte("secret data\n"), 0644)
require.NoError(t, err)
// Point FileLocation to the outside directory
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.LogSettings.EnableFile = true
*cfg.LogSettings.FileLocation = outsideDir
})
// Should be blocked by path validation
lines, appErr := th.Service.GetLogsSkipSend(th.Context, 0, 10, &model.LogFilter{})
assert.Nil(t, lines)
require.NotNil(t, appErr)
assert.Equal(t, "api.admin.file_read_error", appErr.Id)
})
}
func TestGetAdvancedLogs(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
t.Run("log messages from advanced logging settings get returned", func(t *testing.T) {
dir, err := os.MkdirTemp("", "logs")
require.NoError(t, err)
t.Cleanup(func() {
err = os.RemoveAll(dir)
require.NoError(t, err)
})
// Override log root path to allow advanced logging to write to our temp directory
th.Service.SetLogRootPathOverride(dir)
// Setup log files for each setting
optLDAP := map[string]string{
"filename": path.Join(dir, "ldap.log"),
}
dataLDAP, err := json.Marshal(optLDAP)
require.NoError(t, err)
optStd := map[string]string{
"filename": path.Join(dir, "std.log"),
}
dataStd, err := json.Marshal(optStd)
require.NoError(t, err)
// LogSettings config
logCfg := mlog.LoggerConfiguration{
"ldap-file": mlog.TargetCfg{
Type: "file",
Format: "json",
Levels: []mlog.Level{
mlog.LvlLDAPError,
mlog.LvlLDAPWarn,
mlog.LvlLDAPInfo,
mlog.LvlLDAPDebug,
},
Options: dataLDAP,
},
"std": mlog.TargetCfg{
Type: "file",
Format: "json",
Levels: []mlog.Level{
mlog.LvlError,
},
Options: dataStd,
},
}
logCfgData, err := json.Marshal(logCfg)
require.NoError(t, err)
th.Service.UpdateConfig(func(c *model.Config) {
c.LogSettings.AdvancedLoggingJSON = logCfgData
// Audit logs are not testable as they are part of the server, not the platform
})
// Write some logs and ensure they're flushed
logger := th.Service.Logger()
logger.LogM([]mlog.Level{mlog.LvlLDAPInfo}, "Some LDAP info")
logger.Error("Some Error")
// Flush logger and wait a bit for filesystem
err = logger.Flush()
require.NoError(t, err)
// Get and verify logs
fileDatas, err := th.Service.GetAdvancedLogs(th.Context)
require.NoError(t, err)
for _, fd := range fileDatas {
t.Log(fd.Filename)
}
require.Len(t, fileDatas, 2)
// Helper to find file data by name
findFile := func(name string) *model.FileData {
for _, fd := range fileDatas {
if fd.Filename == name {
return fd
}
}
return nil
}
// Check each log file
ldapFile := findFile("ldap.log")
require.NotNil(t, ldapFile)
testlib.AssertLog(t, bytes.NewBuffer(ldapFile.Body), mlog.LvlLDAPInfo.Name, "Some LDAP info")
stdFile := findFile("std.log")
require.NotNil(t, stdFile)
testlib.AssertLog(t, bytes.NewBuffer(stdFile.Body), mlog.LvlError.Name, "Some Error")
})
// Disable AdvancedLoggingJSON
th.Service.UpdateConfig(func(c *model.Config) {
c.LogSettings.AdvancedLoggingJSON = nil
})
t.Run("No logs returned when AdvancedLoggingJSON is empty", func(t *testing.T) {
// Confirm no logs get returned
fileDatas, err := th.Service.GetAdvancedLogs(th.Context)
require.NoError(t, err)
require.Len(t, fileDatas, 0)
})
t.Run("path validation prevents reading files outside log directory", func(t *testing.T) {
// Create a temporary directory to use as the log root
logDir, err := os.MkdirTemp("", "logs")
require.NoError(t, err)
t.Cleanup(func() {
err = os.RemoveAll(logDir)
require.NoError(t, err)
})
// Override log root path to restrict log file access to logDir
th.Service.SetLogRootPathOverride(logDir)
// Create a file outside the log directory that should not be accessible
outsideDir, err := os.MkdirTemp("", "outside")
require.NoError(t, err)
t.Cleanup(func() {
err = os.RemoveAll(outsideDir)
require.NoError(t, err)
})
secretFile := path.Join(outsideDir, "secret.txt")
err = os.WriteFile(secretFile, []byte("secret data"), 0644)
require.NoError(t, err)
// Create a valid log file inside the log directory
validLog := path.Join(logDir, "valid.log")
err = os.WriteFile(validLog, []byte("valid log data"), 0644)
require.NoError(t, err)
// Test 1: Attempt to read file outside log directory using absolute path
optOutside := map[string]string{
"filename": secretFile,
}
dataOutside, err := json.Marshal(optOutside)
require.NoError(t, err)
logCfgOutside := mlog.LoggerConfiguration{
"malicious": mlog.TargetCfg{
Type: "file",
Format: "json",
Levels: []mlog.Level{mlog.LvlError},
Options: dataOutside,
},
}
logCfgDataOutside, err := json.Marshal(logCfgOutside)
require.NoError(t, err)
th.Service.UpdateConfig(func(c *model.Config) {
c.LogSettings.AdvancedLoggingJSON = logCfgDataOutside
})
fileDatas, err := th.Service.GetAdvancedLogs(th.Context)
// Should return error indicating path is outside allowed directory
require.Error(t, err)
require.Contains(t, err.Error(), "outside allowed logging directory")
require.Len(t, fileDatas, 0)
// Test 2: Attempt path traversal attack
traversalPath := path.Join(logDir, "..", "..", "etc", "passwd")
optTraversal := map[string]string{
"filename": traversalPath,
}
dataTraversal, err := json.Marshal(optTraversal)
require.NoError(t, err)
logCfgTraversal := mlog.LoggerConfiguration{
"traversal": mlog.TargetCfg{
Type: "file",
Format: "json",
Levels: []mlog.Level{mlog.LvlError},
Options: dataTraversal,
},
}
logCfgDataTraversal, err := json.Marshal(logCfgTraversal)
require.NoError(t, err)
th.Service.UpdateConfig(func(c *model.Config) {
c.LogSettings.AdvancedLoggingJSON = logCfgDataTraversal
})
fileDatas, err = th.Service.GetAdvancedLogs(th.Context)
// Should return error for path traversal attempt
require.Error(t, err)
require.Contains(t, err.Error(), "outside")
require.Len(t, fileDatas, 0)
// Test 3: Valid path within log directory should work
optValid := map[string]string{
"filename": validLog,
}
dataValid, err := json.Marshal(optValid)
require.NoError(t, err)
logCfgValid := mlog.LoggerConfiguration{
"valid": mlog.TargetCfg{
Type: "file",
Format: "json",
Levels: []mlog.Level{mlog.LvlError},
Options: dataValid,
},
}
logCfgDataValid, err := json.Marshal(logCfgValid)
require.NoError(t, err)
th.Service.UpdateConfig(func(c *model.Config) {
c.LogSettings.AdvancedLoggingJSON = logCfgDataValid
})
fileDatas, err = th.Service.GetAdvancedLogs(th.Context)
require.NoError(t, err)
require.Len(t, fileDatas, 1)
require.Equal(t, "valid.log", fileDatas[0].Filename)
require.Equal(t, []byte("valid log data"), fileDatas[0].Body)
})
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"flag"
"os"
"strconv"
"testing"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)
var (
mainHelper *testlib.MainHelper
)
func TestMain(m *testing.M) {
var parallelism int
if f := flag.Lookup("test.parallel"); f != nil {
parallelism, _ = strconv.Atoi(f.Value.String())
}
runParallel := os.Getenv("ENABLE_FULLY_PARALLEL_TESTS") == "true" && parallelism > 1
if runParallel {
mlog.Info("Fully parallel tests enabled", mlog.Int("parallelism", parallelism))
}
options := testlib.HelperOptions{
EnableStore: true,
EnableResources: true,
RunParallel: runParallel,
Parallelism: parallelism,
}
mainHelper = testlib.NewMainHelperWithOptions(&options)
defer mainHelper.Close()
mainHelper.Main(m)
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build darwin
package platform
import (
"golang.org/x/sys/unix"
)
// getTotalMemory returns the total system memory in bytes.
func getTotalMemory() (uint64, error) {
mem, err := unix.SysctlUint64("hw.memsize")
if err != nil {
return 0, err
}
return mem, nil
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build linux
package platform
import (
"syscall"
)
// getTotalMemory returns the total system memory in bytes.
func getTotalMemory() (uint64, error) {
var info syscall.Sysinfo_t
err := syscall.Sysinfo(&info)
if err != nil {
return 0, err
}
// Sysinfo returns memory in units of info.Unit bytes
return info.Totalram * uint64(info.Unit), nil
}

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build !linux && !darwin
package platform
import "errors"
// ErrMemoryUnsupportedPlatform is returned when memory detection is not supported on the current platform.
var ErrMemoryUnsupportedPlatform = errors.New("total memory detection not supported on this platform")
// getTotalMemory returns the total system memory in bytes.
// On unsupported platforms, this returns an error.
func getTotalMemory() (uint64, error) {
return 0, ErrMemoryUnsupportedPlatform
}

View File

@@ -0,0 +1,359 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"bytes"
"context"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/http/pprof"
"path"
"runtime"
"strings"
"sync"
"text/template"
"time"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/pkg/errors"
dto "github.com/prometheus/client_model/go"
"github.com/prometheus/common/expfmt"
prommodel "github.com/prometheus/common/model"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
const (
TimeToWaitForConnectionsToCloseOnServerShutdown = time.Second
cpuProfileDuration = 5 * time.Second
)
type platformMetrics struct {
server *http.Server
router *mux.Router
lock sync.Mutex
logger *mlog.Logger
metricsImpl einterfaces.MetricsInterface
cfgFn func() *model.Config
listenAddr string
getPluginsEnv func() *plugin.Environment
}
// resetMetrics resets the metrics server. Clears the metrics if the metrics are disabled by the config.
func (ps *PlatformService) resetMetrics() error {
if !*ps.Config().MetricsSettings.Enable {
if ps.metrics != nil {
return ps.metrics.stopMetricsServer()
}
return nil
}
if ps.metrics != nil {
if err := ps.metrics.stopMetricsServer(); err != nil {
return err
}
}
ps.metrics = &platformMetrics{
cfgFn: ps.Config,
metricsImpl: ps.metricsIFace,
logger: ps.logger,
getPluginsEnv: func() *plugin.Environment {
if ps.pluginEnv == nil {
return nil
}
return ps.pluginEnv.GetPluginsEnvironment()
},
}
if err := ps.metrics.initMetricsRouter(); err != nil {
return err
}
if ps.metricsIFace != nil {
ps.metricsIFace.Register()
}
return ps.metrics.startMetricsServer()
}
func (pm *platformMetrics) stopMetricsServer() error {
pm.lock.Lock()
defer pm.lock.Unlock()
if pm.server != nil {
ctx, cancel := context.WithTimeout(context.Background(), TimeToWaitForConnectionsToCloseOnServerShutdown)
defer cancel()
if err := pm.server.Shutdown(ctx); err != nil {
return fmt.Errorf("could not shutdown metrics server: %v", err)
}
pm.logger.Info("Metrics and profiling server is stopped")
}
return nil
}
func (pm *platformMetrics) startMetricsServer() error {
var notify chan struct{}
pm.lock.Lock()
defer func() {
if notify != nil {
<-notify
}
pm.lock.Unlock()
}()
l, err := net.Listen("tcp", *pm.cfgFn().MetricsSettings.ListenAddress)
if err != nil {
return err
}
notify = make(chan struct{})
pm.server = &http.Server{
Handler: handlers.RecoveryHandler(handlers.PrintRecoveryStack(true))(pm.router),
ReadTimeout: time.Duration(*pm.cfgFn().ServiceSettings.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(*pm.cfgFn().ServiceSettings.WriteTimeout) * time.Second,
}
go func() {
close(notify)
if err := pm.server.Serve(l); err != nil && err != http.ErrServerClosed {
pm.logger.Fatal(err.Error())
}
}()
pm.listenAddr = l.Addr().String()
pm.logger.Info("Metrics and profiling server is started", mlog.String("address", pm.listenAddr))
return nil
}
func (pm *platformMetrics) initMetricsRouter() error {
pm.router = mux.NewRouter()
runtime.SetBlockProfileRate(*pm.cfgFn().MetricsSettings.BlockProfileRate)
metricsPage := `
<html>
<body>{{if .}}
<div><a href="/metrics">Metrics</a></div>{{end}}
<div><a href="/debug/pprof/">Profiling Root</a></div>
<div><a href="/debug/pprof/cmdline">Profiling Command Line</a></div>
<div><a href="/debug/pprof/symbol">Profiling Symbols</a></div>
<div><a href="/debug/pprof/goroutine">Profiling Goroutines</a></div>
<div><a href="/debug/pprof/heap">Profiling Heap</a></div>
<div><a href="/debug/pprof/threadcreate">Profiling Threads</a></div>
<div><a href="/debug/pprof/block">Profiling Blocking</a></div>
<div><a href="/debug/pprof/trace">Profiling Execution Trace</a></div>
<div><a href="/debug/pprof/profile">Profiling CPU</a></div>
</body>
</html>
`
metricsPageTmpl, err := template.New("page").Parse(metricsPage)
if err != nil {
return errors.Wrap(err, "failed to create template")
}
rootHandler := func(w http.ResponseWriter, r *http.Request) {
if err := metricsPageTmpl.Execute(w, pm.metricsImpl != nil); err != nil {
pm.logger.Error("Failed to execute template", mlog.Err(err))
}
}
pm.router.HandleFunc("/", rootHandler)
pm.router.StrictSlash(true)
pm.router.Handle("/debug", http.RedirectHandler("/", http.StatusMovedPermanently))
pm.router.HandleFunc("/debug/pprof/", pprof.Index)
pm.router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
pm.router.HandleFunc("/debug/pprof/profile", pprof.Profile)
pm.router.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
pm.router.HandleFunc("/debug/pprof/trace", pprof.Trace)
// Manually add support for paths linked to by index page at /debug/pprof/
pm.router.Handle("/debug/pprof/goroutine", pprof.Handler("goroutine"))
pm.router.Handle("/debug/pprof/heap", pprof.Handler("heap"))
pm.router.Handle("/debug/pprof/allocs", pprof.Handler("allocs"))
pm.router.Handle("/debug/pprof/threadcreate", pprof.Handler("threadcreate"))
pm.router.Handle("/debug/pprof/block", pprof.Handler("block"))
pm.router.Handle("/debug/pprof/mutex", pprof.Handler("mutex"))
// Plugins metrics route
pluginsMetricsRoute := pm.router.PathPrefix("/plugins/{plugin_id:[A-Za-z0-9\\_\\-\\.]+}/metrics").Subrouter()
pluginsMetricsRoute.HandleFunc("", pm.servePluginMetricsRequest)
pluginsMetricsRoute.HandleFunc("/{anything:.*}", pm.servePluginMetricsRequest)
return nil
}
func (pm *platformMetrics) servePluginMetricsRequest(w http.ResponseWriter, r *http.Request) {
pluginID := mux.Vars(r)["plugin_id"]
pluginsEnvironment := pm.getPluginsEnv()
if pluginsEnvironment == nil {
appErr := model.NewAppError("ServePluginMetricsRequest", "app.plugin.disabled.app_error",
nil, "Enable plugins to serve plugin metric requests", http.StatusNotImplemented)
mlog.Error(appErr.Error())
w.WriteHeader(appErr.StatusCode)
w.Header().Set("Content-Type", "application/json")
if _, writeErr := w.Write([]byte(appErr.ToJSON())); writeErr != nil {
mlog.Error("Failed to write error response", mlog.Err(writeErr))
}
return
}
hooks, err := pluginsEnvironment.HooksForPlugin(pluginID)
if err != nil {
mlog.Debug("Access to route for non-existent plugin",
mlog.String("missing_plugin_id", pluginID),
mlog.String("url", r.URL.String()),
mlog.Err(err))
http.NotFound(w, r)
return
}
subpath, err := utils.GetSubpathFromConfig(pm.cfgFn())
if err != nil {
appErr := model.NewAppError("ServePluginMetricsRequest", "app.plugin.subpath_parse.app_error",
nil, "Failed to parse SiteURL subpath", http.StatusInternalServerError).Wrap(err)
mlog.Error(appErr.Error())
w.WriteHeader(appErr.StatusCode)
w.Header().Set("Content-Type", "application/json")
if _, writeErr := w.Write([]byte(appErr.ToJSON())); writeErr != nil {
mlog.Error("Failed to write error response", mlog.Err(writeErr))
}
return
}
r.URL.Path = strings.TrimPrefix(r.URL.Path, path.Join(subpath, "plugins", pluginID, "metrics"))
// Passing an empty plugin context for the time being. To be decided whether we
// should support forms of authentication in the future.
hooks.ServeMetrics(&plugin.Context{}, w, r)
}
func (ps *PlatformService) HandleMetrics(route string, h http.Handler) {
if ps.metrics == nil {
return
}
if route == "/metrics" && ps.Config().FeatureFlags.AggregatePluginMetrics {
h = ps.wrapMetricsHandler(h)
}
ps.metrics.router.Handle(route, h)
}
func (ps *PlatformService) wrapMetricsHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var buf bytes.Buffer
// Strip Accept-Encoding to force plaintext response (no gzip)
plainReq := r.Clone(r.Context())
plainReq.Header.Del("Accept-Encoding")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, plainReq)
buf.Write(rec.Body.Bytes())
if ps.pluginEnv != nil {
if env := ps.pluginEnv.GetPluginsEnvironment(); env != nil {
env.RunMultiPluginHook(func(hooks plugin.Hooks, manifest *model.Manifest) bool {
pluginRec := httptest.NewRecorder()
pluginReq, err := http.NewRequestWithContext(r.Context(), "GET", "/metrics", nil)
if err != nil {
return true
}
hooks.ServeMetrics(&plugin.Context{}, pluginRec, pluginReq)
if pluginRec.Code == http.StatusOK && pluginRec.Body.Len() > 0 {
if labeledMetrics := addPluginLabelToMetrics(pluginRec.Body.String(), manifest.Id); labeledMetrics != "" {
buf.WriteString("\n")
buf.WriteString(labeledMetrics)
}
}
return true
}, plugin.ServeMetricsID)
}
}
// Copy content type from main metrics response
if contentType := rec.Header().Get("Content-Type"); contentType != "" {
w.Header().Set("Content-Type", contentType)
}
w.WriteHeader(rec.Code)
if _, writeErr := w.Write(buf.Bytes()); writeErr != nil {
mlog.Error("Failed to write to HandleMetrics's response writer", mlog.Err(writeErr))
}
})
}
// addPluginLabelToMetrics adds a plugin_id label to all metrics in the Prometheus text format.
// It parses using expfmt, mutates the label sets in-place, then re-encodes.
// Returns an empty string and logs a warning if any parsing or encoding error occurs.
func addPluginLabelToMetrics(metricsText, pluginID string) string {
parser := expfmt.NewTextParser(prommodel.LegacyValidation)
families, err := parser.TextToMetricFamilies(strings.NewReader(metricsText))
if err != nil {
mlog.Warn("Failed to parse plugin metrics", mlog.String("plugin_id", pluginID), mlog.Err(err))
return ""
}
pluginIDLabel := &dto.LabelPair{
Name: new("plugin_id"),
Value: new(pluginID),
}
var result strings.Builder
enc := expfmt.NewEncoder(&result, expfmt.FmtText)
for name, family := range families {
for _, metric := range family.Metric {
replaced := false
for _, l := range metric.Label {
if l.GetName() == "plugin_id" {
l.Value = new(pluginID)
replaced = true
break
}
}
if !replaced {
metric.Label = append(metric.Label, pluginIDLabel)
}
}
if encErr := enc.Encode(family); encErr != nil {
mlog.Warn("Failed to encode plugin metrics", mlog.String("plugin_id", pluginID), mlog.String("family", name), mlog.Err(encErr))
return ""
}
}
if closer, ok := enc.(expfmt.Closer); ok {
closer.Close()
}
return result.String()
}
func (ps *PlatformService) RestartMetrics() error {
return ps.resetMetrics()
}
func (ps *PlatformService) Metrics() einterfaces.MetricsInterface {
if ps.metrics == nil {
return nil
}
return ps.metricsIFace
}

View File

@@ -0,0 +1,168 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMetricsRouter(t *testing.T) {
logger := mlog.CreateTestLogger(t)
cfg := &model.Config{}
cfg.SetDefaults()
metrics := &platformMetrics{
cfgFn: func() *model.Config { return cfg },
logger: logger,
}
err := metrics.initMetricsRouter()
require.NoError(t, err)
server := httptest.NewServer(metrics.router)
t.Cleanup(server.Close)
client := server.Client()
for name, tc := range map[string]struct {
path string
method string
ExpectedBodyContains string
ExpectedCode int
}{
"root": {path: "", ExpectedBodyContains: "<div><a href=\"/debug/pprof/\">Profiling Root</a></div>"},
"root with slash": {path: "/", ExpectedBodyContains: "<div><a href=\"/debug/pprof/\">Profiling Root</a></div>"},
"debug redirect": {path: "/debug", ExpectedBodyContains: "<div><a href=\"/debug/pprof/\">Profiling Root</a></div>"},
"debug redirect with slash": {path: "/debug/", ExpectedBodyContains: "<div><a href=\"/debug/pprof/\">Profiling Root</a></div>"},
"pprof index page": {path: "/debug/pprof", ExpectedBodyContains: "<p>Set debug=1 as a query parameter to export in legacy text format</p>"},
"pprof index page with slash": {path: "/debug/pprof/", ExpectedBodyContains: "<p>Set debug=1 as a query parameter to export in legacy text format</p>"},
"pprof allocs": {path: "/debug/pprof/allocs"},
"pprof block": {path: "/debug/pprof/block"},
"pprof cmdline": {path: "/debug/pprof/cmdline"},
"pprof goroutine": {path: "/debug/pprof/goroutine"},
"pprof heap": {path: "/debug/pprof/heap"},
"pprof mutex": {path: "/debug/pprof/mutex"},
"pprof profile": {path: "/debug/pprof/profile?seconds=1"},
"pprof symbol": {path: "/debug/pprof/symbol"},
"pprof threadcreate": {path: "/debug/pprof/threadcreate"},
"pprof trace": {path: "/debug/pprof/trace"},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
method := http.MethodGet
if tc.method != "" {
method = tc.method
}
url := server.URL + tc.path
req, err := http.NewRequest(method, url, nil)
require.NoError(t, err, name)
resp, err := client.Do(req)
require.NoError(t, err, name)
t.Cleanup(func() {
err := resp.Body.Close()
assert.NoError(t, err)
})
expectedCode := 200
if tc.ExpectedCode != 0 {
expectedCode = tc.ExpectedCode
}
assert.Equal(t, expectedCode, resp.StatusCode)
if tc.ExpectedBodyContains != "" {
b, err := io.ReadAll(resp.Body)
require.NoError(t, err, name)
assert.Contains(t, string(b), tc.ExpectedBodyContains)
}
})
}
}
func TestAddPluginLabelToMetrics(t *testing.T) {
for name, tc := range map[string]struct {
input []string
pluginID string
expected []string
}{
"metric without labels": {
input: []string{"my_metric 42"},
pluginID: "com.example.plugin",
expected: []string{
"# TYPE my_metric untyped",
`my_metric{plugin_id="com.example.plugin"} 42`,
},
},
"metric with existing labels": {
input: []string{`my_metric{env="prod"} 42`},
pluginID: "com.example.plugin",
expected: []string{
"# TYPE my_metric untyped",
`my_metric{env="prod",plugin_id="com.example.plugin"} 42`,
},
},
"help and type comments are preserved": {
input: []string{
"# HELP my_metric A metric",
"# TYPE my_metric gauge",
"my_metric 42",
},
pluginID: "my-plugin",
expected: []string{
"# HELP my_metric A metric",
"# TYPE my_metric gauge",
`my_metric{plugin_id="my-plugin"} 42`,
},
},
"empty lines are dropped": {
// blank lines carry no semantic meaning in Prometheus text format
input: []string{"", "my_metric 1"},
pluginID: "p",
expected: []string{
"# TYPE my_metric untyped",
`my_metric{plugin_id="p"} 1`,
},
},
"existing plugin_id label is replaced not duplicated": {
input: []string{`my_metric{plugin_id="old-plugin"} 42`},
pluginID: "new-plugin",
expected: []string{
`my_metric{plugin_id="new-plugin"} 42`,
},
},
"multiple metrics are sorted and separated": {
input: []string{"metric_a 1", "metric_b 2"},
pluginID: "plug",
expected: []string{
"# TYPE metric_a untyped",
`metric_a{plugin_id="plug"} 1`,
"# TYPE metric_b untyped",
`metric_b{plugin_id="plug"} 2`,
},
},
} {
t.Run(name, func(t *testing.T) {
result := addPluginLabelToMetrics(strings.Join(tc.input, "\n")+"\n", tc.pluginID)
for _, line := range tc.expected {
assert.Contains(t, result, line)
}
})
}
}
func TestAddPluginLabelToMetricsMalformedInput(t *testing.T) {
result := addPluginLabelToMetrics("not valid prometheus text {{{", "my-plugin")
assert.Equal(t, "", result)
}

View File

@@ -0,0 +1,220 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Regenerate this file using `make platform-mocks`.
package mocks
import (
model "github.com/mattermost/mattermost/server/public/model"
mock "github.com/stretchr/testify/mock"
request "github.com/mattermost/mattermost/server/public/shared/request"
)
// SuiteIFace is an autogenerated mock type for the SuiteIFace type
type SuiteIFace struct {
mock.Mock
}
// GetSession provides a mock function with given fields: token
func (_m *SuiteIFace) GetSession(token string) (*model.Session, *model.AppError) {
ret := _m.Called(token)
if len(ret) == 0 {
panic("no return value specified for GetSession")
}
var r0 *model.Session
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(string) (*model.Session, *model.AppError)); ok {
return rf(token)
}
if rf, ok := ret.Get(0).(func(string) *model.Session); ok {
r0 = rf(token)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Session)
}
}
if rf, ok := ret.Get(1).(func(string) *model.AppError); ok {
r1 = rf(token)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// HasPermissionToFileAction provides a mock function with given fields: rctx, userID, roles, channelID, action
func (_m *SuiteIFace) HasPermissionToFileAction(rctx request.CTX, userID string, roles string, channelID string, action string) bool {
ret := _m.Called(rctx, userID, roles, channelID, action)
if len(ret) == 0 {
panic("no return value specified for HasPermissionToFileAction")
}
var r0 bool
if rf, ok := ret.Get(0).(func(request.CTX, string, string, string, string) bool); ok {
r0 = rf(rctx, userID, roles, channelID, action)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// HasPermissionToReadChannel provides a mock function with given fields: rctx, userID, channel
func (_m *SuiteIFace) HasPermissionToReadChannel(rctx request.CTX, userID string, channel *model.Channel) (bool, bool) {
ret := _m.Called(rctx, userID, channel)
if len(ret) == 0 {
panic("no return value specified for HasPermissionToReadChannel")
}
var r0 bool
var r1 bool
if rf, ok := ret.Get(0).(func(request.CTX, string, *model.Channel) (bool, bool)); ok {
return rf(rctx, userID, channel)
}
if rf, ok := ret.Get(0).(func(request.CTX, string, *model.Channel) bool); ok {
r0 = rf(rctx, userID, channel)
} else {
r0 = ret.Get(0).(bool)
}
if rf, ok := ret.Get(1).(func(request.CTX, string, *model.Channel) bool); ok {
r1 = rf(rctx, userID, channel)
} else {
r1 = ret.Get(1).(bool)
}
return r0, r1
}
// HasPermissionToResolveChannelMention provides a mock function with given fields: rctx, userID, channel
func (_m *SuiteIFace) HasPermissionToResolveChannelMention(rctx request.CTX, userID string, channel *model.Channel) bool {
ret := _m.Called(rctx, userID, channel)
if len(ret) == 0 {
panic("no return value specified for HasPermissionToResolveChannelMention")
}
var r0 bool
if rf, ok := ret.Get(0).(func(request.CTX, string, *model.Channel) bool); ok {
r0 = rf(rctx, userID, channel)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// LogAuditRec provides a mock function with given fields: rctx, auditRec, err
func (_m *SuiteIFace) LogAuditRec(rctx request.CTX, auditRec *model.AuditRecord, err error) {
_m.Called(rctx, auditRec, err)
}
// MFARequired provides a mock function with given fields: rctx, method
func (_m *SuiteIFace) MFARequired(rctx request.CTX, method string) *model.AppError {
ret := _m.Called(rctx, method)
if len(ret) == 0 {
panic("no return value specified for MFARequired")
}
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string) *model.AppError); ok {
r0 = rf(rctx, method)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// MakeAuditRecord provides a mock function with given fields: rctx, event, initialStatus
func (_m *SuiteIFace) MakeAuditRecord(rctx request.CTX, event string, initialStatus string) *model.AuditRecord {
ret := _m.Called(rctx, event, initialStatus)
if len(ret) == 0 {
panic("no return value specified for MakeAuditRecord")
}
var r0 *model.AuditRecord
if rf, ok := ret.Get(0).(func(request.CTX, string, string) *model.AuditRecord); ok {
r0 = rf(rctx, event, initialStatus)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AuditRecord)
}
}
return r0
}
// RolesGrantPermission provides a mock function with given fields: roleNames, permissionId
func (_m *SuiteIFace) RolesGrantPermission(roleNames []string, permissionId string) bool {
ret := _m.Called(roleNames, permissionId)
if len(ret) == 0 {
panic("no return value specified for RolesGrantPermission")
}
var r0 bool
if rf, ok := ret.Get(0).(func([]string, string) bool); ok {
r0 = rf(roleNames, permissionId)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// UserCanSeeOtherUser provides a mock function with given fields: rctx, userID, otherUserId
func (_m *SuiteIFace) UserCanSeeOtherUser(rctx request.CTX, userID string, otherUserId string) (bool, *model.AppError) {
ret := _m.Called(rctx, userID, otherUserId)
if len(ret) == 0 {
panic("no return value specified for UserCanSeeOtherUser")
}
var r0 bool
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(request.CTX, string, string) (bool, *model.AppError)); ok {
return rf(rctx, userID, otherUserId)
}
if rf, ok := ret.Get(0).(func(request.CTX, string, string) bool); ok {
r0 = rf(rctx, userID, otherUserId)
} else {
r0 = ret.Get(0).(bool)
}
if rf, ok := ret.Get(1).(func(request.CTX, string, string) *model.AppError); ok {
r1 = rf(rctx, userID, otherUserId)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// NewSuiteIFace creates a new instance of SuiteIFace. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewSuiteIFace(t interface {
mock.TestingT
Cleanup(func())
}) *SuiteIFace {
mock := &SuiteIFace{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,144 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"fmt"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/store/localcachelayer"
"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore"
"github.com/mattermost/mattermost/server/v8/config"
"github.com/mattermost/mattermost/server/v8/einterfaces"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
)
type Option func(ps *PlatformService) error
// By default, the app will use the store specified by the configuration. This allows you to
// construct an app with a different store.
//
// The override parameter must be either a store.Store or func(App) store.Store().
func StoreOverride(override any) Option {
return func(ps *PlatformService) error {
switch o := override.(type) {
case store.Store:
ps.newStore = func() (store.Store, error) {
return o, nil
}
return nil
case func(*PlatformService) store.Store:
ps.newStore = func() (store.Store, error) {
return o(ps), nil
}
return nil
default:
return errors.New("invalid StoreOverride")
}
}
}
// StoreOverrideWithCache is a test option to construct the app with the store layer
// wrapped on top of the store that is passed.
func StoreOverrideWithCache(override store.Store) Option {
return func(ps *PlatformService) error {
ps.newStore = func() (store.Store, error) {
lcl, err := localcachelayer.NewLocalCacheLayer(override, ps.metricsIFace, ps.clusterIFace, ps.cacheProvider, ps.Log())
if err != nil {
return nil, err
}
// Clearing all the caches because the in-mem data
// is persisted in case of Redis.
lcl.Invalidate()
return lcl, nil
}
return nil
}
}
// StoreOption allows passing options when constructing the store.
//
// This option has no effect if StoreOverride or StoreOverrideWithCache is also set.
func StoreOption(option sqlstore.Option) Option {
return func(ps *PlatformService) error {
ps.storeOptions = append(ps.storeOptions, option)
return nil
}
}
// Config applies the given config dsn, whether a path to config.json
// or a database connection string. It receives as well a set of
// custom defaults that will be applied for any unset property of the
// config loaded from the dsn on top of the normal defaults
func Config(dsn string, readOnly bool, configDefaults *model.Config) Option {
return func(ps *PlatformService) error {
configStore, err := config.NewStoreFromDSN(dsn, readOnly, configDefaults, true)
if err != nil {
return fmt.Errorf("failed to apply Config option: %w", err)
}
ps.configStore = configStore
return nil
}
}
func SetFileStore(filestore filestore.FileBackend) Option {
return func(ps *PlatformService) error {
ps.filestore = filestore
return nil
}
}
func SetExportFileStore(filestore filestore.FileBackend) Option {
return func(ps *PlatformService) error {
ps.exportFilestore = filestore
return nil
}
}
// ConfigStore applies the given config store, typically to replace the traditional sources with a memory store for testing.
func ConfigStore(configStore *config.Store) Option {
return func(ps *PlatformService) error {
ps.configStore = configStore
return nil
}
}
func StartMetrics() Option {
return func(ps *PlatformService) error {
ps.startMetrics = true
return nil
}
}
func SetLogger(logger *mlog.Logger) Option {
return func(ps *PlatformService) error {
ps.SetLogger(logger)
return nil
}
}
func SetCluster(cluster einterfaces.ClusterInterface) Option {
return func(ps *PlatformService) error {
ps.clusterIFace = cluster
return nil
}
}
func ForceEnableRedis() Option {
return func(ps *PlatformService) error {
ps.forceEnableRedis = true
return nil
}
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/platform/services/searchengine"
)
func (ps *PlatformService) backfillPostsChannelType(engine searchengine.SearchEngineInterface) {
rctx := request.EmptyContext(ps.Log())
// Check if already done.
sys, err := ps.Store.System().GetByName(model.SystemPostChannelTypeBackfillComplete)
if err == nil && sys.Value == "true" {
return
}
rctx.Logger().Info("Starting post channel_type backfill")
// Backfill public and private channels. GetAllChannels does not filter by the Public/Private opts,
// so we fetch all channels and group them by type ourselves.
page := 0
const perPage = 10000
for {
allChannels, channelErr := ps.Store.Channel().GetAllChannels(page*perPage, perPage, store.ChannelSearchOpts{})
if channelErr != nil {
rctx.Logger().Error("Failed to get channels for backfill", mlog.Err(channelErr))
return
}
if len(allChannels) == 0 {
break
}
byType := map[string][]string{}
for _, ch := range allChannels {
byType[string(ch.Type)] = append(byType[string(ch.Type)], ch.Id)
}
for chType, channelIDs := range byType {
rctx.Logger().Info("Backfilling channel_type batch",
mlog.String("channel_type", chType),
mlog.Int("channel_count", len(channelIDs)),
mlog.Int("page", page))
if appErr := engine.BackfillPostsChannelType(rctx, channelIDs, chType); appErr != nil {
rctx.Logger().Error("Failed to backfill channel_type on posts",
mlog.String("channel_type", chType),
mlog.Err(appErr))
return
}
}
if len(allChannels) < perPage {
break
}
page++
}
// Mark done.
if err := ps.Store.System().SaveOrUpdate(&model.System{
Name: model.SystemPostChannelTypeBackfillComplete,
Value: "true",
}); err != nil {
rctx.Logger().Error("Backfill data was written but completion flag was not saved; backfill will re-run on next trigger", mlog.Err(err))
return
}
rctx.Logger().Info("Post channel_type backfill complete")
}

View File

@@ -0,0 +1,150 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"testing"
"github.com/stretchr/testify/mock"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
searchenginemocks "github.com/mattermost/mattermost/server/v8/platform/services/searchengine/mocks"
)
func TestBackfillPostsChannelType(t *testing.T) {
t.Run("already completed", func(t *testing.T) {
th := SetupWithStoreMock(t)
mockStore := th.Service.Store.(*mocks.Store)
systemMock := &mocks.SystemStore{}
systemMock.On("GetByName", model.SystemPostChannelTypeBackfillComplete).
Return(&model.System{Name: model.SystemPostChannelTypeBackfillComplete, Value: "true"}, nil)
mockStore.On("System").Return(systemMock)
engineMock := &searchenginemocks.SearchEngineInterface{}
th.Service.backfillPostsChannelType(engineMock)
systemMock.AssertExpectations(t)
engineMock.AssertNotCalled(t, "BackfillPostsChannelType", mock.Anything, mock.Anything, mock.Anything)
})
t.Run("successful backfill", func(t *testing.T) {
th := SetupWithStoreMock(t)
mockStore := th.Service.Store.(*mocks.Store)
systemMock := &mocks.SystemStore{}
systemMock.On("GetByName", model.SystemPostChannelTypeBackfillComplete).
Return(nil, model.NewAppError("test", "not_found", nil, "", 404))
systemMock.On("SaveOrUpdate", &model.System{
Name: model.SystemPostChannelTypeBackfillComplete,
Value: "true",
}).Return(nil)
mockStore.On("System").Return(systemMock)
channelMock := &mocks.ChannelStore{}
channelMock.On("GetAllChannels", 0, 10000, mock.Anything).
Return(model.ChannelListWithTeamData{
{Channel: model.Channel{Id: "ch1", Type: model.ChannelTypeOpen}},
{Channel: model.Channel{Id: "ch2", Type: model.ChannelTypeOpen}},
{Channel: model.Channel{Id: "ch3", Type: model.ChannelTypePrivate}},
}, nil)
mockStore.On("Channel").Return(channelMock)
engineMock := &searchenginemocks.SearchEngineInterface{}
engineMock.On("BackfillPostsChannelType", mock.Anything, []string{"ch1", "ch2"}, "O").Return(nil)
engineMock.On("BackfillPostsChannelType", mock.Anything, []string{"ch3"}, "P").Return(nil)
th.Service.backfillPostsChannelType(engineMock)
engineMock.AssertExpectations(t)
systemMock.AssertExpectations(t)
})
t.Run("GetAllChannels error stops backfill", func(t *testing.T) {
th := SetupWithStoreMock(t)
mockStore := th.Service.Store.(*mocks.Store)
systemMock := &mocks.SystemStore{}
systemMock.On("GetByName", model.SystemPostChannelTypeBackfillComplete).
Return(nil, model.NewAppError("test", "not_found", nil, "", 404))
mockStore.On("System").Return(systemMock)
channelMock := &mocks.ChannelStore{}
channelMock.On("GetAllChannels", 0, 10000, mock.Anything).
Return(nil, model.NewAppError("test", "store_error", nil, "", 500))
mockStore.On("Channel").Return(channelMock)
engineMock := &searchenginemocks.SearchEngineInterface{}
th.Service.backfillPostsChannelType(engineMock)
// BackfillPostsChannelType should never be called.
engineMock.AssertNotCalled(t, "BackfillPostsChannelType", mock.Anything, mock.Anything, mock.Anything)
// Completion flag should not be written.
systemMock.AssertNotCalled(t, "SaveOrUpdate", mock.Anything)
})
t.Run("should separate public and private channels from GetAllChannels", func(t *testing.T) {
th := SetupWithStoreMock(t)
mockStore := th.Service.Store.(*mocks.Store)
systemMock := &mocks.SystemStore{}
systemMock.On("GetByName", model.SystemPostChannelTypeBackfillComplete).
Return(nil, model.NewAppError("test", "not_found", nil, "", 404))
systemMock.On("SaveOrUpdate", &model.System{
Name: model.SystemPostChannelTypeBackfillComplete,
Value: "true",
}).Return(nil)
mockStore.On("System").Return(systemMock)
// GetAllChannels returns BOTH public and private channels regardless
// of the opts passed — this matches the real store behavior.
mixedChannels := model.ChannelListWithTeamData{
{Channel: model.Channel{Id: "pub1", Type: model.ChannelTypeOpen}},
{Channel: model.Channel{Id: "pub2", Type: model.ChannelTypeOpen}},
{Channel: model.Channel{Id: "priv1", Type: model.ChannelTypePrivate}},
}
channelMock := &mocks.ChannelStore{}
channelMock.On("GetAllChannels", 0, 10000, mock.Anything).
Return(mixedChannels, nil)
mockStore.On("Channel").Return(channelMock)
engineMock := &searchenginemocks.SearchEngineInterface{}
engineMock.On("BackfillPostsChannelType", mock.Anything, []string{"pub1", "pub2"}, "O").Return(nil)
engineMock.On("BackfillPostsChannelType", mock.Anything, []string{"priv1"}, "P").Return(nil)
th.Service.backfillPostsChannelType(engineMock)
engineMock.AssertExpectations(t)
systemMock.AssertExpectations(t)
})
t.Run("BackfillPostsChannelType error stops backfill", func(t *testing.T) {
th := SetupWithStoreMock(t)
mockStore := th.Service.Store.(*mocks.Store)
systemMock := &mocks.SystemStore{}
systemMock.On("GetByName", model.SystemPostChannelTypeBackfillComplete).
Return(nil, model.NewAppError("test", "not_found", nil, "", 404))
mockStore.On("System").Return(systemMock)
channelMock := &mocks.ChannelStore{}
channelMock.On("GetAllChannels", 0, 10000, mock.Anything).
Return(model.ChannelListWithTeamData{
{Channel: model.Channel{Id: "ch1", Type: model.ChannelTypeOpen}},
}, nil).Once()
mockStore.On("Channel").Return(channelMock)
engineMock := &searchenginemocks.SearchEngineInterface{}
engineMock.On("BackfillPostsChannelType", mock.Anything, []string{"ch1"}, "O").
Return(model.NewAppError("test", "es_error", nil, "", 500))
th.Service.backfillPostsChannelType(engineMock)
// Completion flag should not be written.
systemMock.AssertNotCalled(t, "SaveOrUpdate", mock.Anything)
})
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (ps *PlatformService) StartSearchEngine() (string, string) {
if ps.SearchEngine.ElasticsearchEngine != nil {
ps.esWatcher = newSearchEngineWatcher(ps)
ps.esWatcher.start()
}
configListenerId := ps.AddConfigListener(func(oldConfig *model.Config, newConfig *model.Config) {
if ps.SearchEngine == nil {
return
}
if err := ps.SearchEngine.UpdateConfig(newConfig); err != nil {
ps.Log().Error("Failed to update search engine config", mlog.Err(err))
}
oldESCfg := oldConfig.ElasticsearchSettings
newESCfg := newConfig.ElasticsearchSettings
startingES := ps.SearchEngine.ElasticsearchEngine != nil &&
!model.SafeDereference(oldESCfg.EnableIndexing) &&
model.SafeDereference(newESCfg.EnableIndexing)
stoppingES := ps.SearchEngine.ElasticsearchEngine != nil &&
model.SafeDereference(oldESCfg.EnableIndexing) &&
!model.SafeDereference(newESCfg.EnableIndexing)
connectionChanged := ps.SearchEngine.ElasticsearchEngine != nil &&
(model.SafeDereference(oldESCfg.ConnectionURL) != model.SafeDereference(newESCfg.ConnectionURL) ||
model.SafeDereference(oldESCfg.Username) != model.SafeDereference(newESCfg.Username) ||
model.SafeDereference(oldESCfg.Password) != model.SafeDereference(newESCfg.Password) ||
model.SafeDereference(oldESCfg.Sniff) != model.SafeDereference(newESCfg.Sniff))
startingBackfill := !model.SafeDereference(oldESCfg.EnableSearchPublicChannelsWithoutMembership) &&
model.SafeDereference(newESCfg.EnableSearchPublicChannelsWithoutMembership)
if connectionChanged {
// Signal the watcher to tear down the stale client before
// re-evaluating. The watcher will call Stop() + Start()
// with the new settings on its next tick.
ps.esWatcher.requestRestart()
} else if startingES || stoppingES {
ps.esWatcher.reevaluate()
}
// Backfill was enabled but ES was already running (not starting fresh).
if startingBackfill && !startingES {
ps.Go(func() {
engine := ps.SearchEngine.ElasticsearchEngine
if engine == nil || !engine.IsActive() || !engine.IsIndexingEnabled() {
ps.Log().Warn("Elasticsearch not available for channel_type backfill")
return
}
ps.backfillPostsChannelType(engine)
})
}
})
licenseListenerId := ps.AddLicenseListener(func(oldLicense, newLicense *model.License) {
if ps.SearchEngine == nil {
return
}
if oldLicense == nil && newLicense != nil {
// License added -- watcher will try Start() on next evaluation.
ps.esWatcher.reevaluate()
} else if oldLicense != nil && newLicense == nil {
// License removed -- tell the watcher to stop the engine.
// The watcher will then retry Start() which returns nil
// without a license, so it backs off gracefully.
if ps.SearchEngine.ElasticsearchEngine != nil {
ps.esWatcher.requestRestart()
}
}
})
return configListenerId, licenseListenerId
}
func (ps *PlatformService) StopSearchEngine() {
if ps.esWatcher != nil {
ps.esWatcher.stop()
}
ps.RemoveConfigListener(ps.searchConfigListenerId)
ps.RemoveLicenseListener(ps.searchLicenseListenerId)
if ps.SearchEngine != nil && ps.SearchEngine.ElasticsearchEngine != nil && ps.SearchEngine.ElasticsearchEngine.IsActive() {
if err := ps.SearchEngine.ElasticsearchEngine.Stop(); err != nil {
ps.Log().Error("Failed to stop Elasticsearch engine", mlog.Err(err))
}
}
}

View File

@@ -0,0 +1,735 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"context"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
storemocks "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
"github.com/mattermost/mattermost/server/v8/platform/services/searchengine"
searchenginemocks "github.com/mattermost/mattermost/server/v8/platform/services/searchengine/mocks"
)
// overrideWatcherTimings sets all watcher durations to fast values for testing
// and restores the originals when the test finishes.
func overrideWatcherTimings(t *testing.T) {
t.Helper()
origRetryInitial := searchEngineRetryInitial
origRetryMax := searchEngineRetryMax
origHealthInterval := searchEngineHealthInterval
origHealthFailThreshold := searchEngineHealthFailThreshold
origStopTimeout := searchEngineStopTimeout
searchEngineRetryInitial = 10 * time.Millisecond
searchEngineRetryMax = 50 * time.Millisecond
searchEngineHealthInterval = 10 * time.Millisecond
searchEngineHealthFailThreshold = 3
searchEngineStopTimeout = 500 * time.Millisecond
t.Cleanup(func() {
searchEngineRetryInitial = origRetryInitial
searchEngineRetryMax = origRetryMax
searchEngineHealthInterval = origHealthInterval
searchEngineHealthFailThreshold = origHealthFailThreshold
searchEngineStopTimeout = origStopTimeout
})
}
// setupWatcherTest creates a PlatformService with a mock search engine wired
// into the broker and a ready-to-use watcher. Only GetName is pre-configured
// on the mock — each test sets up other expectations explicitly.
func setupWatcherTest(t *testing.T) (*searchEngineWatcher, *searchenginemocks.SearchEngineInterface) {
t.Helper()
overrideWatcherTimings(t)
th := SetupWithStoreMock(t)
ps := th.Service
engineMock := &searchenginemocks.SearchEngineInterface{}
engineMock.On("GetName").Return("test-engine").Maybe()
engineMock.On("IsHealthy").Return(true).Maybe()
engineMock.On("SetHealthy", mock.Anything).Maybe()
// Make backfillPostsChannelType return immediately by indicating the backfill
// is already done. This prevents the goroutine from calling unmocked store
// methods in tests that only exercise watcher retry/health logic.
systemMock := &storemocks.SystemStore{}
systemMock.On("GetByName", model.SystemPostChannelTypeBackfillComplete).
Return(&model.System{Name: model.SystemPostChannelTypeBackfillComplete, Value: "true"}, nil).Maybe()
ps.Store.(*storemocks.Store).On("System").Return(systemMock).Maybe()
ps.SearchEngine = searchengine.NewBroker(ps.Config())
ps.SearchEngine.ElasticsearchEngine = engineMock
w := newSearchEngineWatcher(ps)
ps.esWatcher = w
return w, engineMock
}
func TestRunSearchEngineWatcher(t *testing.T) {
t.Run("retries Start on failure then transitions to health phase", func(t *testing.T) {
w, engineMock := setupWatcherTest(t)
var logBuffer mlog.Buffer
require.NoError(t, mlog.AddWriterTarget(w.ps.Logger(), &logBuffer, true, mlog.StdAll...))
engineMock.On("IsEnabled").Return(true).Maybe()
// Start() fails twice, then succeeds on the third call.
var startCalls atomic.Int32
engineMock.On("Start", mock.Anything).Return(func(context.Context) *model.AppError {
n := startCalls.Add(1)
if n <= 2 {
return model.NewAppError("test", "start_failed", nil, "", 500)
}
return nil
})
// After Start() succeeds, IsActive returns true so the watcher
// transitions to the health phase.
engineMock.On("IsActive").Return(func() bool {
return startCalls.Load() > 2
})
// Track whether HealthCheck is called — that confirms the
// watcher entered the health phase.
var healthChecked atomic.Int32
engineMock.On("HealthCheck", mock.Anything).Run(func(mock.Arguments) {
healthChecked.Store(1)
}).Return(nil).Maybe()
// The engine will be active when the watcher exits, so the
// cleanup defer in run() will call Stop().
engineMock.On("Stop").Return(nil).Maybe()
w.start()
t.Cleanup(w.stop)
require.Eventually(t, func() bool {
return healthChecked.Load() == 1
}, 2*time.Second, 5*time.Millisecond,
"watcher should have transitioned to health phase after Start succeeds")
require.NoError(t, w.ps.Logger().Flush())
logText := logBuffer.String()
testlib.AssertLog(t, strings.NewReader(logText), mlog.LvlError.Name, "Search engine watcher: Start() failed, will retry")
testlib.AssertNoLog(t, strings.NewReader(logText), mlog.LvlWarn.Name, "Search engine watcher: Start() failed, will retry")
})
t.Run("exponential backoff with cap", func(t *testing.T) {
w, engineMock := setupWatcherTest(t)
engineMock.On("IsEnabled").Return(true).Maybe()
engineMock.On("IsActive").Return(false).Maybe()
// Start() always fails — we record timestamps to observe backoff.
var (
startTimes []time.Time
startTimesMu sync.Mutex
)
engineMock.On("Start", mock.Anything).Return(func(context.Context) *model.AppError {
startTimesMu.Lock()
startTimes = append(startTimes, time.Now())
startTimesMu.Unlock()
return model.NewAppError("test", "start_failed", nil, "", 500)
})
w.start()
// Wait for several retries.
require.Eventually(t, func() bool {
startTimesMu.Lock()
defer startTimesMu.Unlock()
return len(startTimes) >= 5
}, 2*time.Second, 5*time.Millisecond)
w.stop()
// After stop returns, the watcher goroutine has exited, so
// startTimes is safe to read without the lock.
for i := 1; i < len(startTimes); i++ {
gap := startTimes[i].Sub(startTimes[i-1])
assert.LessOrEqual(t, gap.Milliseconds(), searchEngineRetryMax.Milliseconds()+50,
"gap between retry %d and %d exceeds max: %v", i-1, i, gap)
}
})
t.Run("exits when engine is disabled", func(t *testing.T) {
w, engineMock := setupWatcherTest(t)
engineMock.On("IsActive").Return(false).Maybe()
// Engine starts enabled, then becomes disabled after first Start() attempt.
var startCalls atomic.Int32
engineMock.On("IsEnabled").Return(func() bool {
return startCalls.Load() == 0
})
engineMock.On("Start", mock.Anything).Return(func(context.Context) *model.AppError {
startCalls.Add(1)
return model.NewAppError("test", "start_failed", nil, "", 500)
}).Maybe()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
notifyCh := make(chan struct{}, 1)
done := make(chan struct{})
go func() {
defer close(done)
w.run(ctx, notifyCh)
}()
// The watcher should park because IsEnabled() returns false.
time.Sleep(50 * time.Millisecond)
// Cancel context to let the watcher exit.
cancel()
require.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, 2*time.Second, 5*time.Millisecond,
"watcher did not exit after context cancellation")
})
t.Run("notify wakes watcher from park", func(t *testing.T) {
w, engineMock := setupWatcherTest(t)
// Start disabled, then enable after a notify.
var enabled atomic.Int32
engineMock.On("IsEnabled").Return(func() bool {
return enabled.Load() == 1
})
engineMock.On("IsActive").Return(false).Maybe()
var started atomic.Int32
engineMock.On("Start", mock.Anything).Return(func(context.Context) *model.AppError {
started.Store(1)
return model.NewAppError("test", "start_failed", nil, "", 500)
}).Maybe()
w.start()
t.Cleanup(w.stop)
// Watcher should be parked (engine disabled).
time.Sleep(50 * time.Millisecond)
assert.Equal(t, int32(0), started.Load(), "Start should not be called when disabled")
// Enable and notify.
enabled.Store(1)
w.reevaluate()
// Watcher should wake up and try Start().
require.Eventually(t, func() bool {
return started.Load() == 1
}, 2*time.Second, 5*time.Millisecond,
"watcher should have called Start after notify")
})
t.Run("stops engine on exit when Start succeeds during shutdown", func(t *testing.T) {
w, engineMock := setupWatcherTest(t)
engineMock.On("IsEnabled").Return(true).Maybe()
// Start() succeeds but the context is cancelled while it runs,
// simulating the race where Start() completes just as stop() is
// called. Without the safety-net defer in run(), the engine
// would be left active with no goroutine to manage it.
ctx, cancel := context.WithCancel(context.Background())
notifyCh := make(chan struct{}, 1)
engineMock.On("IsActive").Return(false).Once() // first check in startIfInactive
engineMock.On("Start", mock.Anything).Run(func(mock.Arguments) {
cancel() // simulate stop() racing with Start()
}).Return(nil).Once()
// After Start() returns nil, run() checks ctx.Err() != nil and
// falls through to the defer, which sees the engine as active.
engineMock.On("IsActive").Return(true).Once() // checked by the safety-net defer
var stopped atomic.Int32
engineMock.On("Stop").Run(func(mock.Arguments) {
stopped.Add(1)
}).Return(nil).Once()
done := make(chan struct{})
go func() {
defer close(done)
w.run(ctx, notifyCh)
}()
require.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, 2*time.Second, 5*time.Millisecond,
"watcher did not exit after context cancellation")
assert.Equal(t, int32(1), stopped.Load(),
"safety net should have called Stop() exactly once on goroutine exit")
})
}
func TestStartIfInactive(t *testing.T) {
t.Run("parks when Start returns nil but no ES license", func(t *testing.T) {
w, engineMock := setupWatcherTest(t)
engineMock.On("IsEnabled").Return(true).Maybe()
engineMock.On("IsActive").Return(false).Maybe()
// Start() succeeds but engine stays inactive (no license).
var startCalls atomic.Int32
engineMock.On("Start", mock.Anything).Run(func(mock.Arguments) {
startCalls.Add(1)
}).Return(nil)
// No license is set — the default for the test PlatformService.
w.start()
t.Cleanup(w.stop)
// Wait for the first Start() call.
require.Eventually(t, func() bool {
return startCalls.Load() >= 1
}, 2*time.Second, 5*time.Millisecond)
// The watcher should park — no further Start() calls after the first one.
time.Sleep(100 * time.Millisecond)
assert.Equal(t, int32(1), startCalls.Load(),
"watcher should park after one Start() call when there is no ES license")
engineMock.AssertNotCalled(t, "HealthCheck", mock.Anything)
})
t.Run("retries when Start returns nil but engine not active with ES license", func(t *testing.T) {
w, engineMock := setupWatcherTest(t)
engineMock.On("IsEnabled").Return(true).Maybe()
engineMock.On("IsActive").Return(false).Maybe()
var startCalls atomic.Int32
engineMock.On("Start", mock.Anything).Run(func(mock.Arguments) {
startCalls.Add(1)
}).Return(nil)
// Set a license with Elasticsearch feature enabled.
w.ps.licenseValue.Store(model.NewTestLicense("elastic_search"))
w.start()
t.Cleanup(w.stop)
// The watcher should keep retrying because the license is present.
require.Eventually(t, func() bool {
return startCalls.Load() >= 3
}, 2*time.Second, 5*time.Millisecond,
"watcher should retry Start when it returns nil but engine is not active (with license)")
engineMock.AssertNotCalled(t, "HealthCheck", mock.Anything)
})
}
func TestWatcherHealthPhase(t *testing.T) {
t.Run("intermittent failures below threshold do not trigger Stop", func(t *testing.T) {
w, engineMock := setupWatcherTest(t)
// Engine starts active.
engineMock.On("IsEnabled").Return(true).Maybe()
engineMock.On("IsActive").Return(true).Maybe()
// The engine will be active when the watcher exits, so the
// cleanup defer in run() will call Stop().
engineMock.On("Stop").Return(nil).Maybe()
// HealthCheck alternates: fail, ok, fail, ok...
// The consecutive failure counter resets on each success, so
// the threshold is never reached and Stop() is never called
// by the health-check logic (only by the exit defer).
var healthCalls atomic.Int32
engineMock.On("HealthCheck", mock.Anything).Return(func(_ request.CTX) *model.AppError {
n := healthCalls.Add(1)
if n%2 == 1 { // odd calls fail
return model.NewAppError("test", "hc_fail", nil, "", 502)
}
return nil
})
w.start()
t.Cleanup(w.stop)
require.Eventually(t, func() bool {
return healthCalls.Load() >= 4
}, 2*time.Second, 5*time.Millisecond)
// Stop should not have been called by health-check logic —
// failures never reached the threshold. (It will be called
// once later by the cleanup defer in run().)
engineMock.AssertNotCalled(t, "Stop")
})
t.Run("consecutive failures trigger Stop and retry", func(t *testing.T) {
w, engineMock := setupWatcherTest(t)
engineMock.On("IsEnabled").Return(true).Maybe()
// Engine starts active; becomes inactive after Stop().
var stopped atomic.Int32
engineMock.On("IsActive").Return(func() bool {
return stopped.Load() == 0
})
// HealthCheck always fails — triggers Stop after 3 consecutive failures.
engineMock.On("HealthCheck", mock.Anything).Return(
model.NewAppError("test", "hc_fail", nil, "", 502),
)
// Stop() returns an error (simulates concurrent stop by config
// listener). The watcher must tolerate this and still transition
// to the retry phase.
engineMock.On("Stop").Return(func() *model.AppError {
stopped.Store(1)
return model.NewAppError("test", "already_stopped", nil, "", 500)
})
// After transitioning to retry phase, Start() succeeds.
var started atomic.Int32
engineMock.On("Start", mock.Anything).Return(func(context.Context) *model.AppError {
started.Store(1)
return nil
}).Maybe()
w.start()
t.Cleanup(w.stop)
// Watcher should have called Stop() then retried Start().
require.Eventually(t, func() bool {
return started.Load() == 1
}, 2*time.Second, 5*time.Millisecond,
"watcher should have called Stop then retried Start")
})
}
func TestStopSearchEngineWatcher(t *testing.T) {
t.Run("exits immediately on stop signal during retry wait", func(t *testing.T) {
w, engineMock := setupWatcherTest(t)
engineMock.On("IsEnabled").Return(true).Maybe()
engineMock.On("IsActive").Return(false).Maybe()
// Make retries slow so the watcher is sitting in a timer wait.
searchEngineRetryInitial = 10 * time.Second
engineMock.On("Start", mock.Anything).Return(
model.NewAppError("test", "start_failed", nil, "", 500),
).Maybe()
w.start()
// Should exit well before the 10s retry timer fires.
start := time.Now()
w.stop()
assert.Less(t, time.Since(start), 2*time.Second)
})
t.Run("idempotent", func(t *testing.T) {
w, engineMock := setupWatcherTest(t)
engineMock.On("IsEnabled").Return(true).Maybe()
engineMock.On("IsActive").Return(false).Maybe()
// Not running — calling stop should be a no-op.
require.NotPanics(t, w.stop)
require.NotPanics(t, w.stop)
engineMock.On("Start", mock.Anything).Return(
model.NewAppError("test", "start_failed", nil, "", 500),
).Maybe()
// Start, then stop twice — second stop should not panic.
w.start()
w.stop()
require.NotPanics(t, w.stop)
})
t.Run("returns after timeout when watcher is stuck in Start", func(t *testing.T) {
w, engineMock := setupWatcherTest(t)
engineMock.On("IsEnabled").Return(true).Maybe()
engineMock.On("IsActive").Return(false).Maybe()
searchEngineStopTimeout = 100 * time.Millisecond
// Start() blocks forever, simulating a hung connection.
blocked := make(chan struct{})
enteredStart := make(chan struct{})
engineMock.On("Start", mock.Anything).Return(func(context.Context) *model.AppError {
close(enteredStart)
<-blocked
return nil
})
w.start()
// Wait until the watcher is actually stuck inside Start().
<-enteredStart
// Should return after the timeout, not hang forever.
start := time.Now()
w.stop()
elapsed := time.Since(start)
assert.GreaterOrEqual(t, elapsed, 100*time.Millisecond)
assert.Less(t, elapsed, 1*time.Second)
// Unblock the goroutine so it can be cleaned up.
close(blocked)
})
t.Run("start after timeout creates new watcher", func(t *testing.T) {
w, engineMock := setupWatcherTest(t)
engineMock.On("IsEnabled").Return(true).Maybe()
engineMock.On("IsActive").Return(false).Maybe()
searchEngineStopTimeout = 50 * time.Millisecond
// First watcher: Start() blocks forever.
blocked := make(chan struct{})
enteredStart := make(chan struct{}, 1)
var startCalls atomic.Int32
engineMock.On("Start", mock.Anything).Return(func(context.Context) *model.AppError {
n := startCalls.Add(1)
if n == 1 {
enteredStart <- struct{}{}
<-blocked
}
return model.NewAppError("test", "start_failed", nil, "", 500)
})
w.start()
<-enteredStart
// Save the first goroutine's done channel before stop nils it.
w.mu.Lock()
firstDone := w.done
w.mu.Unlock()
// Stop times out (watcher stuck in Start).
w.stop()
// Start a new watcher — should work despite the abandoned goroutine.
w.start()
// New watcher should call Start() (the second call).
require.Eventually(t, func() bool {
return startCalls.Load() >= 2
}, 2*time.Second, 5*time.Millisecond,
"new watcher should have called Start")
// Unblock the abandoned goroutine and wait for it to fully exit
// before the test cleanup restores package-level timing vars.
close(blocked)
<-firstDone
w.stop()
})
}
func TestNotifySearchEngineWatcher(t *testing.T) {
t.Run("notify resets backoff and triggers immediate evaluation", func(t *testing.T) {
w, engineMock := setupWatcherTest(t)
engineMock.On("IsEnabled").Return(true).Maybe()
engineMock.On("IsActive").Return(false).Maybe()
// Use a long retry so the watcher is in a backoff wait.
searchEngineRetryInitial = 10 * time.Second
var startCalls atomic.Int32
engineMock.On("Start", mock.Anything).Return(func(context.Context) *model.AppError {
startCalls.Add(1)
return model.NewAppError("test", "start_failed", nil, "", 500)
})
w.start()
t.Cleanup(w.stop)
// Wait for first Start() call.
require.Eventually(t, func() bool {
return startCalls.Load() >= 1
}, 2*time.Second, 5*time.Millisecond)
// Watcher is now in backoff (10s). Send notify to wake it.
before := startCalls.Load()
w.reevaluate()
// Start should be called again quickly (not after 10s).
require.Eventually(t, func() bool {
return startCalls.Load() > before
}, 1*time.Second, 5*time.Millisecond,
"notify should have triggered an immediate Start retry")
})
t.Run("no-op when watcher is not running", func(t *testing.T) {
w, _ := setupWatcherTest(t)
// Should not panic or block.
require.NotPanics(t, func() {
w.reevaluate()
})
})
}
func TestWatcherHealthFlag(t *testing.T) {
t.Run("first health check failure sets engine unhealthy", func(t *testing.T) {
w, engineMock := setupWatcherTest(t)
engineMock.On("IsEnabled").Return(true).Maybe()
engineMock.On("IsActive").Return(true).Maybe()
// Track SetHealthy calls.
var healthyValue atomic.Int32
healthyValue.Store(1) // assume healthy at start
engineMock.On("SetHealthy", mock.Anything).Unset()
engineMock.On("SetHealthy", mock.Anything).Run(func(args mock.Arguments) {
if args.Bool(0) {
healthyValue.Store(1)
} else {
healthyValue.Store(0)
}
}).Maybe()
// HealthCheck fails on the first call.
engineMock.On("HealthCheck", mock.Anything).Return(func(_ request.CTX) *model.AppError {
return model.NewAppError("test", "hc_fail", nil, "", 502)
})
engineMock.On("Stop").Return(nil).Maybe()
engineMock.On("Start", mock.Anything).Return(nil).Maybe()
w.start()
t.Cleanup(w.stop)
// Wait for the health flag update itself rather than the HealthCheck
// call count, which can race with the watcher's SetHealthy(false) call.
require.Eventually(t, func() bool {
return healthyValue.Load() == 0
}, 2*time.Second, 5*time.Millisecond,
"engine should be marked unhealthy after first health check failure")
})
t.Run("successful health check restores healthy flag", func(t *testing.T) {
w, engineMock := setupWatcherTest(t)
engineMock.On("IsEnabled").Return(true).Maybe()
engineMock.On("IsActive").Return(true).Maybe()
// Track SetHealthy calls.
var healthyValue atomic.Int32
engineMock.On("SetHealthy", mock.Anything).Unset()
engineMock.On("SetHealthy", mock.Anything).Run(func(args mock.Arguments) {
if args.Bool(0) {
healthyValue.Store(1)
} else {
healthyValue.Store(0)
}
}).Maybe()
// The engine will be active when the watcher exits, so the
// cleanup defer in run() will call Stop().
engineMock.On("Stop").Return(nil).Maybe()
// First health check fails, second succeeds.
var healthCalls atomic.Int32
engineMock.On("HealthCheck", mock.Anything).Return(func(_ request.CTX) *model.AppError {
n := healthCalls.Add(1)
if n == 1 {
return model.NewAppError("test", "hc_fail", nil, "", 502)
}
return nil
})
w.start()
t.Cleanup(w.stop)
// Wait for SetHealthy(true) — called by the watcher after the
// second (successful) HealthCheck returns.
require.Eventually(t, func() bool {
return healthyValue.Load() == 1
}, 2*time.Second, 5*time.Millisecond,
"engine should be marked healthy after successful health check")
})
setupHealthCheckLogTest := func(t *testing.T, startHealthy bool) (*searchEngineWatcher, *mlog.Buffer) {
t.Helper()
w, engineMock := setupWatcherTest(t)
logBuffer := &mlog.Buffer{}
require.NoError(t, mlog.AddWriterTarget(w.ps.Logger(), logBuffer, true, mlog.StdAll...))
engineMock.On("IsEnabled").Return(true).Maybe()
engineMock.On("IsActive").Return(true).Maybe()
engineMock.On("Stop").Return(nil).Maybe()
engineMock.On("Start", mock.Anything).Return(nil).Maybe()
engineMock.On("IsHealthy").Unset()
engineMock.On("IsHealthy").Return(startHealthy)
if startHealthy {
engineMock.On("HealthCheck", mock.Anything).Return(
model.NewAppError("test", "hc_fail", nil, "", 502),
)
} else {
engineMock.On("HealthCheck", mock.Anything).Return(nil)
}
return w, logBuffer
}
t.Run("logs healthy to unhealthy transition", func(t *testing.T) {
w, logBuffer := setupHealthCheckLogTest(t, true)
w.start()
t.Cleanup(w.stop)
require.Eventually(t, func() bool {
require.NoError(t, w.ps.Logger().Flush())
return testlib.CheckLog(t, strings.NewReader(logBuffer.String()), mlog.LvlWarn.Name,
"Search engine health check failed: it is now marked as unhealthy")
}, 2*time.Second, 5*time.Millisecond,
"expected log for healthy->unhealthy transition")
})
t.Run("logs unhealthy to healthy transition", func(t *testing.T) {
w, logBuffer := setupHealthCheckLogTest(t, false)
w.start()
t.Cleanup(w.stop)
require.Eventually(t, func() bool {
require.NoError(t, w.ps.Logger().Flush())
return testlib.CheckLog(t, strings.NewReader(logBuffer.String()), mlog.LvlInfo.Name,
"Search engine health check succeeded: it is now marked as healthy")
}, 2*time.Second, 5*time.Millisecond,
"expected log for unhealthy->healthy transition")
})
}

View File

@@ -0,0 +1,389 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/platform/services/searchengine"
)
// Watcher tuning knobs -- declared as vars so tests can override them.
var (
searchEngineRetryInitial = 15 * time.Second
searchEngineRetryMax = 5 * time.Minute
searchEngineHealthInterval = 60 * time.Second
searchEngineHealthFailThreshold = 3
searchEngineStopTimeout = 15 * time.Second
)
// searchEngineWatcher monitors an Elasticsearch engine's health and manages
// its lifecycle (Start / Stop) in a single background goroutine. All engine
// state mutations happen inside this goroutine, eliminating races between
// concurrent callers.
//
// External code communicates with the watcher through two signals:
// - reevaluate(): wake the watcher for immediate re-evaluation
// - requestRestart(): tell the watcher to Stop() before re-evaluating
type searchEngineWatcher struct {
ps *PlatformService
engine searchengine.SearchEngineInterface
// Coordination -- accessed from multiple goroutines.
mu sync.Mutex
cancel context.CancelFunc // nil when not running
done chan struct{} // closed when goroutine exits
notifyCh chan struct{} // buffered(1), non-blocking wake signal
forceRestart int32 // atomic: 1 = stop engine before next evaluation
}
// watcherLoopState holds per-goroutine loop state. Declared locally in run()
// and passed by pointer to helpers, so an abandoned goroutine and a fresh one
// never share these fields.
type watcherLoopState struct {
backoff time.Duration
consecutiveFailures int
}
func newSearchEngineWatcher(ps *PlatformService) *searchEngineWatcher {
return &searchEngineWatcher{
ps: ps,
engine: ps.SearchEngine.ElasticsearchEngine,
}
}
// start launches the watcher goroutine. Idempotent: no-op if already running.
func (w *searchEngineWatcher) start() {
w.mu.Lock()
defer w.mu.Unlock()
if w.cancel != nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
w.cancel = cancel
w.done = make(chan struct{})
w.notifyCh = make(chan struct{}, 1)
done := w.done
notifyCh := w.notifyCh
go func() {
defer close(done)
w.run(ctx, notifyCh)
}()
}
// stop cancels the watcher and waits for it to exit with a bounded timeout.
// If the watcher is stuck (e.g. in a blocking Start() call), the goroutine is
// abandoned and will self-terminate when the blocking call returns. The mutex
// is NOT held during the wait.
func (w *searchEngineWatcher) stop() {
w.mu.Lock()
cancel := w.cancel
done := w.done
w.cancel = nil
w.done = nil
w.notifyCh = nil
w.mu.Unlock()
if cancel == nil {
return
}
cancel()
select {
case <-done:
case <-time.After(searchEngineStopTimeout):
w.ps.Log().Warn("Search engine watcher did not stop in time; abandoning goroutine",
mlog.Duration("timeout", searchEngineStopTimeout))
}
}
// reevaluate sends a non-blocking signal to the watcher to re-evaluate engine
// state immediately. Safe to call on a nil receiver.
func (w *searchEngineWatcher) reevaluate() {
if w == nil {
return
}
w.mu.Lock()
ch := w.notifyCh
w.mu.Unlock()
if ch != nil {
select {
case ch <- struct{}{}:
default:
}
}
}
// requestRestart sets a flag that tells the watcher to Stop() the engine
// before its next evaluation cycle, then wakes it for immediate re-evaluation.
// Used when connection settings change or the license is removed.
// Safe to call on a nil receiver.
func (w *searchEngineWatcher) requestRestart() {
if w == nil {
return
}
atomic.StoreInt32(&w.forceRestart, 1)
w.reevaluate()
}
// run is the watcher's main loop. It drives a simple state machine:
//
// (start)
// |
// v
// forceRestart? --yes--> Stop()
// |
// v
// IsEnabled()? --no---> PARK (wait for notify or cancel)
// |
// yes
// |
// v
// IsActive()? --yes---> HEALTH CHECK (periodic)
// | |
// no N consecutive failures
// | |
// v Stop() engine
// RETRY <---------------------+
// |
// | wait (exponential backoff, interruptible)
// v
// Start()
// |--error--> backoff*2 (capped), retry
// |--ok-----> reset backoff, HEALTH CHECK
//
// Any state -- ctx.Done() --> EXIT
// Any state -- reevaluate --> immediate re-evaluation
func (w *searchEngineWatcher) run(ctx context.Context, notifyCh <-chan struct{}) {
if w.engine == nil {
return
}
// Safety net: if the engine is still active when the goroutine exits
// (e.g. Start() completed just as the context was canceled), stop it so
// it is not left running with no goroutine to manage it.
defer func() {
if w.engine.IsActive() {
w.ps.Log().Info("Search engine watcher: stopping engine on goroutine exit",
mlog.String("engine", w.engine.GetName()))
if err := w.engine.Stop(); err != nil {
w.ps.Log().Warn("Search engine watcher: Stop() failed on goroutine exit",
mlog.Err(err),
mlog.String("engine", w.engine.GetName()))
}
}
}()
s := &watcherLoopState{backoff: searchEngineRetryInitial}
rctx := request.EmptyContext(w.ps.logger)
timer := time.NewTimer(0) // immediate first evaluation
defer timer.Stop()
for {
if !w.waitForEvent(ctx, timer, notifyCh, s) {
return
}
w.applyForceRestart()
if w.parkIfDisabled(timer) {
continue
}
if w.startIfInactive(ctx, timer, s) {
continue
}
w.healthCheck(rctx, timer, s)
}
}
// waitForEvent blocks until the timer fires, a notify arrives, or the context
// is cancelled. Returns false when the watcher should exit.
func (w *searchEngineWatcher) waitForEvent(ctx context.Context, timer *time.Timer, notifyCh <-chan struct{}, s *watcherLoopState) bool {
for {
select {
case <-ctx.Done():
return false
case <-notifyCh:
// Something changed -- re-evaluate immediately.
timer.Reset(0)
s.backoff = searchEngineRetryInitial
s.consecutiveFailures = 0
continue
case <-timer.C:
}
// Prioritize shutdown: if select picked timer.C and ctx.Done()
// simultaneously (random when both are ready), exit now.
return ctx.Err() == nil
}
}
// applyForceRestart checks and clears the force-restart flag. If set and the
// engine is active, it stops the engine so the next evaluation cycle will
// create a fresh client.
func (w *searchEngineWatcher) applyForceRestart() {
if !atomic.CompareAndSwapInt32(&w.forceRestart, 1, 0) {
return
}
if w.engine.IsActive() {
w.ps.Log().Info("Search engine watcher: force-restart requested, stopping engine",
mlog.String("engine", w.engine.GetName()))
if err := w.engine.Stop(); err != nil {
w.ps.Log().Warn("Search engine watcher: Stop() failed during force-restart",
mlog.Err(err),
mlog.String("engine", w.engine.GetName()))
}
}
}
// parkIfDisabled stops the engine (if active) and parks the watcher when the
// engine is disabled. Returns true when parked (caller should continue the
// loop without scheduling a timer -- the next wake comes from reevaluate).
func (w *searchEngineWatcher) parkIfDisabled(timer *time.Timer) bool {
if w.engine.IsEnabled() {
return false
}
if w.engine.IsActive() {
if err := w.engine.Stop(); err != nil {
w.ps.Log().Warn("Search engine watcher: Stop() returned error while disabling",
mlog.Err(err),
mlog.String("engine", w.engine.GetName()))
}
}
w.ps.Log().Info("Search engine watcher: engine disabled, parking")
timer.Stop()
return true
}
// parkIfUnlicensed parks the watcher when the server has no Elasticsearch
// license. Returns true when parked (caller should continue the loop).
func (w *searchEngineWatcher) parkIfUnlicensed(timer *time.Timer) bool {
license := w.ps.License()
if license != nil && model.SafeDereference(license.Features.Elasticsearch) {
return false
}
w.ps.Log().Info("Search engine watcher: engine not active (no Elasticsearch license), parking",
mlog.String("engine", w.engine.GetName()))
timer.Stop()
return true
}
// startIfInactive attempts to start the engine when it is not active.
// Returns true when it handled the state (caller should continue the loop).
func (w *searchEngineWatcher) startIfInactive(ctx context.Context, timer *time.Timer, s *watcherLoopState) bool {
if w.engine.IsActive() {
return false
}
if err := w.engine.Start(ctx); err != nil {
s.consecutiveFailures++
w.ps.Log().Error("Search engine watcher: Start() failed, will retry",
mlog.Err(err),
mlog.Int("consecutive_failures", s.consecutiveFailures),
mlog.Duration("next_backoff", s.backoff),
mlog.String("engine", w.engine.GetName()))
timer.Reset(s.backoff)
s.backoff = min(s.backoff*2, searchEngineRetryMax)
return true
}
if ctx.Err() != nil {
return true // shutting down
}
// Start() returned nil but engine may not be active (e.g. no license).
if !w.engine.IsActive() {
if w.parkIfUnlicensed(timer) {
return true
}
s.consecutiveFailures++
w.ps.Log().Warn("Search engine watcher: Start() returned no error but engine is not active, will retry",
mlog.Int("consecutive_failures", s.consecutiveFailures),
mlog.Duration("next_backoff", s.backoff),
mlog.String("engine", w.engine.GetName()))
timer.Reset(s.backoff)
s.backoff = min(s.backoff*2, searchEngineRetryMax)
return true
}
w.ps.Log().Info("Search engine watcher: engine started successfully",
mlog.String("engine", w.engine.GetName()))
s.backoff = searchEngineRetryInitial
s.consecutiveFailures = 0
if model.SafeDereference(w.ps.Config().ElasticsearchSettings.EnableSearchPublicChannelsWithoutMembership) {
engine := w.engine
w.ps.Go(func() {
w.ps.backfillPostsChannelType(engine)
})
}
timer.Reset(searchEngineHealthInterval)
return true
}
// healthCheck runs a periodic health check on an active engine. After
// reaching the consecutive failure threshold it stops the engine and
// schedules a retry; otherwise it schedules the next health check.
func (w *searchEngineWatcher) healthCheck(rctx request.CTX, timer *time.Timer, s *watcherLoopState) {
// Save the previous state so that we can log any state change
wasHealthy := w.engine.IsHealthy()
if err := w.engine.HealthCheck(rctx); err != nil {
// Mark unhealthy on the first failure so the broker skips this
// engine immediately (searches fall back to DB, indexing is
// deferred). The engine stays started until the consecutive
// failure threshold is reached.
w.engine.SetHealthy(false)
if wasHealthy {
w.ps.Log().Warn("Search engine health check failed: it is now marked as unhealthy",
mlog.String("engine", w.engine.GetName()))
}
s.consecutiveFailures++
if s.consecutiveFailures >= searchEngineHealthFailThreshold {
w.ps.Log().Error("Search engine health check failed repeatedly; stopping engine",
mlog.Err(err),
mlog.Int("consecutive_failures", s.consecutiveFailures),
mlog.String("engine", w.engine.GetName()))
if stopErr := w.engine.Stop(); stopErr != nil {
w.ps.Log().Warn("Search engine watcher: Stop() returned error (may already be stopped)",
mlog.Err(stopErr),
mlog.String("engine", w.engine.GetName()))
}
s.consecutiveFailures = 0
s.backoff = searchEngineRetryInitial
timer.Reset(s.backoff)
return
}
w.ps.Log().Warn("Search engine health check failed",
mlog.Err(err),
mlog.Int("consecutive_failures", s.consecutiveFailures),
mlog.Int("threshold", searchEngineHealthFailThreshold),
mlog.String("engine", w.engine.GetName()))
} else {
w.engine.SetHealthy(true)
if !wasHealthy {
w.ps.Log().Info("Search engine health check succeeded: it is now marked as healthy",
mlog.String("engine", w.engine.GetName()))
}
s.consecutiveFailures = 0
}
timer.Reset(searchEngineHealthInterval)
}

View File

@@ -0,0 +1,740 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"context"
"crypto/ecdsa"
"errors"
"fmt"
"hash/maphash"
"net/http"
"runtime"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
"github.com/mattermost/mattermost/server/public/shared/markdown"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/app/featureflag"
"github.com/mattermost/mattermost/server/v8/channels/jobs"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/channels/store/localcachelayer"
"github.com/mattermost/mattermost/server/v8/channels/store/retrylayer"
"github.com/mattermost/mattermost/server/v8/channels/store/searchlayer"
"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore"
"github.com/mattermost/mattermost/server/v8/channels/store/timerlayer"
"github.com/mattermost/mattermost/server/v8/config"
"github.com/mattermost/mattermost/server/v8/einterfaces"
"github.com/mattermost/mattermost/server/v8/platform/services/cache"
"github.com/mattermost/mattermost/server/v8/platform/services/searchengine"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
)
// PlatformService is the service for the platform related tasks. It is
// responsible for non-entity related functionalities that are required
// by a product such as database access, configuration access, licensing etc.
type PlatformService struct {
sqlStore *sqlstore.SqlStore
Store store.Store
newStore func() (store.Store, error)
storeOptions []sqlstore.Option
WebSocketRouter *WebSocketRouter
configStore *config.Store
filestore filestore.FileBackend
exportFilestore filestore.FileBackend
// Channel for batching status updates
statusUpdateChan chan *model.Status
statusUpdateExitSignal chan struct{}
statusUpdateDoneSignal chan struct{}
cacheProvider cache.Provider
statusCache cache.Cache
sessionCache cache.Cache
asymmetricSigningKey atomic.Pointer[ecdsa.PrivateKey]
clientConfig atomic.Value
clientConfigHash atomic.Value
limitedClientConfig atomic.Value
isFirstUserAccountLock sync.Mutex
isFirstUserAccount atomic.Bool
logger *mlog.Logger
startMetrics bool
metrics *platformMetrics
metricsIFace einterfaces.MetricsInterface
featureFlagSynchronizerMutex sync.Mutex
featureFlagSynchronizer *featureflag.Synchronizer
featureFlagStop chan struct{}
featureFlagStopped chan struct{}
licenseValue atomic.Pointer[model.License]
clientLicenseValue atomic.Value
licenseListeners map[string]func(*model.License, *model.License)
licenseManager einterfaces.LicenseInterface
telemetryId string
configListenerId string
licenseListenerId string
clusterLeaderListeners sync.Map
clusterIFace einterfaces.ClusterInterface
Busy *Busy
SearchEngine *searchengine.Broker
searchConfigListenerId string
searchLicenseListenerId string
esWatcher *searchEngineWatcher
ldapDiagnostic einterfaces.LdapDiagnosticInterface
samlDiagnostic einterfaces.SamlDiagnosticInterface
Jobs *jobs.JobServer
hubs []*Hub
hashSeed maphash.Seed
goroutineCount int32
goroutineExitSignal chan struct{}
goroutineBuffered chan struct{}
// goroutineCtx is cancelled when the server begins shutting down. Long-running background
// requests (e.g. outgoing integration requests) derive their context from it so shutdown can
// cancel them instead of blocking on their full configured timeout.
goroutineCtx context.Context
goroutineCancel context.CancelFunc
// Document content extraction runs on a dedicated, bounded worker pool so
// that expensive extractions cannot saturate the generic worker pool and
// block the request goroutines that dispatch them.
extractionQueue chan func()
extractionStop chan struct{}
extractionWG sync.WaitGroup
additionalClusterHandlers map[model.ClusterEvent]einterfaces.ClusterMessageHandler
shareChannelServiceMux sync.RWMutex
sharedChannelService SharedChannelServiceIFace
pluginEnv HookRunner
// This is a test mode setting used to enable Redis
// without a license.
forceEnableRedis bool
pdpService einterfaces.PolicyDecisionPointInterface
startTime time.Time
// installTypeOverride overrides MM_INSTALL_TYPE in support packet diagnostics.
installTypeOverride string
// logRootPathOverride overrides MM_LOG_PATH for log root path validation.
logRootPathOverride string
postDeliveryRecorder func(marker *model.PostDeliveryMarker, userID string)
}
// SetInstallTypeOverride sets the install type override for support packet diagnostics.
func (ps *PlatformService) SetInstallTypeOverride(v string) {
ps.installTypeOverride = v
}
// SetLogRootPathOverride sets the log root path override for log file validation.
func (ps *PlatformService) SetLogRootPathOverride(v string) {
ps.logRootPathOverride = v
}
type HookRunner interface {
RunMultiHook(hookRunnerFunc func(hooks plugin.Hooks, _ *model.Manifest) bool, hookId int)
GetPluginsEnvironment() *plugin.Environment
}
// New creates a new PlatformService.
func New(sc ServiceConfig, options ...Option) (*PlatformService, error) {
// Step 0: Create the PlatformService.
// ConfigStore is and should be handled on a upper level.
ps := &PlatformService{
Store: sc.Store,
clusterIFace: sc.Cluster,
hashSeed: maphash.MakeSeed(),
startTime: time.Now(),
goroutineExitSignal: make(chan struct{}, 1),
goroutineBuffered: make(chan struct{}, runtime.NumCPU()),
extractionQueue: make(chan func(), runtime.NumCPU()),
extractionStop: make(chan struct{}),
WebSocketRouter: &WebSocketRouter{
handlers: make(map[string]webSocketHandler),
},
licenseListeners: map[string]func(*model.License, *model.License){},
additionalClusterHandlers: map[model.ClusterEvent]einterfaces.ClusterMessageHandler{},
statusUpdateChan: make(chan *model.Status, statusUpdateBufferSize),
statusUpdateExitSignal: make(chan struct{}),
statusUpdateDoneSignal: make(chan struct{}),
}
ps.goroutineCtx, ps.goroutineCancel = context.WithCancel(context.Background())
// Assume the first user account has not been created yet. A call to the DB will later check if this is really the case.
ps.isFirstUserAccount.Store(true)
// Apply options, some of the options overrides the default config actually.
for _, option := range options {
if err2 := option(ps); err2 != nil {
return nil, fmt.Errorf("failed to apply option: %w", err2)
}
}
// the config store is not set, we need to create a new one
if ps.configStore == nil {
innerStore, err := config.NewFileStore("config.json", true)
if err != nil {
return nil, fmt.Errorf("failed to load config from file: %w", err)
}
configStore, err := config.NewStoreFromBacking(innerStore, nil, false)
if err != nil {
return nil, fmt.Errorf("failed to load config from file: %w", err)
}
ps.configStore = configStore
}
// Step 1: Start logging.
err := ps.initLogging()
if err != nil {
return nil, fmt.Errorf("failed to initialize logging: %w", err)
}
ps.Log().Info("Server is initializing...", mlog.String("go_version", runtime.Version()))
logCurrentVersion := fmt.Sprintf("Current version is %v (%v/%v/%v/%v)", model.CurrentVersion, model.BuildNumber, model.BuildDate, model.BuildHash, model.BuildHashEnterprise)
ps.Log().Info(
logCurrentVersion,
mlog.String("current_version", model.CurrentVersion),
mlog.String("build_number", model.BuildNumber),
mlog.String("build_date", model.BuildDate),
mlog.String("build_hash", model.BuildHash),
mlog.String("build_hash_enterprise", model.BuildHashEnterprise),
mlog.String("service_environment", model.GetServiceEnvironment()),
)
if model.BuildEnterpriseReady == "true" {
isTrial := false
if licence := ps.License(); licence != nil {
isTrial = licence.IsTrial
}
ps.Log().Info(
"Enterprise Build",
mlog.Bool("enterprise_build", true),
mlog.Bool("is_trial", isTrial),
)
} else {
ps.Log().Info("Team Edition Build", mlog.Bool("enterprise_build", false))
}
// Step 2: Cache provider.
cacheConfig := ps.configStore.Get().CacheSettings
if *cacheConfig.CacheType == model.CacheTypeLRU {
ps.cacheProvider = cache.NewProvider()
} else if *cacheConfig.CacheType == model.CacheTypeRedis {
ps.cacheProvider, err = cache.NewRedisProvider(
&cache.RedisOptions{
RedisAddr: *cacheConfig.RedisAddress,
RedisPassword: *cacheConfig.RedisPassword,
RedisDB: *cacheConfig.RedisDB,
RedisCachePrefix: *cacheConfig.RedisCachePrefix,
DisableCache: *cacheConfig.DisableClientCache,
},
)
}
if err != nil {
return nil, fmt.Errorf("unable to create cache provider: %w", err)
}
res, err := ps.cacheProvider.Connect()
if err != nil {
return nil, fmt.Errorf("unable to connect to cache provider: %w", err)
}
ps.Log().Info("Successfully connected to cache backend", mlog.String("backend", *cacheConfig.CacheType), mlog.String("result", res))
// Step 3: Search Engine
searchEngine := searchengine.NewBroker(ps.Config())
ps.SearchEngine = searchEngine
// Step 4: Init Enterprise
// Depends on step 3 (s.SearchEngine must be non-nil)
ps.initEnterprise()
// Step 5: Init Metrics
if metricsInterfaceFn != nil && ps.metricsIFace == nil { // if the metrics interface is set by options, do not override it
ps.metricsIFace = metricsInterfaceFn(ps, *ps.configStore.Get().SqlSettings.DriverName, *ps.configStore.Get().SqlSettings.DataSource)
}
ps.cacheProvider.SetMetrics(ps.metricsIFace)
// Step 6: Store.
// Depends on Step 0 (config), 1 (cacheProvider), 3 (search engine), 5 (metrics) and cluster.
if ps.newStore == nil {
ps.newStore = func() (store.Store, error) {
// The layer cake is as follows: (From bottom to top)
// SQL layer
// |
// Retry layer
// |
// Search layer
// |
// Timer layer
// |
// Cache layer
opts := append(ps.storeOptions, sqlstore.WithFeatureFlags(func() *model.FeatureFlags {
return ps.Config().FeatureFlags
}))
ps.sqlStore, err = sqlstore.New(ps.Config().SqlSettings, ps.Log(), ps.metricsIFace, opts...)
if err != nil {
return nil, err
}
searchStore := searchlayer.NewSearchLayer(
retrylayer.New(ps.sqlStore),
ps.SearchEngine,
ps.Config(),
)
ps.AddConfigListener(func(prevCfg, cfg *model.Config) {
searchStore.UpdateConfig(cfg)
})
lcl, err2 := localcachelayer.NewLocalCacheLayer(
timerlayer.New(searchStore, ps.metricsIFace),
ps.metricsIFace,
ps.clusterIFace,
ps.cacheProvider,
ps.Log(),
)
if err2 != nil {
return nil, fmt.Errorf("cannot create local cache layer: %w", err2)
}
license := ps.License()
ps.sqlStore.UpdateLicense(license)
ps.AddLicenseListener(func(oldLicense, newLicense *model.License) {
ps.sqlStore.UpdateLicense(newLicense)
})
return lcl, nil
}
}
ps.Store, err = ps.newStore()
if err != nil {
return nil, fmt.Errorf("cannot create store: %w", err)
}
// The markdown package needs to know what the maximum post size is, so we
// let it know once the store is created.
markdown.SetMaxPostRunes(ps.MaxPostSize())
// Step 7: initialize status and session cache.
// We need to do this because ps.LoadLicense() called in step 8, could
// end up calling InvalidateAllCaches, so the status and session caches
// need to be initialized before that.
// Note: we hardcode the session and status cache to LRU because they lead
// to a lot of SCAN calls in case of Redis. We could potentially have a
// reverse mapping to avoid the scan, but this needs more complicated code.
// Leaving this for now.
ps.statusCache, err = cache.NewProvider().NewCache(&cache.CacheOptions{
Name: "Status",
Size: model.StatusCacheSize,
Striped: true,
StripedBuckets: max(runtime.NumCPU()-1, 1),
DefaultExpiry: 30 * time.Minute,
})
if err != nil {
return nil, fmt.Errorf("unable to create status cache: %w", err)
}
ps.sessionCache, err = cache.NewProvider().NewCache(&cache.CacheOptions{
Name: "Session",
Size: model.SessionCacheSize,
Striped: true,
StripedBuckets: max(runtime.NumCPU()-1, 1),
})
if err != nil {
return nil, fmt.Errorf("could not create session cache: %w", err)
}
// Step 8: Init License
if model.BuildEnterpriseReady == "true" {
ps.LoadLicense()
}
license := ps.License()
// This is a hack because ideally we wouldn't even have started the Redis client
// if the license didn't have clustering. But there's an intricate deadlock
// where license cannot be loaded before store, and store cannot be loaded before
// cache. So loading license before loading cache is an uphill battle.
if (license == nil || !*license.Features.Cluster) && *cacheConfig.CacheType == model.CacheTypeRedis && !ps.forceEnableRedis {
return nil, fmt.Errorf("Redis cannot be used in an instance without a license or a license without clustering")
}
// Step 9: Initialize filestore
if ps.filestore == nil {
insecure := ps.Config().ServiceSettings.EnableInsecureOutgoingConnections
allowedUntrustedInternalConnections := model.SafeDereference(ps.Config().ServiceSettings.AllowedUntrustedInternalConnections)
backend, err2 := filestore.NewFileBackend(filestore.NewFileBackendSettingsFromConfig(&ps.Config().FileSettings, license != nil && *license.Features.Compliance, insecure != nil && *insecure, allowedUntrustedInternalConnections))
if err2 != nil {
return nil, fmt.Errorf("failed to initialize filebackend: %w", err2)
}
ps.filestore = backend
}
if ps.exportFilestore == nil {
ps.exportFilestore = ps.filestore
if *ps.Config().FileSettings.DedicatedExportStore {
mlog.Info("Setting up dedicated export filestore", mlog.String("driver_name", *ps.Config().FileSettings.ExportDriverName))
allowedUntrustedInternalConnections := model.SafeDereference(ps.Config().ServiceSettings.AllowedUntrustedInternalConnections)
backend, errFileBack := filestore.NewExportFileBackend(filestore.NewExportFileBackendSettingsFromConfig(&ps.Config().FileSettings, license != nil && *license.Features.Compliance, false, allowedUntrustedInternalConnections))
if errFileBack != nil {
return nil, fmt.Errorf("failed to initialize export filebackend: %w", errFileBack)
}
ps.exportFilestore = backend
}
}
// Step 10: Init Metrics Server depends on step 6 (store) and 8 (license)
if ps.startMetrics {
if mErr := ps.resetMetrics(); mErr != nil {
return nil, mErr
}
ps.configStore.AddListener(func(oldCfg, newCfg *model.Config) {
if *oldCfg.MetricsSettings.Enable != *newCfg.MetricsSettings.Enable || *oldCfg.MetricsSettings.ListenAddress != *newCfg.MetricsSettings.ListenAddress {
if mErr := ps.resetMetrics(); mErr != nil {
mlog.Warn("Failed to reset metrics", mlog.Err(mErr))
}
}
})
}
// Step 11: Init AsymmetricSigningKey depends on step 6 (store)
if err = ps.EnsureAsymmetricSigningKey(); err != nil {
return nil, fmt.Errorf("unable to ensure asymmetric signing key: %w", err)
}
ps.Busy = NewBusy(ps.clusterIFace)
// Enable developer settings and mmctl local mode if this is a "dev" build
if model.BuildNumber == "dev" {
ps.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableDeveloper = true
*cfg.ServiceSettings.EnableLocalMode = true
})
}
ps.AddLicenseListener(func(oldLicense, newLicense *model.License) {
wasLicensed := (oldLicense != nil && *oldLicense.Features.Metrics) || (model.BuildNumber == "dev")
isLicensed := (newLicense != nil && *newLicense.Features.Metrics) || (model.BuildNumber == "dev")
if wasLicensed == isLicensed || !ps.startMetrics {
return
}
if err := ps.RestartMetrics(); err != nil {
ps.logger.Error("Failed to reset metrics server", mlog.Err(err))
}
})
if err := ps.SearchEngine.UpdateConfig(ps.Config()); err != nil {
ps.logger.Error("Failed to update search engine config", mlog.Err(err))
}
searchConfigListenerId, searchLicenseListenerId := ps.StartSearchEngine()
ps.searchConfigListenerId = searchConfigListenerId
ps.searchLicenseListenerId = searchLicenseListenerId
ps.startExtractionWorkers()
return ps, nil
}
func (ps *PlatformService) Start(broadcastHooks map[string]BroadcastHook) error {
// Start the status update processor.
// Must be done before hub start.
go ps.processStatusUpdates()
ps.hubStart(broadcastHooks)
ps.configListenerId = ps.AddConfigListener(func(_, _ *model.Config) {
ps.regenerateClientConfig()
message := model.NewWebSocketEvent(model.WebsocketEventConfigChanged, "", "", "", nil, "")
message.Add("config", ps.ClientConfigWithComputed())
ps.Go(func() {
ps.Publish(message)
})
if err := ps.ReconfigureLogger(); err != nil {
mlog.Error("Error re-configuring logging after config change", mlog.Err(err))
return
}
})
ps.licenseListenerId = ps.AddLicenseListener(func(oldLicense, newLicense *model.License) {
ps.regenerateClientConfig()
message := model.NewWebSocketEvent(model.WebsocketEventLicenseChanged, "", "", "", nil, "")
message.Add("license", ps.GetSanitizedClientLicense())
ps.Publish(message)
})
return nil
}
func (ps *PlatformService) ShutdownMetrics() error {
if ps.metrics != nil {
return ps.metrics.stopMetricsServer()
}
return nil
}
// GetMetricsRouter returns the metrics router. This is primarily used for testing.
func (ps *PlatformService) GetMetricsRouter() http.Handler {
if ps.metrics != nil {
return ps.metrics.router
}
return nil
}
func (ps *PlatformService) ShutdownConfig() error {
ps.RemoveConfigListener(ps.configListenerId)
if ps.configStore != nil {
err := ps.configStore.Close()
if err != nil {
return fmt.Errorf("failed to close config store: %w", err)
}
}
return nil
}
func (ps *PlatformService) SetTelemetryId(id string) {
ps.telemetryId = id
ps.PostTelemetryIdHook()
}
// PostTelemetryIdHook triggers necessary events to propagate telemtery ID
func (ps *PlatformService) PostTelemetryIdHook() {
ps.regenerateClientConfig()
}
func (ps *PlatformService) SetLogger(logger *mlog.Logger) {
ps.logger = logger
}
func (ps *PlatformService) initEnterprise() {
if clusterInterface != nil && ps.clusterIFace == nil {
ps.clusterIFace = clusterInterface(ps)
}
if elasticsearchInterface != nil {
ps.SearchEngine.RegisterElasticsearchEngine(elasticsearchInterface(ps))
}
if ldapDiagnosticInterface != nil {
ps.ldapDiagnostic = ldapDiagnosticInterface(ps)
}
if samlDiagnosticInterface != nil {
ps.samlDiagnostic = samlDiagnosticInterface(ps)
}
if licenseInterface != nil {
ps.licenseManager = licenseInterface(ps)
}
if accessControlServiceInterface != nil {
ps.pdpService = accessControlServiceInterface(ps)
}
}
func (ps *PlatformService) TotalWebsocketConnections() int {
// This method is only called after the hub is initialized.
// Therefore, no mutex is needed to protect s.hubs.
count := int64(0)
for _, hub := range ps.hubs {
count = count + atomic.LoadInt64(&hub.connectionCount)
}
return int(count)
}
func (ps *PlatformService) Shutdown() error {
// Deferred so it still runs even if a later step below returns early
// (e.g. cacheProvider.Close failing). Must run last: every other step
// below (notably HubStop) is a candidate to still attempt a cluster send
// after StopInterNodeCommunication has already run, so the cluster
// interface's own shutdown accounting needs everything below to have
// already happened, which defer guarantees regardless of the early return.
if ps.clusterIFace != nil {
defer ps.clusterIFace.Shutdown()
}
ps.HubStop()
// Shutdown status processor.
// Must be done after hub shutdown.
close(ps.statusUpdateExitSignal)
// wait for it to be stopped.
<-ps.statusUpdateDoneSignal
ps.RemoveLicenseListener(ps.licenseListenerId)
// Stop the document extraction workers and wait for any in-flight
// extraction to finish before closing the store it depends on.
ps.stopExtractionWorkers()
// Cancel in-flight background requests (e.g. outgoing integration requests) derived from
// goroutineCtx so shutdown isn't blocked by their full configured timeout.
if ps.goroutineCancel != nil {
ps.goroutineCancel()
}
// we need to wait the goroutines to finish before closing the store
// and this needs to be called after hub stop because hub generates goroutines
// when it is active. If we wait first we have no mechanism to prevent adding
// more go routines hence they still going to be invoked.
ps.waitForGoroutines()
if ps.Store != nil {
ps.Store.Close()
}
if ps.cacheProvider != nil {
if err := ps.cacheProvider.Close(); err != nil {
return fmt.Errorf("unable to cleanly shutdown cache: %w", err)
}
}
return nil
}
func (ps *PlatformService) CacheProvider() cache.Provider {
return ps.cacheProvider
}
// SetSqlStore is used for plugin testing
func (ps *PlatformService) SetSqlStore(s *sqlstore.SqlStore) {
ps.sqlStore = s
}
func (ps *PlatformService) SetSharedChannelService(s SharedChannelServiceIFace) {
ps.shareChannelServiceMux.Lock()
defer ps.shareChannelServiceMux.Unlock()
ps.sharedChannelService = s
}
func (ps *PlatformService) GetSharedChannelService() SharedChannelServiceIFace {
ps.shareChannelServiceMux.RLock()
defer ps.shareChannelServiceMux.RUnlock()
return ps.sharedChannelService
}
func (ps *PlatformService) SetPluginsEnvironment(runner HookRunner) {
ps.pluginEnv = runner
}
func (ps *PlatformService) SetPostDeliveryRecorder(fn func(marker *model.PostDeliveryMarker, userID string)) {
ps.postDeliveryRecorder = fn
}
// GetPluginStatuses meant to be used by cluster implementation
func (ps *PlatformService) GetPluginStatuses() (model.PluginStatuses, *model.AppError) {
if ps.pluginEnv == nil || ps.pluginEnv.GetPluginsEnvironment() == nil {
return nil, model.NewAppError("GetPluginStatuses", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented)
}
pluginStatuses, err := ps.pluginEnv.GetPluginsEnvironment().Statuses()
if err != nil {
return nil, model.NewAppError("GetPluginStatuses", "app.plugin.get_statuses.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Add our cluster ID
for _, status := range pluginStatuses {
if ps.Cluster() != nil {
status.ClusterId = ps.Cluster().GetClusterId()
} else {
status.ClusterId = ""
}
}
return pluginStatuses, nil
}
func (ps *PlatformService) getPluginManifests() ([]*model.Manifest, error) {
if ps.pluginEnv == nil {
return nil, errors.New("plugin environment not initialized")
}
pluginsEnvironment := ps.pluginEnv.GetPluginsEnvironment()
if pluginsEnvironment == nil {
return nil, model.NewAppError("getPluginManifests", "app.plugin.disabled.app_error", nil, "", http.StatusNotImplemented)
}
plugins, err := pluginsEnvironment.Available()
if err != nil {
return nil, fmt.Errorf("failed to get list of available plugins: %w", err)
}
manifests := make([]*model.Manifest, len(plugins))
for i := range plugins {
manifests[i] = plugins[i].Manifest
}
return manifests, nil
}
func (ps *PlatformService) FileBackend() filestore.FileBackend {
return ps.filestore
}
func (ps *PlatformService) ExportFileBackend() filestore.FileBackend {
return ps.exportFilestore
}
func (ps *PlatformService) LdapDiagnostic() einterfaces.LdapDiagnosticInterface {
return ps.ldapDiagnostic
}
func (ps *PlatformService) SamlDiagnostic() einterfaces.SamlDiagnosticInterface {
return ps.samlDiagnostic
}
// DatabaseTypeAndSchemaVersion returns the database type and current version of the schema
func (ps *PlatformService) DatabaseTypeAndSchemaVersion() (string, string, error) {
schemaVersion, err := ps.Store.GetDBSchemaVersion()
if err != nil {
return "", "", err
}
return model.SafeDereference(ps.Config().SqlSettings.DriverName), strconv.Itoa(schemaVersion), nil
}

View File

@@ -0,0 +1,264 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"math/rand"
"net/http"
"os"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/markdown"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
"github.com/mattermost/mattermost/server/v8/config"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
)
// A copy of validTestLicense from channels/utils/license_test.go
var validTestLicense = []byte("eyJpZCI6InpvZ3c2NW44Z2lmajVkbHJoYThtYnUxcGl3IiwiaXNzdWVkX2F0IjoxNjg0Nzg3MzcxODY5LCJzdGFydHNfYXQiOjE2ODQ3ODczNzE4NjksImV4cGlyZXNfYXQiOjIwMDA0MDY1MzgwMDAsInNrdV9uYW1lIjoiUHJvZmVzc2lvbmFsIiwic2t1X3Nob3J0X25hbWUiOiJwcm9mZXNzaW9uYWwiLCJjdXN0b21lciI6eyJpZCI6InA5dW4zNjlhNjdnaW1qNHlkNmk2aWIzOXdoIiwibmFtZSI6Ik1hdHRlcm1vc3QiLCJlbWFpbCI6ImpvcmFtQG1hdHRlcm1vc3QuY29tIiwiY29tcGFueSI6Ik1hdHRlcm1vc3QifSwiZmVhdHVyZXMiOnsidXNlcnMiOjIwMDAwMCwibGRhcCI6dHJ1ZSwibGRhcF9ncm91cHMiOmZhbHNlLCJtZmEiOnRydWUsImdvb2dsZV9vYXV0aCI6dHJ1ZSwib2ZmaWNlMzY1X29hdXRoIjp0cnVlLCJjb21wbGlhbmNlIjpmYWxzZSwiY2x1c3RlciI6dHJ1ZSwibWV0cmljcyI6dHJ1ZSwibWhwbnMiOnRydWUsInNhbWwiOnRydWUsImVsYXN0aWNfc2VhcmNoIjp0cnVlLCJhbm5vdW5jZW1lbnQiOnRydWUsInRoZW1lX21hbmFnZW1lbnQiOmZhbHNlLCJlbWFpbF9ub3RpZmljYXRpb25fY29udGVudHMiOmZhbHNlLCJkYXRhX3JldGVudGlvbiI6ZmFsc2UsIm1lc3NhZ2VfZXhwb3J0IjpmYWxzZSwiY3VzdG9tX3Blcm1pc3Npb25zX3NjaGVtZXMiOmZhbHNlLCJjdXN0b21fdGVybXNfb2Zfc2VydmljZSI6ZmFsc2UsImd1ZXN0X2FjY291bnRzIjp0cnVlLCJndWVzdF9hY2NvdW50c19wZXJtaXNzaW9ucyI6dHJ1ZSwiaWRfbG9hZGVkIjpmYWxzZSwibG9ja190ZWFtbWF0ZV9uYW1lX2Rpc3BsYXkiOmZhbHNlLCJjbG91ZCI6ZmFsc2UsInNoYXJlZF9jaGFubmVscyI6ZmFsc2UsInJlbW90ZV9jbHVzdGVyX3NlcnZpY2UiOmZhbHNlLCJvcGVuaWQiOnRydWUsImVudGVycHJpc2VfcGx1Z2lucyI6dHJ1ZSwiYWR2YW5jZWRfbG9nZ2luZyI6dHJ1ZSwiZnV0dXJlX2ZlYXR1cmVzIjpmYWxzZX0sImlzX3RyaWFsIjp0cnVlLCJpc19nb3Zfc2t1IjpmYWxzZX0bEOVk2GdE1kSWKJ3dENWnkj0htY6QyXTtNA5hqnQ71Uc6teqXc7htHAxrnT/hV42xu+G24OMrAIsQtX4NjFSX6jvehIMRL5II3RPXYhHKUd2wruQ5ITEh1htFb5DgOJW3tvBdMmXt09nXjLRS1UYJ7ZsX3mU0uQndt7qfMriGAkk71veYuUJgztB3MsV7lRWB+8ZTp6WJ7RH+uWnuDspiA8B85mLnyuoCDokYksF2uIb+CtPGBTUB6qSOgxBBJxu5qftQXISCDAWY4O8lCrN3p5HCA/zf/rSRRNtet06QFobbjUDI4B7ZEAescKBKoHpP6nZPhg4KmhnkUi/o04ox")
func TestSetLicenseOnStart(t *testing.T) {
oldValue := model.BuildEnterpriseReady
defer func() { model.BuildEnterpriseReady = oldValue }()
model.BuildEnterpriseReady = "true"
cfg := model.Config{}
cfg.SetDefaults()
f, err := os.CreateTemp("", "TestSetLicenseOnStart")
require.NoError(t, err)
defer os.Remove(f.Name())
require.NoError(t, os.WriteFile(f.Name(), validTestLicense, 0777))
*cfg.ServiceSettings.LicenseFileLocation = f.Name()
driverName := os.Getenv("MM_SQLSETTINGS_DRIVERNAME")
if driverName == "" {
driverName = model.DatabaseDriverPostgres
}
cfg.SqlSettings = *storetest.MakeSqlSettings(driverName)
configStore := config.NewTestMemoryStore()
_, _, err = configStore.Set(&cfg)
require.NoError(t, err)
// It should not panic when ps.LoadLicense gets called from platform.New
_, err = New(
ServiceConfig{},
ConfigStore(configStore),
)
require.NoError(t, err)
}
func TestReadReplicaDisabledBasedOnLicense(t *testing.T) {
mainHelper.Parallel(t)
cfg := model.Config{}
cfg.SetDefaults()
driverName := os.Getenv("MM_SQLSETTINGS_DRIVERNAME")
if driverName == "" {
driverName = model.DatabaseDriverPostgres
}
cfg.SqlSettings = *storetest.MakeSqlSettings(driverName)
cfg.SqlSettings.DataSourceReplicas = []string{*cfg.SqlSettings.DataSource}
cfg.SqlSettings.DataSourceSearchReplicas = []string{*cfg.SqlSettings.DataSource}
t.Run("Read Replicas with no License", func(t *testing.T) {
configStore := config.NewTestMemoryStore()
_, _, err := configStore.Set(&cfg)
require.NoError(t, err)
ps, err := New(
ServiceConfig{},
ConfigStore(configStore),
)
require.NoError(t, err)
require.Same(t, ps.sqlStore.GetMaster(), ps.sqlStore.GetReplica())
require.Len(t, ps.Config().SqlSettings.DataSourceReplicas, 1)
})
t.Run("Read Replicas With License", func(t *testing.T) {
configStore := config.NewTestMemoryStore()
_, _, err := configStore.Set(&cfg)
require.NoError(t, err)
ps, err := New(
ServiceConfig{},
ConfigStore(configStore),
func(ps *PlatformService) error {
ps.licenseValue.Store(model.NewTestLicense())
return nil
},
)
require.NoError(t, err)
require.NotSame(t, ps.sqlStore.GetMaster(), ps.sqlStore.GetReplica())
require.Len(t, ps.Config().SqlSettings.DataSourceReplicas, 1)
})
t.Run("Search Replicas with no License", func(t *testing.T) {
configStore := config.NewTestMemoryStore()
_, _, err := configStore.Set(&cfg)
require.NoError(t, err)
ps, err := New(
ServiceConfig{},
ConfigStore(configStore),
)
require.NoError(t, err)
require.Same(t, ps.sqlStore.GetMaster(), ps.sqlStore.GetSearchReplicaX())
require.Len(t, ps.Config().SqlSettings.DataSourceSearchReplicas, 1)
})
t.Run("Search Replicas With License", func(t *testing.T) {
configStore := config.NewTestMemoryStore()
_, _, err := configStore.Set(&cfg)
require.NoError(t, err)
ps, err := New(
ServiceConfig{},
ConfigStore(configStore),
func(ps *PlatformService) error {
ps.licenseValue.Store(model.NewTestLicense())
return nil
},
)
require.NoError(t, err)
require.NotSame(t, ps.sqlStore.GetMaster(), ps.sqlStore.GetSearchReplicaX())
require.Len(t, ps.Config().SqlSettings.DataSourceSearchReplicas, 1)
})
}
func TestMetrics(t *testing.T) {
mainHelper.Parallel(t)
t.Run("ensure the metrics server is not started by default", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
require.Nil(t, th.Service.metrics)
})
t.Run("ensure the metrics server is started", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t, StartMetrics())
// there is no config listener for the metrics
// we handle it on config save step
cfg := th.Service.Config().Clone()
cfg.MetricsSettings.Enable = new(true)
_, _, appErr := th.Service.SaveConfig(cfg, false)
require.Nil(t, appErr)
require.NotNil(t, th.Service.metrics)
metricsAddr := strings.Replace(th.Service.metrics.listenAddr, "[::]", "http://localhost", 1)
metricsAddr = strings.Replace(metricsAddr, "127.0.0.1", "http://localhost", 1)
resp, err := http.Get(metricsAddr)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
cfg.MetricsSettings.Enable = new(false)
_, _, appErr = th.Service.SaveConfig(cfg, false)
require.Nil(t, appErr)
_, err = http.Get(metricsAddr)
require.Error(t, err)
})
t.Run("ensure the metrics server is started with advanced metrics", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t, StartMetrics())
mockMetricsImpl := &mocks.MetricsInterface{}
mockMetricsImpl.On("Register").Return()
th.Service.metricsIFace = mockMetricsImpl
err := th.Service.resetMetrics()
require.NoError(t, err)
mockMetricsImpl.AssertExpectations(t)
})
t.Run("ensure advanced metrics have database metrics", func(t *testing.T) {
mainHelper.Parallel(t)
mockMetricsImpl := &mocks.MetricsInterface{}
mockMetricsImpl.On("Register").Return()
mockMetricsImpl.On("ObserveStoreMethodDuration", mock.Anything, mock.Anything, mock.Anything).Return()
mockMetricsImpl.On("RegisterDBCollector", mock.AnythingOfType("*sql.DB"), "master")
th := Setup(t, StartMetrics(), func(ps *PlatformService) error {
ps.metricsIFace = mockMetricsImpl
return nil
})
_ = th.CreateUserOrGuest(t, false)
th.Shutdown(t)
mockMetricsImpl.AssertExpectations(t)
})
}
func TestShutdown(t *testing.T) {
mainHelper.Parallel(t)
t.Run("should shutdown gracefully", func(t *testing.T) {
th := Setup(t)
rand.Seed(time.Now().UnixNano())
// we create plenty of go routines to make sure we wait for all of them
// to finish before shutting down
for range 1000 {
th.Service.Go(func() {
time.Sleep(time.Millisecond * time.Duration(rand.Intn(20)))
})
}
th.Shutdown(t)
// assert that there are no more go routines running
require.Zero(t, atomic.LoadInt32(&th.Service.goroutineCount))
})
}
func TestSetTelemetryId(t *testing.T) {
mainHelper.Parallel(t)
t.Run("ensure client config is regenerated after setting the telemetry id", func(t *testing.T) {
th := Setup(t)
clientConfig := th.Service.LimitedClientConfig()
require.Empty(t, clientConfig["DiagnosticId"])
id := model.NewId()
th.Service.SetTelemetryId(id)
clientConfig = th.Service.LimitedClientConfig()
require.Equal(t, clientConfig["DiagnosticId"], id)
})
}
func TestDatabaseTypeAndMattermostVersion(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
databaseType, schemaVersion, err := th.Service.DatabaseTypeAndSchemaVersion()
require.NoError(t, err)
assert.Equal(t, "postgres", databaseType)
// It's hard to check whether the schema version is correct or not.
// So, we just check if it's greater than 1.
assert.GreaterOrEqual(t, schemaVersion, strconv.Itoa(1))
}
func TestNewSyncsMarkdownMaxLenWithMaxPostSize(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
// markdown.SetMaxPostRunes is package-global; parallel tests (including
// markdown's own tests) can overwrite it between Setup and this check.
require.Eventually(t, func() bool {
maxPostSize := th.Service.MaxPostSize()
return markdown.MaxLen() == 4*maxPostSize
}, 5*time.Second, 10*time.Millisecond)
}

View File

@@ -0,0 +1,344 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"errors"
"fmt"
"strconv"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/platform/services/cache"
)
var (
ErrEmptyDeviceId = errors.New("device id is empty")
ErrEmptyVoIPDeviceId = errors.New("voip device id is empty")
)
func (ps *PlatformService) CreateSession(rctx request.CTX, session *model.Session) (*model.Session, error) {
session.Token = ""
session, err := ps.Store.Session().Save(rctx, session)
if err != nil {
return nil, err
}
if err := ps.AddSessionToCache(session); err != nil {
ps.Logger().Error("Failed to add session to cache", mlog.Err(err))
}
return session, nil
}
func (ps *PlatformService) GetSessionContext(rctx request.CTX, token string) (*model.Session, error) {
return ps.Store.Session().Get(rctx, token)
}
func (ps *PlatformService) GetSessions(rctx request.CTX, userID string) ([]*model.Session, error) {
return ps.Store.Session().GetSessions(rctx, userID)
}
func (ps *PlatformService) GetLRUSessions(rctx request.CTX, userID string, limit uint64, offset uint64) ([]*model.Session, error) {
return ps.Store.Session().GetLRUSessions(rctx, userID, limit, offset)
}
func (ps *PlatformService) AddSessionToCache(session *model.Session) error {
return ps.sessionCache.SetWithExpiry(session.Token, session, time.Duration(int64(*ps.Config().ServiceSettings.SessionCacheInMinutes))*time.Minute)
}
func (ps *PlatformService) ClearUserSessionCacheLocal(userID string) {
var toDelete []string
// First, we iterate over the entire session cache.
err := ps.sessionCache.Scan(func(keys []string) error {
if len(keys) == 0 {
return nil
}
// This always needs to be model.Session, not *model.Session.
// Otherwise the msp unmarshaler will fail to work.
toPass := allocateCacheTargets[model.Session](len(keys))
errs := ps.sessionCache.GetMulti(keys, toPass)
for i, err := range errs {
if err != nil {
if err != cache.ErrKeyNotFound {
return err
}
continue
}
gotSession := toPass[i].(*model.Session)
if gotSession == nil {
ps.logger.Warn("Found nil session in ClearUserSessionCacheLocal. This is not expected")
continue
}
// If we find the userID matches the passed userID,
// we mark it up for deletion.
if gotSession.UserId == userID {
toDelete = append(toDelete, keys[i])
if m := ps.metricsIFace; m != nil {
m.IncrementMemCacheInvalidationCounterSession()
}
}
}
return nil
})
if err != nil {
ps.logger.Warn("Error while scanning in ClearUserSessionCacheLocal", mlog.Err(err))
return
}
// Now, we delete everything.
err = ps.sessionCache.RemoveMulti(toDelete)
if err != nil {
ps.logger.Warn("Error while removing keys in ClearUserSessionCacheLocal", mlog.Err(err))
return
}
}
func (ps *PlatformService) ClearAllUsersSessionCacheLocal() error {
return ps.sessionCache.Purge()
}
func (ps *PlatformService) ClearUserSessionCache(userID string) {
ps.ClearSessionCacheForUserSkipClusterSend(userID)
if ps.clusterIFace != nil {
msg := &model.ClusterMessage{
Event: model.ClusterEventClearSessionCacheForUser,
SendType: model.ClusterSendReliable,
Data: []byte(userID),
}
ps.clusterIFace.SendClusterMessage(msg)
}
}
func (ps *PlatformService) ClearAllUsersSessionCache() error {
// Mirrors the per-user shape: the SkipClusterSend helper handles the
// local cache purge and the WebConn hub fan-out, then we broadcast to
// peer nodes. The broadcast still runs on local-purge failure so peers
// can act independently.
err := ps.ClearSessionCacheForAllUsersSkipClusterSend()
if ps.clusterIFace != nil {
msg := &model.ClusterMessage{
Event: model.ClusterEventClearSessionCacheForAllUsers,
SendType: model.ClusterSendReliable,
}
ps.clusterIFace.SendClusterMessage(msg)
}
return err
}
func (ps *PlatformService) invalidateSessionAttributes(sessionID string) {
if !ps.Config().FeatureFlags.SessionAttributes || !model.MinimumEnterpriseAdvancedLicense(ps.License()) {
return
}
if err := ps.Store.SessionAttribute().Invalidate(sessionID); err != nil {
ps.logger.Warn("Failed to invalidate session attributes", mlog.String("session_id", sessionID), mlog.Err(err))
}
}
func (ps *PlatformService) GetSession(rctx request.CTX, token string) (*model.Session, error) {
var session model.Session
if err := ps.sessionCache.Get(token, &session); err == nil {
if m := ps.metricsIFace; m != nil {
m.IncrementMemCacheHitCounterSession()
}
} else {
if m := ps.metricsIFace; m != nil {
m.IncrementMemCacheMissCounterSession()
}
}
if session.Id != "" {
return &session, nil
}
return ps.GetSessionContext(rctx, token)
}
func (ps *PlatformService) GetSessionByID(rctx request.CTX, sessionID string) (*model.Session, error) {
return ps.Store.Session().Get(rctx, sessionID)
}
func (ps *PlatformService) RevokeSessionsFromAllUsers() error {
// revoke tokens before sessions so they can't be used to relogin
nErr := ps.Store.OAuth().RemoveAllAccessData()
if nErr != nil {
return fmt.Errorf("%s: %w", nErr.Error(), DeleteAllAccessDataError)
}
err := ps.Store.Session().RemoveAllSessions()
if err != nil {
return err
}
if err := ps.ClearAllUsersSessionCache(); err != nil {
ps.logger.Error("Failed to clear session cache", mlog.Err(err))
}
if err := ps.Store.SessionAttribute().Clear(); err != nil {
ps.logger.Error("Failed to clear session attribute cache", mlog.Err(err))
}
return nil
}
func (ps *PlatformService) RevokeOtherSessionsForDeviceId(rctx request.CTX, userID string, deviceId string, currentSessionId string) error {
if deviceId == "" {
return ErrEmptyDeviceId
}
sessions, err := ps.Store.Session().GetSessions(rctx, userID)
if err != nil {
return err
}
for _, session := range sessions {
if session.DeviceId == deviceId && session.Id != currentSessionId {
rctx.Logger().Debug("Revoking sessionId for userId. Re-login with the same device Id", mlog.String("session_id", session.Id), mlog.String("user_id", userID))
if err := ps.RevokeSession(rctx, session); err != nil {
rctx.Logger().Warn("Could not revoke session for device", mlog.String("session_id", session.Id), mlog.Err(err))
}
}
}
return nil
}
func (ps *PlatformService) RevokeOtherSessionsForVoIPDeviceId(rctx request.CTX, userID string, voIPDeviceId string, currentSessionId string) error {
if voIPDeviceId == "" {
return ErrEmptyVoIPDeviceId
}
sessions, err := ps.Store.Session().GetSessions(rctx, userID)
if err != nil {
return err
}
for _, session := range sessions {
if session.VoIPDeviceId == voIPDeviceId && session.Id != currentSessionId {
rctx.Logger().Debug("Revoking sessionId for userId. Re-login with the same VoIP device Id", mlog.String("session_id", session.Id), mlog.String("user_id", userID))
if err := ps.RevokeSession(rctx, session); err != nil {
rctx.Logger().Warn("Could not revoke session for VoIP device", mlog.String("session_id", session.Id), mlog.Err(err))
}
}
}
return nil
}
func (ps *PlatformService) RevokeSession(rctx request.CTX, session *model.Session) error {
if session.IsOAuth {
if err := ps.RevokeAccessToken(rctx, session.Token); err != nil {
return err
}
} else {
if err := ps.Store.Session().Remove(session.Id); err != nil {
return fmt.Errorf("%s: %w", err.Error(), DeleteSessionError)
}
}
ps.ClearUserSessionCache(session.UserId)
ps.invalidateSessionAttributes(session.Id)
return nil
}
func (ps *PlatformService) RevokeAccessToken(rctx request.CTX, token string) error {
session, _ := ps.GetSession(rctx, token)
schan := make(chan error, 1)
go func() {
schan <- ps.Store.Session().Remove(token)
close(schan)
}()
if _, err := ps.Store.OAuth().GetAccessData(token); err != nil {
return fmt.Errorf("%s: %w", err.Error(), GetTokenError)
}
if err := ps.Store.OAuth().RemoveAccessData(token); err != nil {
return fmt.Errorf("%s: %w", err.Error(), DeleteTokenError)
}
if err := <-schan; err != nil {
return fmt.Errorf("%s: %w", err.Error(), DeleteSessionError)
}
if session != nil {
ps.ClearUserSessionCache(session.UserId)
}
return nil
}
// SetSessionExpireInHours sets the session's expiry the specified number of hours
// relative to either the session creation date or the current time, depending
// on the `ExtendSessionOnActivity` config setting.
func (ps *PlatformService) SetSessionExpireInHours(session *model.Session, hours int) {
if session.CreateAt == 0 || *ps.Config().ServiceSettings.ExtendSessionLengthWithActivity {
session.ExpiresAt = model.GetMillis() + (1000 * 60 * 60 * int64(hours))
} else {
session.ExpiresAt = session.CreateAt + (1000 * 60 * 60 * int64(hours))
}
}
func (ps *PlatformService) ExtendSessionExpiry(session *model.Session, newExpiry int64) error {
if err := ps.Store.Session().UpdateExpiresAt(session.Id, newExpiry); err != nil {
return err
}
// Update local cache. No need to invalidate cache for cluster as the session cache timeout
// ensures each node will get an extended expiry within the next 10 minutes.
// Worst case is another node may generate a redundant expiry update.
session.ExpiresAt = newExpiry
if err := ps.AddSessionToCache(session); err != nil {
ps.Logger().Error("Failed to update session cache", mlog.Err(err))
}
return nil
}
func (ps *PlatformService) UpdateSessionsIsGuest(rctx request.CTX, user *model.User, isGuest bool) error {
sessions, err := ps.GetSessions(rctx, user.Id)
if err != nil {
return err
}
_, err = ps.Store.Session().UpdateRoles(user.Id, user.GetRawRoles())
if err != nil {
return err
}
for _, session := range sessions {
session.AddProp(model.SessionPropIsGuest, strconv.FormatBool(isGuest))
err := ps.Store.Session().UpdateProps(session)
if err != nil {
rctx.Logger().Warn("Unable to update isGuest session", mlog.Err(err))
continue
}
if err := ps.AddSessionToCache(session); err != nil {
ps.Logger().Error("Failed to update session cache", mlog.Err(err))
}
}
return nil
}
func (ps *PlatformService) RevokeAllSessions(rctx request.CTX, userID string) ([]*model.Session, error) {
sessions, err := ps.Store.Session().GetSessions(rctx, userID)
if err != nil {
return nil, fmt.Errorf("%s: %w", err.Error(), GetSessionError)
}
for _, session := range sessions {
if session.IsOAuth {
if err := ps.RevokeAccessToken(rctx, session.Token); err != nil {
return nil, err
}
} else {
if err := ps.Store.Session().Remove(session.Id); err != nil {
return nil, fmt.Errorf("%s: %w", err.Error(), DeleteSessionError)
}
ps.invalidateSessionAttributes(session.Id)
}
}
ps.ClearUserSessionCache(userID)
return sessions, nil
}

View File

@@ -0,0 +1,332 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
const (
dayInMillis = 86400000
grace = 5 * 1000
thirtyDays = dayInMillis * 30
)
func TestCache(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
session := &model.Session{
Id: model.NewId(),
Token: model.NewId(),
UserId: model.NewId(),
}
session2 := &model.Session{
Id: model.NewId(),
Token: model.NewId(),
UserId: model.NewId(),
}
err := th.Service.sessionCache.SetWithExpiry(session.Token, session, 5*time.Minute)
require.NoError(t, err)
err = th.Service.sessionCache.SetWithExpiry(session2.Token, session2, 5*time.Minute)
require.NoError(t, err)
var keys []string
err = th.Service.sessionCache.Scan(func(in []string) error {
keys = append(keys, in...)
return nil
})
require.NoError(t, err)
require.NotEmpty(t, keys)
th.Service.ClearUserSessionCache(session.UserId)
var rkeys []string
err = th.Service.sessionCache.Scan(func(in []string) error {
rkeys = append(rkeys, in...)
return nil
})
require.NoError(t, err)
require.Lenf(t, rkeys, len(keys)-1, "should have one less: %d - %d != 1", len(keys), len(rkeys))
require.NotEmpty(t, rkeys)
clear(rkeys)
rkeys = []string{}
err = th.Service.ClearAllUsersSessionCache()
require.NoError(t, err)
err = th.Service.sessionCache.Scan(func(in []string) error {
rkeys = append(rkeys, in...)
return nil
})
require.NoError(t, err)
require.Len(t, rkeys, 0)
}
func TestSetSessionExpireInHours(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
now := model.GetMillis()
createAt := now - (dayInMillis * 20)
tests := []struct {
name string
extend bool
create bool
days int
want int64
}{
{name: "zero days, extend", extend: true, create: true, days: 0, want: now},
{name: "zero days, extend", extend: true, create: false, days: 0, want: now},
{name: "zero days, no extend", extend: false, create: true, days: 0, want: createAt},
{name: "zero days, no extend", extend: false, create: false, days: 0, want: now},
{name: "thirty days, extend", extend: true, create: true, days: 30, want: now + thirtyDays},
{name: "thirty days, extend", extend: true, create: false, days: 30, want: now + thirtyDays},
{name: "thirty days, no extend", extend: false, create: true, days: 30, want: createAt + thirtyDays},
{name: "thirty days, no extend", extend: false, create: false, days: 30, want: now + thirtyDays},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.ExtendSessionLengthWithActivity = tt.extend
})
var create int64
if tt.create {
create = createAt
}
session := &model.Session{
CreateAt: create,
ExpiresAt: model.GetMillis() + dayInMillis,
}
th.Service.SetSessionExpireInHours(session, tt.days*24)
// must be within 5 seconds of expected time.
require.GreaterOrEqual(t, session.ExpiresAt, tt.want-grace)
require.LessOrEqual(t, session.ExpiresAt, tt.want+grace)
})
}
}
func TestOAuthRevokeAccessToken(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
err := th.Service.RevokeAccessToken(th.Context, model.NewRandomString(16))
require.Error(t, err, "Should have failed due to an incorrect token")
session := &model.Session{}
session.CreateAt = model.GetMillis()
session.UserId = model.NewId()
session.Token = model.NewId()
session.Roles = model.SystemUserRoleId
th.Service.SetSessionExpireInHours(session, 24)
session, _ = th.Service.CreateSession(th.Context, session)
err = th.Service.RevokeAccessToken(th.Context, session.Token)
require.Error(t, err, "Should have failed does not have an access token")
accessData := &model.AccessData{}
accessData.Token = session.Token
accessData.UserId = session.UserId
accessData.RedirectUri = "http://example.com"
accessData.ClientId = model.NewId()
accessData.ExpiresAt = session.ExpiresAt
_, nErr := th.Service.Store.OAuth().SaveAccessData(accessData)
require.NoError(t, nErr)
err = th.Service.RevokeAccessToken(th.Context, accessData.Token)
require.NoError(t, err)
}
func TestUpdateSessionsIsGuest(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
t.Run("Test session is demoted", func(t *testing.T) {
user := th.CreateUserOrGuest(t, false)
session := &model.Session{}
session.CreateAt = model.GetMillis()
session.UserId = user.Id
session.Token = model.NewId()
session.Roles = "fake_role"
th.Service.SetSessionExpireInHours(session, 24)
session, _ = th.Service.CreateSession(th.Context, session)
demotedUser, err := th.Service.Store.User().DemoteUserToGuest(th.Context, user.Id)
require.NoError(t, err)
require.Equal(t, model.SystemGuestRoleId, demotedUser.Roles)
err = th.Service.UpdateSessionsIsGuest(th.Context, demotedUser, true)
require.NoError(t, err)
session, err = th.Service.GetSession(th.Context, session.Id)
require.NoError(t, err)
require.Equal(t, model.SystemGuestRoleId, session.Roles)
require.Equal(t, "true", session.Props[model.SessionPropIsGuest])
})
t.Run("Test session is promoted", func(t *testing.T) {
user := th.CreateUserOrGuest(t, true)
session := &model.Session{}
session.CreateAt = model.GetMillis()
session.UserId = user.Id
session.Token = model.NewId()
session.Roles = "fake_role"
th.Service.SetSessionExpireInHours(session, 24)
session, _ = th.Service.CreateSession(th.Context, session)
err := th.Service.Store.User().PromoteGuestToUser(th.Context, user.Id)
require.NoError(t, err)
promotedUser, err := th.Service.Store.User().Get(th.Context, user.Id)
require.NoError(t, err)
err = th.Service.UpdateSessionsIsGuest(th.Context, promotedUser, false)
require.NoError(t, err)
session, err = th.Service.GetSession(th.Context, session.Id)
require.NoError(t, err)
require.Equal(t, model.SystemUserRoleId, session.Roles)
require.Equal(t, "false", session.Props[model.SessionPropIsGuest])
})
}
func TestRevokeSessionsForDeviceTokens(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// Both revoke variants are structurally identical (one matches DeviceId,
// the other VoIPDeviceId), and the invariants they must hold are the
// same. Drive them through a single table.
type variant struct {
name string
setToken func(s *model.Session, token string)
revoke func(userID, token, currentSessionID string) error
emptyTokenErr error
}
variants := []variant{
{
name: "RevokeOtherSessionsForDeviceId",
setToken: func(s *model.Session, token string) { s.DeviceId = token },
revoke: func(userID, token, currentSessionID string) error {
return th.Service.RevokeOtherSessionsForDeviceId(th.Context, userID, token, currentSessionID)
},
emptyTokenErr: ErrEmptyDeviceId,
},
{
name: "RevokeOtherSessionsForVoIPDeviceId",
setToken: func(s *model.Session, token string) { s.VoIPDeviceId = token },
revoke: func(userID, token, currentSessionID string) error {
return th.Service.RevokeOtherSessionsForVoIPDeviceId(th.Context, userID, token, currentSessionID)
},
emptyTokenErr: ErrEmptyVoIPDeviceId,
},
}
createSession := func(t *testing.T, v variant, userID, token string) *model.Session {
t.Helper()
s := &model.Session{UserId: userID, Roles: model.SystemUserRoleId}
v.setToken(s, token)
saved, err := th.Service.CreateSession(th.Context, s)
require.NoError(t, err)
return saved
}
sessionExists := func(t *testing.T, id string) bool {
t.Helper()
_, err := th.Service.GetSessionByID(th.Context, id)
return err == nil
}
for _, v := range variants {
t.Run(v.name, func(t *testing.T) {
t.Run("revokes the matching session", func(t *testing.T) {
token := "apple_rn:revokes-matching-" + v.name
target := createSession(t, v, th.BasicUser.Id, token)
require.NoError(t, v.revoke(th.BasicUser.Id, token, ""))
assert.False(t, sessionExists(t, target.Id), "matching session must be revoked")
})
t.Run("does not revoke the session passed as currentSessionId", func(t *testing.T) {
// Self-skip: a session re-attaching its own token must not
// revoke itself.
token := "apple_rn:self-skip-" + v.name
self := createSession(t, v, th.BasicUser.Id, token)
require.NoError(t, v.revoke(th.BasicUser.Id, token, self.Id))
assert.True(t, sessionExists(t, self.Id), "currentSessionId must be exempt from revocation")
})
t.Run("does not revoke sessions belonging to other users", func(t *testing.T) {
// The function scopes by userID — a session with a matching
// token on a different user must stay alive.
token := "apple_rn:cross-user-" + v.name
otherUserSession := createSession(t, v, th.BasicUser2.Id, token)
require.NoError(t, v.revoke(th.BasicUser.Id, token, ""))
assert.True(t, sessionExists(t, otherUserSession.Id),
"session on a different user must not be revoked")
})
t.Run("empty token returns an error and revokes nothing", func(t *testing.T) {
webSession := createSession(t, v, th.BasicUser.Id, "")
err := v.revoke(th.BasicUser.Id, "", "")
require.ErrorIs(t, err, v.emptyTokenErr)
assert.True(t, sessionExists(t, webSession.Id),
"empty-token revoke must not match sessions with no token in this slot")
})
t.Run("leaves siblings of the same user with different tokens alone", func(t *testing.T) {
targetToken := "apple_rn:target-" + v.name
siblingToken := "apple_rn:sibling-" + v.name
target := createSession(t, v, th.BasicUser.Id, targetToken)
sibling := createSession(t, v, th.BasicUser.Id, siblingToken)
require.NoError(t, v.revoke(th.BasicUser.Id, targetToken, ""))
assert.False(t, sessionExists(t, target.Id))
assert.True(t, sessionExists(t, sibling.Id),
"session with a different token must stay alive")
})
})
}
}
func TestRevokeAllSessionsReturnsRevokedSessions(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
userId := model.NewId()
s1 := &model.Session{UserId: userId, ExpiresAt: model.GetMillis() + dayInMillis, DeviceId: model.NewId()}
s1, _ = th.Service.CreateSession(th.Context, s1)
s2 := &model.Session{UserId: userId, ExpiresAt: model.GetMillis() + dayInMillis}
s2, _ = th.Service.CreateSession(th.Context, s2)
sessions, err := th.Service.RevokeAllSessions(th.Context, userId)
require.NoError(t, err)
require.Len(t, sessions, 2)
require.ElementsMatch(t, []string{s1.Id, s2.Id}, []string{sessions[0].Id, sessions[1].Id})
remaining, err := th.Service.Store.Session().GetSessions(th.Context, userId)
require.NoError(t, err)
require.Empty(t, remaining)
}

View File

@@ -0,0 +1,174 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"fmt"
"slices"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/platform/services/sharedchannel"
)
var sharedChannelEventsForSync = []model.WebsocketEventType{
model.WebsocketEventPosted,
model.WebsocketEventPostEdited,
model.WebsocketEventPostDeleted,
model.WebsocketEventReactionAdded,
model.WebsocketEventReactionRemoved,
model.WebsocketEventAcknowledgementAdded,
model.WebsocketEventAcknowledgementRemoved,
}
var sharedChannelEventsForInvitation = []model.WebsocketEventType{
model.WebsocketEventDirectAdded,
}
// SharedChannelSyncHandler is called when a websocket event is received by a cluster node.
// Only on the leader node it will notify the sync service to perform necessary updates to the remote for the given
// shared channel.
func (ps *PlatformService) SharedChannelSyncHandler(event *model.WebSocketEvent) {
syncService := ps.GetSharedChannelService()
if syncService == nil {
return
}
if isEligibleForEvents(syncService, event, sharedChannelEventsForSync) {
err := handleContentSync(ps, syncService, event)
if err != nil {
mlog.Warn(
err.Error(),
mlog.String("event", event.EventType()),
mlog.String("action", "content_sync"),
)
}
} else if isEligibleForEvents(syncService, event, sharedChannelEventsForInvitation) {
err := handleInvitation(ps, syncService, event)
if err != nil {
mlog.Warn(
err.Error(),
mlog.String("event", event.EventType()),
mlog.String("action", "invitation"),
)
}
}
}
func isEligibleForEvents(syncService SharedChannelServiceIFace, event *model.WebSocketEvent, events []model.WebsocketEventType) bool {
return syncServiceEnabled(syncService) &&
eventHasChannel(event) &&
slices.Contains(events, event.EventType())
}
func eventHasChannel(event *model.WebSocketEvent) bool {
return event.GetBroadcast() != nil &&
event.GetBroadcast().ChannelId != ""
}
func syncServiceEnabled(syncService SharedChannelServiceIFace) bool {
return syncService != nil &&
syncService.Active()
}
func handleContentSync(ps *PlatformService, syncService SharedChannelServiceIFace, event *model.WebSocketEvent) error {
channel, err := findChannel(ps, event.GetBroadcast().ChannelId)
if err != nil {
return err
}
shouldNotify := channel.IsShared()
// check if any remotes need to be auto-subscribed to this channel. Remotes are auto-subscribed to DM/GM's if they registered
// with the AutoShareDMs flag set.
if channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup {
filter := model.RemoteClusterQueryFilter{
NotInChannel: channel.Id,
OnlyConfirmed: true,
RequireOptions: model.BitflagOptionAutoShareDMs,
}
remotes, err := ps.Store.RemoteCluster().GetAll(0, 999999, filter) // empty list returned if none found, no error
if err != nil {
return fmt.Errorf("cannot fetch remote clusters: %w", err)
}
for _, remote := range remotes {
// invite remote to channel (will share the channel if not already shared)
if err := syncService.InviteRemoteToChannel(channel.Id, remote.RemoteId, remote.CreatorId, true); err != nil {
return fmt.Errorf("cannot invite remote %s to channel %s: %w", remote.RemoteId, channel.Id, err)
}
shouldNotify = true
}
}
// notify
if shouldNotify {
syncService.NotifyChannelChanged(channel.Id)
}
return nil
}
func handleInvitation(ps *PlatformService, syncService SharedChannelServiceIFace, event *model.WebSocketEvent) error {
channel, err := findChannel(ps, event.GetBroadcast().ChannelId)
if err != nil {
return err
}
if channel == nil || !channel.IsShared() {
return nil
}
creator, err := getUserFromEvent(ps, event, "creator_id")
if err != nil {
return err
}
// This is a termination condition, since on the other end when we are processing
// the invite we are re-triggering a model.WEBSOCKET_EVENT_DIRECT_ADDED, which will call this handler.
// When the creator is remote, it means that this is a DM that was not originated from the current server
// and therefore we do not need to do anything.
if creator == nil || creator.IsRemote() {
return nil
}
participant, err := getUserFromEvent(ps, event, "teammate_id")
if err != nil {
return err
}
if participant == nil || participant.RemoteId == nil {
return nil
}
rc, err := ps.Store.RemoteCluster().Get(*participant.RemoteId, false)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("couldn't find remote cluster %s, for creating shared channel invitation for a DM", *participant.RemoteId))
}
return syncService.SendChannelInvite(channel, creator.Id, rc, sharedchannel.WithDirectParticipant(creator, rc.RemoteId), sharedchannel.WithDirectParticipant(participant, rc.RemoteId))
}
func getUserFromEvent(ps *PlatformService, event *model.WebSocketEvent, key string) (*model.User, error) {
userID, ok := event.GetData()[key].(string)
if !ok || userID == "" {
return nil, fmt.Errorf("received websocket message that is eligible for sending an invitation but message does not have `%s` present", key)
}
user, err := ps.Store.User().Get(request.EmptyContext(ps.logger), userID)
if err != nil {
return nil, errors.Wrap(err, "couldn't find user for creating shared channel invitation for a DM")
}
return user, nil
}
func findChannel(server *PlatformService, channelId string) (*model.Channel, error) {
channel, err := server.Store.Channel().Get(channelId, true)
if err != nil {
return nil, errors.Wrap(err, "received websocket message that is eligible for shared channel sync but channel does not exist")
}
return channel, nil
}

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
)
func TestServerSyncSharedChannelHandler(t *testing.T) {
t.Run("sync service inactive, it does nothing", func(t *testing.T) {
th := SetupWithStoreMock(t)
mockService := NewMockSharedChannelService(nil)
mockService.active = false
th.Service.SetSharedChannelService(mockService)
th.Service.SharedChannelSyncHandler(&model.WebSocketEvent{})
assert.Empty(t, mockService.channelNotifications)
})
t.Run("sync service active and broadcast envelope has ineligible event, it does nothing", func(t *testing.T) {
th := Setup(t).InitBasic(t)
mockService := NewMockSharedChannelService(nil)
mockService.active = true
th.Service.SetSharedChannelService(mockService)
channel := th.CreateChannel(t, th.BasicTeam, WithShared(true))
websocketEvent := model.NewWebSocketEvent(model.WebsocketEventAddedToTeam, model.NewId(), channel.Id, "", nil, "")
th.Service.SharedChannelSyncHandler(websocketEvent)
assert.Empty(t, mockService.channelNotifications)
})
t.Run("sync service active and broadcast envelope has eligible event but channel does not exist, it does nothing", func(t *testing.T) {
th := Setup(t).InitBasic(t)
mockService := NewMockSharedChannelService(nil)
mockService.active = true
th.Service.SetSharedChannelService(mockService)
websocketEvent := model.NewWebSocketEvent(model.WebsocketEventPosted, model.NewId(), model.NewId(), "", nil, "")
th.Service.SharedChannelSyncHandler(websocketEvent)
assert.Empty(t, mockService.channelNotifications)
})
t.Run("sync service active when received eligible event, it triggers a shared channel content sync", func(t *testing.T) {
th := Setup(t).InitBasic(t)
mockService := NewMockSharedChannelService(nil)
mockService.active = true
th.Service.SetSharedChannelService(mockService)
channel := th.CreateChannel(t, th.BasicTeam, WithShared(true))
websocketEvent := model.NewWebSocketEvent(model.WebsocketEventPosted, th.BasicTeam.Id, channel.Id, "", nil, "")
th.Service.SharedChannelSyncHandler(websocketEvent)
require.Len(t, mockService.channelNotifications, 1)
assert.Equal(t, channel.Id, mockService.channelNotifications[0])
})
t.Run("sync service doesn't panic when no RemoteId", func(t *testing.T) {
th := SetupWithStoreMock(t)
mockStore := th.Service.Store.(*mocks.Store)
mockChannelStore := &mocks.ChannelStore{}
mockChannelStore.On("Get", "channelID", true).Return(&model.Channel{
Id: "channelID",
Shared: new(true),
}, nil)
mockUserStore := &mocks.UserStore{}
mockUserStore.On("Get", mock.Anything, "creator").Return(&model.User{}, nil)
// Not setting RemoteId here causes the panic.
mockUserStore.On("Get", mock.Anything, "teammate").Return(&model.User{}, nil)
mockRemoteClusterStore := &mocks.RemoteClusterStore{}
mockRemoteClusterStore.On("Get", mock.Anything).Return(&model.RemoteCluster{}, nil)
mockStore.On("Channel").Return(mockChannelStore)
mockStore.On("User").Return(mockUserStore)
mockStore.On("RemoteCluster").Return(mockRemoteClusterStore)
mockService := NewMockSharedChannelService(nil)
mockService.active = true
th.Service.SetSharedChannelService(mockService)
require.NotPanics(t, func() {
websocketEvent := model.NewWebSocketEvent(model.WebsocketEventDirectAdded, "teamID", "channelID", "userID", nil, "")
websocketEvent = websocketEvent.SetData(map[string]any{"creator_id": "creator", "teammate_id": "teammate"})
th.Service.SharedChannelSyncHandler(websocketEvent)
assert.Empty(t, mockService.channelNotifications)
})
})
}

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/platform/services/sharedchannel"
)
// SharedChannelServiceIFace is the interface to the shared channel service
type SharedChannelServiceIFace interface {
Shutdown() error
Start() error
NotifyChannelChanged(channelId string)
NotifyUserProfileChanged(userID string)
NotifyUserStatusChanged(status *model.Status)
SendChannelInvite(channel *model.Channel, userId string, rc *model.RemoteCluster, options ...sharedchannel.InviteOption) error
Active() bool
InviteRemoteToChannel(channelID, remoteID, userID string, shareIfNotShared bool) error
UninviteRemoteFromChannel(channelID, remoteID string) error
ShareChannel(sc *model.SharedChannel) (*model.SharedChannel, error)
CheckChannelNotShared(channelID string) error
CheckChannelIsShared(channelID string) error
CheckCanInviteToSharedChannel(channelId string) error
NotifyMembershipChanged(channelID string, originRemoteID string)
TransformMentionsOnReceiveForTesting(rctx request.CTX, post *model.Post, targetChannel *model.Channel, rc *model.RemoteCluster, mentionTransforms map[string]string)
}
type MockOptionSharedChannelService func(service *mockSharedChannelService)
func MockOptionSharedChannelServiceWithActive(active bool) MockOptionSharedChannelService {
return func(mrcs *mockSharedChannelService) {
mrcs.active = active
}
}
func NewMockSharedChannelService(service SharedChannelServiceIFace, options ...MockOptionSharedChannelService) *mockSharedChannelService {
mrcs := &mockSharedChannelService{service, true, []string{}, []string{}, 0}
for _, option := range options {
option(mrcs)
}
return mrcs
}
type mockSharedChannelService struct {
SharedChannelServiceIFace
active bool
channelNotifications []string
userProfileNotifications []string
numInvitations int
}
func (mrcs *mockSharedChannelService) NotifyChannelChanged(channelId string) {
mrcs.channelNotifications = append(mrcs.channelNotifications, channelId)
}
func (mrcs *mockSharedChannelService) NotifyUserProfileChanged(userId string) {
mrcs.userProfileNotifications = append(mrcs.userProfileNotifications, userId)
}
func (mrcs *mockSharedChannelService) Shutdown() error {
return nil
}
func (mrcs *mockSharedChannelService) Start() error {
return nil
}
func (mrcs *mockSharedChannelService) Active() bool {
return mrcs.active
}
func (mrcs *mockSharedChannelService) SendChannelInvite(channel *model.Channel, userId string, rc *model.RemoteCluster, options ...sharedchannel.InviteOption) error {
mrcs.numInvitations += 1
return nil
}
func (mrcs *mockSharedChannelService) NumInvitations() int {
return mrcs.numInvitations
}
func (mrcs *mockSharedChannelService) NotifyMembershipChanged(channelID string, originRemoteID string) {
// This is a mock implementation - it doesn't need to do anything
}
func (mrcs *mockSharedChannelService) TransformMentionsOnReceiveForTesting(rctx request.CTX, post *model.Post, targetChannel *model.Channel, rc *model.RemoteCluster, mentionTransforms map[string]string) {
// This is a mock implementation - it doesn't need to do anything
}

View File

@@ -0,0 +1,590 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/store"
"github.com/mattermost/mattermost/server/v8/platform/services/cache"
)
func (ps *PlatformService) AddStatusCacheSkipClusterSend(status *model.Status) {
if err := ps.statusCache.SetWithDefaultExpiry(status.UserId, status); err != nil {
ps.logger.Warn("Failed to set cache entry for status", mlog.String("user_id", status.UserId), mlog.Err(err))
}
}
func (ps *PlatformService) AddStatusCache(status *model.Status) {
ps.AddStatusCacheSkipClusterSend(status)
if ps.Cluster() != nil {
statusJSON, err := json.Marshal(status)
if err != nil {
ps.logger.Warn("Failed to encode status to JSON", mlog.Err(err))
}
msg := &model.ClusterMessage{
Event: model.ClusterEventUpdateStatus,
SendType: model.ClusterSendBestEffort,
Data: statusJSON,
}
ps.Cluster().SendClusterMessage(msg)
}
}
func (ps *PlatformService) GetAllStatuses() map[string]*model.Status {
if !*ps.Config().ServiceSettings.EnableUserStatuses {
return map[string]*model.Status{}
}
statusMap := map[string]*model.Status{}
err := ps.statusCache.Scan(func(keys []string) error {
if len(keys) == 0 {
return nil
}
toPass := allocateCacheTargets[*model.Status](len(keys))
errs := ps.statusCache.GetMulti(keys, toPass)
for i, err := range errs {
if err != nil {
if err != cache.ErrKeyNotFound {
return err
}
continue
}
gotStatus := *(toPass[i].(**model.Status))
if gotStatus != nil {
statusMap[keys[i]] = gotStatus
continue
}
ps.logger.Warn("Found nil status in GetAllStatuses. This is not expected")
}
return nil
})
if err != nil {
ps.logger.Warn("Error while getting all status in GetAllStatuses", mlog.Err(err))
return nil
}
return statusMap
}
func (ps *PlatformService) GetStatusesByIds(userIDs []string) (map[string]any, *model.AppError) {
if !*ps.Config().ServiceSettings.EnableUserStatuses {
return map[string]any{}, nil
}
statusMap := map[string]any{}
metrics := ps.Metrics()
missingUserIds := []string{}
toPass := allocateCacheTargets[*model.Status](len(userIDs))
// First, we do a GetMulti to get all the status objects.
errs := ps.statusCache.GetMulti(userIDs, toPass)
for i, err := range errs {
if err != nil {
if err != cache.ErrKeyNotFound {
ps.logger.Warn("Error in GetStatusesByIds: ", mlog.Err(err))
}
missingUserIds = append(missingUserIds, userIDs[i])
if metrics != nil {
metrics.IncrementMemCacheMissCounter(ps.statusCache.Name())
}
} else {
// If we get a hit, we need to cast it back to the right type.
gotStatus := *(toPass[i].(**model.Status))
if gotStatus == nil {
ps.logger.Warn("Found nil in GetStatusesByIds. This is not expected")
continue
}
statusMap[userIDs[i]] = gotStatus.Status
if metrics != nil {
metrics.IncrementMemCacheHitCounter(ps.statusCache.Name())
}
}
}
if len(missingUserIds) > 0 {
// For cache misses, we fill them back from the DB.
statuses, err := ps.Store.Status().GetByIds(missingUserIds)
if err != nil {
return nil, model.NewAppError("GetStatusesByIds", "app.status.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, s := range statuses {
ps.AddStatusCacheSkipClusterSend(s)
statusMap[s.UserId] = s.Status
}
}
// For the case where the user does not have a row in the Status table and cache
for _, userID := range missingUserIds {
if _, ok := statusMap[userID]; !ok {
statusMap[userID] = model.StatusOffline
}
}
return statusMap, nil
}
// GetUserStatusesByIds used by apiV4
func (ps *PlatformService) GetUserStatusesByIds(userIDs []string) ([]*model.Status, *model.AppError) {
if !*ps.Config().ServiceSettings.EnableUserStatuses {
return []*model.Status{}, nil
}
var statusMap []*model.Status
metrics := ps.Metrics()
missingUserIds := []string{}
toPass := allocateCacheTargets[*model.Status](len(userIDs))
// First, we do a GetMulti to get all the status objects.
errs := ps.statusCache.GetMulti(userIDs, toPass)
for i, err := range errs {
if err != nil {
if err != cache.ErrKeyNotFound {
ps.logger.Warn("Error in GetUserStatusesByIds: ", mlog.Err(err))
}
missingUserIds = append(missingUserIds, userIDs[i])
if metrics != nil {
metrics.IncrementMemCacheMissCounter(ps.statusCache.Name())
}
} else {
// If we get a hit, we need to cast it back to the right type.
gotStatus := *(toPass[i].(**model.Status))
if gotStatus == nil {
ps.logger.Warn("Found nil in GetUserStatusesByIds. This is not expected")
continue
}
statusMap = append(statusMap, gotStatus)
if metrics != nil {
metrics.IncrementMemCacheHitCounter(ps.statusCache.Name())
}
}
}
if len(missingUserIds) > 0 {
// For cache misses, we fill them back from the DB.
statuses, err := ps.Store.Status().GetByIds(missingUserIds)
if err != nil {
return nil, model.NewAppError("GetUserStatusesByIds", "app.status.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, s := range statuses {
ps.AddStatusCacheSkipClusterSend(s)
}
statusMap = append(statusMap, statuses...)
}
// For the case where the user does not have a row in the Status table and cache
// remove the existing ids from missingUserIds and then create a offline state for the missing ones
// This also return the status offline for the non-existing Ids in the system
for i := 0; i < len(missingUserIds); i++ {
missingUserId := missingUserIds[i]
for _, userMap := range statusMap {
if missingUserId == userMap.UserId {
missingUserIds = append(missingUserIds[:i], missingUserIds[i+1:]...)
i--
break
}
}
}
for _, userID := range missingUserIds {
statusMap = append(statusMap, &model.Status{UserId: userID, Status: "offline"})
}
return statusMap, nil
}
func (ps *PlatformService) BroadcastStatus(status *model.Status) {
if ps.Busy.IsBusy() {
// this is considered a non-critical service and will be disabled when server busy.
return
}
event := model.NewWebSocketEvent(model.WebsocketEventStatusChange, "", "", status.UserId, nil, "")
event.Add("status", status.Status)
event.Add("user_id", status.UserId)
ps.Publish(event)
}
func (ps *PlatformService) SaveAndBroadcastStatus(status *model.Status) {
ps.AddStatusCache(status)
if err := ps.Store.Status().SaveOrUpdate(status); err != nil {
ps.Log().Warn("Failed to save status", mlog.String("user_id", status.UserId), mlog.Err(err))
}
ps.BroadcastStatus(status)
}
func (ps *PlatformService) GetStatusFromCache(userID string) *model.Status {
var status *model.Status
if err := ps.statusCache.Get(userID, &status); err == nil {
return status
}
return nil
}
func (ps *PlatformService) GetStatus(userID string) (*model.Status, *model.AppError) {
if !*ps.Config().ServiceSettings.EnableUserStatuses {
return &model.Status{}, nil
}
status := ps.GetStatusFromCache(userID)
if status != nil {
return status, nil
}
status, err := ps.Store.Status().Get(userID)
if err != nil {
var nfErr *store.ErrNotFound
switch {
case errors.As(err, &nfErr):
return nil, model.NewAppError("GetStatus", "app.status.get.missing.app_error", nil, "", http.StatusNotFound).Wrap(err)
default:
return nil, model.NewAppError("GetStatus", "app.status.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
return status, nil
}
// SetStatusLastActivityAt sets the last activity at for a user on the local app server and updates
// status to away if needed. Used by the WS to set status to away if an 'online' device disconnects
// while an 'away' device is still connected
func (ps *PlatformService) SetStatusLastActivityAt(userID string, activityAt int64) {
var status *model.Status
var err *model.AppError
if status, err = ps.GetStatus(userID); err != nil {
return
}
status.LastActivityAt = activityAt
ps.AddStatusCacheSkipClusterSend(status)
ps.SetStatusAwayIfNeeded(userID, false)
if ps.sharedChannelService != nil {
ps.sharedChannelService.NotifyUserStatusChanged(status)
}
}
func (ps *PlatformService) UpdateLastActivityAtIfNeeded(session model.Session) {
now := model.GetMillis()
ps.UpdateWebConnUserActivity(session, now)
if now-session.LastActivityAt < model.SessionActivityTimeout {
return
}
if err := ps.Store.Session().UpdateLastActivityAt(session.Id, now); err != nil {
ps.Log().Warn("Failed to update LastActivityAt", mlog.String("user_id", session.UserId), mlog.String("session_id", session.Id), mlog.Err(err))
}
session.LastActivityAt = now
if err := ps.AddSessionToCache(&session); err != nil {
ps.Log().Warn("Failed to add session to cache", mlog.String("user_id", session.UserId), mlog.String("session_id", session.Id), mlog.Err(err))
}
}
func (ps *PlatformService) SetStatusOnline(userID string, manual bool) {
if !*ps.Config().ServiceSettings.EnableUserStatuses {
return
}
broadcast := false
var oldStatus string = model.StatusOffline
var oldTime int64
var oldManual bool
var status *model.Status
var err *model.AppError
if status, err = ps.GetStatus(userID); err != nil {
status = &model.Status{UserId: userID, Status: model.StatusOnline, Manual: false, LastActivityAt: model.GetMillis(), ActiveChannel: ""}
broadcast = true
} else {
if status.Manual && !manual {
return // manually set status always overrides non-manual one
}
if status.Status != model.StatusOnline {
broadcast = true
}
oldStatus = status.Status
oldTime = status.LastActivityAt
oldManual = status.Manual
status.Status = model.StatusOnline
status.Manual = false // for "online" there's no manual setting
status.LastActivityAt = model.GetMillis()
}
ps.AddStatusCache(status)
// Only update the database if the status has changed, the status has been manually set,
// or enough time has passed since the previous action
if status.Status != oldStatus || status.Manual != oldManual || status.LastActivityAt-oldTime > model.StatusMinUpdateTime {
if broadcast {
if err := ps.Store.Status().SaveOrUpdate(status); err != nil {
mlog.Warn("Failed to save status", mlog.String("user_id", userID), mlog.Err(err), mlog.String("user_id", userID))
}
} else {
if err := ps.Store.Status().UpdateLastActivityAt(status.UserId, status.LastActivityAt); err != nil {
mlog.Error("Failed to save status", mlog.String("user_id", userID), mlog.Err(err), mlog.String("user_id", userID))
}
}
if ps.sharedChannelService != nil {
ps.sharedChannelService.NotifyUserStatusChanged(status)
}
}
if broadcast {
ps.BroadcastStatus(status)
}
}
func (ps *PlatformService) SetStatusOffline(userID string, manual bool, force bool) {
if !*ps.Config().ServiceSettings.EnableUserStatuses {
return
}
status, err := ps.GetStatus(userID)
if err != nil {
ps.Log().Warn("Error getting status. Setting it to offline forcefully.", mlog.String("user_id", userID), mlog.Err(err))
} else if !force && status.Manual && !manual {
return // manually set status always overrides non-manual one
}
ps._setStatusOfflineAndNotify(userID, manual)
}
func (ps *PlatformService) _setStatusOfflineAndNotify(userID string, manual bool) {
status := &model.Status{UserId: userID, Status: model.StatusOffline, Manual: manual, LastActivityAt: model.GetMillis(), ActiveChannel: ""}
ps.SaveAndBroadcastStatus(status)
if ps.sharedChannelService != nil {
ps.sharedChannelService.NotifyUserStatusChanged(status)
}
}
// QueueSetStatusOffline queues a status update to set a user offline
// instead of directly updating it for better performance during high load
func (ps *PlatformService) QueueSetStatusOffline(userID string, manual bool) {
if !*ps.Config().ServiceSettings.EnableUserStatuses {
return
}
status, err := ps.GetStatus(userID)
if err != nil {
ps.Log().Warn("Error getting status. Setting it to offline forcefully.", mlog.String("user_id", userID), mlog.Err(err))
} else if status.Manual && !manual {
// Force will be false here, so no need to add another variable.
return // manually set status always overrides non-manual one
}
status = &model.Status{
UserId: userID,
Status: model.StatusOffline,
Manual: manual,
LastActivityAt: model.GetMillis(),
ActiveChannel: "",
}
select {
case ps.statusUpdateChan <- status:
// Successfully queued
default:
// Channel is full, fall back to direct update
ps.Log().Warn("Status update channel is full. Falling back to direct update")
ps._setStatusOfflineAndNotify(userID, manual)
}
}
const (
statusUpdateBufferSize = sendQueueSize // We use the webConn sendQueue size as a reference point for the buffer size.
statusUpdateFlushThreshold = statusUpdateBufferSize / 8
statusUpdateBatchInterval = 500 * time.Millisecond // Max time to wait before processing
)
// processStatusUpdates processes status updates in batches for better performance
// This runs as a goroutine and continuously monitors the statusUpdateChan
func (ps *PlatformService) processStatusUpdates() {
defer close(ps.statusUpdateDoneSignal)
statusBatch := make(map[string]*model.Status)
ticker := time.NewTicker(statusUpdateBatchInterval)
defer ticker.Stop()
flush := func(broadcast bool) {
if len(statusBatch) == 0 {
return
}
// Add each status to cache.
for _, status := range statusBatch {
ps.AddStatusCache(status)
}
// Process statuses in batch
if err := ps.Store.Status().SaveOrUpdateMany(statusBatch); err != nil {
ps.logger.Warn("Failed to save multiple statuses", mlog.Err(err))
}
// Broadcast each status only if hub is still running
if broadcast {
for _, status := range statusBatch {
ps.BroadcastStatus(status)
if ps.sharedChannelService != nil {
ps.sharedChannelService.NotifyUserStatusChanged(status)
}
}
}
clear(statusBatch)
}
for {
select {
case status := <-ps.statusUpdateChan:
// In case of duplicates, we override the last entry
statusBatch[status.UserId] = status
if len(statusBatch) >= statusUpdateFlushThreshold {
ps.logger.Debug("Flushing statuses because the current buffer exceeded the flush threshold.", mlog.Int("current_buffer", len(statusBatch)), mlog.Int("flush_threshold", statusUpdateFlushThreshold))
flush(true)
}
case <-ticker.C:
flush(true)
case <-ps.statusUpdateExitSignal:
// Process any remaining statuses before shutting down
// Skip broadcast since hub is already stopped
ps.logger.Debug("Exit signal received. Flushing any remaining statuses.")
flush(false)
return
}
}
}
func (ps *PlatformService) SetStatusAwayIfNeeded(userID string, manual bool) {
if !*ps.Config().ServiceSettings.EnableUserStatuses {
return
}
status, err := ps.GetStatus(userID)
if err != nil {
status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: manual, LastActivityAt: 0, ActiveChannel: ""}
}
if !manual && status.Manual {
return // manually set status always overrides non-manual one
}
if !manual {
if status.Status == model.StatusAway {
return
}
if !ps.isUserAway(status.LastActivityAt) {
return
}
}
status.Status = model.StatusAway
status.Manual = manual
status.ActiveChannel = ""
ps.SaveAndBroadcastStatus(status)
if ps.sharedChannelService != nil {
ps.sharedChannelService.NotifyUserStatusChanged(status)
}
}
// SetStatusDoNotDisturbTimed takes endtime in unix epoch format in UTC
// and sets status of given userId to dnd which will be restored back after endtime
func (ps *PlatformService) SetStatusDoNotDisturbTimed(userID string, endtime int64) {
if !*ps.Config().ServiceSettings.EnableUserStatuses {
return
}
status, err := ps.GetStatus(userID)
if err != nil {
status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
}
status.PrevStatus = status.Status
status.Status = model.StatusDnd
status.Manual = true
status.DNDEndTime = truncateDNDEndTime(endtime)
ps.SaveAndBroadcastStatus(status)
if ps.sharedChannelService != nil {
ps.sharedChannelService.NotifyUserStatusChanged(status)
}
}
// truncateDNDEndTime takes a user-provided timestamp (in seconds) for when their DND expiry should end and truncates
// it to line up with the DND expiry job so that the user's DND time doesn't expire late by an interval. The job to
// expire statuses runs every minute currently, so this trims the seconds and milliseconds off the given timestamp.
//
// This will result in statuses expiring slightly earlier than specified in the UI, but the status will expire at
// the correct time on the wall clock. For example, if the time is currently 13:04:29 and the user sets the expiry to
// 5 minutes, truncating will make the status will expire at 13:09:00 instead of at 13:10:00.
//
// Note that the timestamps used by this are in seconds, not milliseconds. This matches UserStatus.DNDEndTime.
func truncateDNDEndTime(endtime int64) int64 {
return time.Unix(endtime, 0).Truncate(model.DNDExpiryInterval).Unix()
}
func (ps *PlatformService) SetStatusDoNotDisturb(userID string) {
if !*ps.Config().ServiceSettings.EnableUserStatuses {
return
}
status, err := ps.GetStatus(userID)
if err != nil {
status = &model.Status{UserId: userID, Status: model.StatusOffline, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
}
status.Status = model.StatusDnd
status.Manual = true
ps.SaveAndBroadcastStatus(status)
if ps.sharedChannelService != nil {
ps.sharedChannelService.NotifyUserStatusChanged(status)
}
}
func (ps *PlatformService) SetStatusOutOfOffice(userID string) {
if !*ps.Config().ServiceSettings.EnableUserStatuses {
return
}
status, err := ps.GetStatus(userID)
if err != nil {
status = &model.Status{UserId: userID, Status: model.StatusOutOfOffice, Manual: false, LastActivityAt: 0, ActiveChannel: ""}
}
status.Status = model.StatusOutOfOffice
status.Manual = true
ps.SaveAndBroadcastStatus(status)
if ps.sharedChannelService != nil {
ps.sharedChannelService.NotifyUserStatusChanged(status)
}
}
func (ps *PlatformService) isUserAway(lastActivityAt int64) bool {
return model.GetMillis()-lastActivityAt >= *ps.Config().TeamSettings.UserStatusAwayTimeout*1000
}

View File

@@ -0,0 +1,256 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestSaveStatus(t *testing.T) {
th := Setup(t).InitBasic(t)
user := th.BasicUser
for _, statusString := range []string{
model.StatusOnline,
model.StatusAway,
model.StatusDnd,
model.StatusOffline,
} {
t.Run(statusString, func(t *testing.T) {
status := &model.Status{
UserId: user.Id,
Status: statusString,
}
th.Service.SaveAndBroadcastStatus(status)
after, err := th.Service.GetStatus(user.Id)
require.Nil(t, err, "failed to get status after save: %v", err)
require.Equal(t, statusString, after.Status, "failed to save status, got %v, expected %v", after.Status, statusString)
})
}
}
func TestTruncateDNDEndTime(t *testing.T) {
// 2025-Jan-20 at 17:13:32 GMT becomes 17:13:00
assert.Equal(t, int64(1737393180), truncateDNDEndTime(1737393212))
// 2025-Jan-20 at 17:13:00 GMT remains unchanged
assert.Equal(t, int64(1737393180), truncateDNDEndTime(1737393180))
// 2025-Jan-20 at 00:00:10 GMT becomes 00:00:00
assert.Equal(t, int64(1737331200), truncateDNDEndTime(1737331210))
// 2025-Jan-20 at 00:00:10 GMT remains unchanged
assert.Equal(t, int64(1737331200), truncateDNDEndTime(1737331200))
}
func TestQueueSetStatusOffline(t *testing.T) {
th := Setup(t).InitBasic(t)
// Create multiple user IDs
userIDs := []string{
th.BasicUser.Id,
model.NewId(),
model.NewId(),
model.NewId(),
}
// Add duplicate user IDs to test duplicate handling
// The second occurrence should override the first
userIDs = append(userIDs, userIDs[0], userIDs[1])
// Initially set all users to online
for _, userID := range userIDs {
th.Service.SetStatusOnline(userID, false)
status, err := th.Service.GetStatus(userID)
require.Nil(t, err, "Failed to get initial status")
require.Equal(t, model.StatusOnline, status.Status, "User should be online initially")
}
// Queue status updates to offline
for i, userID := range userIDs {
// Set every other status as manual to test both cases
manual := i%2 == 0
th.Service.QueueSetStatusOffline(userID, manual)
}
// Wait for the background processor to handle the updates
// Use eventually consistent approach with retries
for idx, userID := range userIDs {
var status *model.Status
var err *model.AppError
// Use poll-wait pattern to account for async processing
require.Eventually(t, func() bool {
status, err = th.Service.GetStatus(userID)
return err == nil && status.Status == model.StatusOffline
}, 5*time.Second, 100*time.Millisecond, "Status wasn't updated to offline")
// For the duplicated user IDs, check that manual setting is based on the last call
// User[0] and User[1] are duplicated at the end of the slice
switch idx {
case 0, 4: // first duplicated user
// Last update for userIDs[0] was at index 4 (i%2 == 0, so manual = true)
require.True(t, status.Manual, "User should have manual status (duplicate case)")
case 1, 5:
// Last update for userIDs[1] was at index 5 (i%2 == 1, so manual = false)
require.False(t, status.Manual, "User should have automatic status (duplicate case)")
default:
require.Equal(t, idx%2 == 0, status.Manual, "Manual flag incorrect")
}
}
// Verify all relevant status fields
for _, userID := range model.RemoveDuplicateStrings(userIDs) {
status, err := th.Service.GetStatus(userID)
require.Nil(t, err, "Failed to get status")
require.Equal(t, model.StatusOffline, status.Status, "User should be offline")
require.Equal(t, "", status.ActiveChannel, "ActiveChannel should be empty")
}
// First shut down the test environment
th.Shutdown(t)
// Then verify that the status update processor has properly shut down
// by checking that the done signal channel is closed
select {
case _, ok := <-th.Service.statusUpdateDoneSignal:
// If channel is closed, ok will be false
assert.False(t, ok, "statusUpdateDoneSignal channel should be closed after teardown")
case <-time.After(5 * time.Second):
require.Fail(t, "Timed out waiting for status update processor to shut down")
}
}
func TestSetStatusOffline(t *testing.T) {
th := Setup(t).InitBasic(t)
user := th.BasicUser
t.Run("when user statuses are disabled", func(t *testing.T) {
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableUserStatuses = false
})
// Set initial status to online
status := &model.Status{
UserId: user.Id,
Status: model.StatusOnline,
}
th.Service.SaveAndBroadcastStatus(status)
// Try to set offline
th.Service.SetStatusOffline(user.Id, false, false)
// Enable user statuses to see what is really in the database
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableUserStatuses = true
})
// Status should remain unchanged
after, err := th.Service.GetStatus(user.Id)
require.Nil(t, err)
assert.Equal(t, model.StatusOnline, after.Status)
})
t.Run("when setting status manually over manually set status", func(t *testing.T) {
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableUserStatuses = true
})
// Set initial status to online manually
status := &model.Status{
UserId: user.Id,
Status: model.StatusOnline,
Manual: true,
}
th.Service.SaveAndBroadcastStatus(status)
// Try to set offline non-manually
th.Service.SetStatusOffline(user.Id, false, false)
// Status should remain unchanged because manual status takes precedence
after, err := th.Service.GetStatus(user.Id)
require.Nil(t, err)
assert.Equal(t, model.StatusOnline, after.Status)
assert.True(t, after.Manual)
})
t.Run("when force flag is true over manually set status", func(t *testing.T) {
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableUserStatuses = true
})
// Set initial status to online manually
status := &model.Status{
UserId: user.Id,
Status: model.StatusOnline,
Manual: true,
}
th.Service.SaveAndBroadcastStatus(status)
// Try to set offline with force flag
th.Service.SetStatusOffline(user.Id, false, true)
// Status should change despite being manual
after, err := th.Service.GetStatus(user.Id)
require.Nil(t, err)
assert.Equal(t, model.StatusOffline, after.Status)
assert.False(t, after.Manual)
})
t.Run("when setting status normally", func(t *testing.T) {
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableUserStatuses = true
})
// Set initial status to online
status := &model.Status{
UserId: user.Id,
Status: model.StatusOnline,
Manual: false,
}
th.Service.SaveAndBroadcastStatus(status)
// Set offline
th.Service.SetStatusOffline(user.Id, false, false)
// Status should change
after, err := th.Service.GetStatus(user.Id)
require.Nil(t, err)
assert.Equal(t, model.StatusOffline, after.Status)
assert.False(t, after.Manual)
})
t.Run("when setting status manually over normal status", func(t *testing.T) {
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableUserStatuses = true
})
// Set initial status to online
status := &model.Status{
UserId: user.Id,
Status: model.StatusOnline,
Manual: false,
}
th.Service.SaveAndBroadcastStatus(status)
// Set offline manually
th.Service.SetStatusOffline(user.Id, true, false)
// Status should change and be marked as manual
after, err := th.Service.GetStatus(user.Id)
require.Nil(t, err)
assert.Equal(t, model.StatusOffline, after.Status)
assert.True(t, after.Manual)
})
}

View File

@@ -0,0 +1,641 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"runtime"
rpprof "runtime/pprof"
"strings"
"time"
"github.com/goccy/go-yaml"
"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/platform/shared/mail"
)
const (
envVarInstallType = "MM_INSTALL_TYPE"
unknownDataPoint = "unknown"
)
// diagnosticsYAMLComments annotates non-obvious fields in diagnostics.yaml
// with inline (and a few group-header) comments so support engineers reading
// the file under triage conditions have units, omitempty semantics, and
// cumulative-vs-point-in-time semantics visible at a glance.
var diagnosticsYAMLComments = yaml.CommentMap{
// server: — grouped into Machine / Capacity / Process lifecycle / Software
"$.server.os": {yaml.HeadComment(" Machine")},
"$.server.cpu_cores": {yaml.HeadComment(" Capacity (hardware → effective quota)"), yaml.LineComment(" logical CPUs visible to the OS")},
"$.server.total_memory_mb": {yaml.LineComment(" host/VM total RAM; may exceed container limit")},
"$.server.container_cpu_limit": {yaml.LineComment(" cgroup v2 CPU quota in CPUs; Linux only, omitted if no limit set")},
"$.server.container_memory_limit_mb": {yaml.LineComment(" cgroup v2 memory quota in MB; Linux only, omitted if no limit set")},
"$.server.process_id": {yaml.HeadComment(" Process lifecycle")},
"$.server.started_at": {yaml.LineComment(" when Mattermost process started")},
"$.server.host_started_at": {yaml.LineComment(" when the host OS booted; omitted if unavailable")},
"$.server.open_file_descriptors": {yaml.LineComment(" current open FDs for this process")},
"$.server.max_file_descriptors": {yaml.LineComment(" system limit (ulimit -n)")},
"$.server.version": {yaml.HeadComment(" Software")},
// database: sql.DBStats cumulative counters (lifetime of process; all drivers)
"$.database.master_pool_wait_count": {yaml.LineComment(" cumulative; total times a goroutine waited for a connection since process start")},
"$.database.master_pool_wait_duration_ms": {yaml.LineComment(" cumulative wait time across all goroutines since process start")},
"$.database.master_connections_closed_max_idle": {yaml.LineComment(" cumulative; connections closed because the idle pool was full")},
"$.database.master_connections_closed_max_lifetime": {yaml.LineComment(" cumulative; connections closed for exceeding ConnMaxLifetime")},
"$.database.replica_pool_wait_count": {yaml.LineComment(" cumulative across all replicas; see master_pool_wait_count")},
"$.database.replica_pool_wait_duration_ms": {yaml.LineComment(" cumulative across all replicas")},
"$.database.replica_connections_closed_max_idle": {yaml.LineComment(" cumulative across all replicas")},
"$.database.replica_connections_closed_max_lifetime": {yaml.LineComment(" cumulative across all replicas")},
// database: PostgreSQL-only fields (omitted on MySQL)
"$.database.cache_hit_ratio": {yaml.HeadComment(" PostgreSQL-only (these fields are omitted on MySQL)"), yaml.LineComment(" blks_hit / (blks_hit + blks_read) from pg_stat_database; cumulative since stats reset")},
"$.database.deadlocks": {yaml.LineComment(" cumulative since pg_stat_database reset")},
"$.database.temp_files": {yaml.LineComment(" cumulative count of temp files created since stats reset")},
"$.database.temp_bytes_mb": {yaml.LineComment(" cumulative bytes written to temp files, in MB")},
"$.database.rollbacks": {yaml.LineComment(" cumulative transaction rollbacks since stats reset")},
"$.database.idle_in_transaction_count": {yaml.LineComment(" point-in-time count from pg_stat_activity")},
"$.database.longest_query_duration_seconds": {yaml.LineComment(" point-in-time; max age of any active query right now")},
"$.database.waiting_for_lock_count": {yaml.LineComment(" point-in-time count of backends waiting on a Lock wait_event_type")},
"$.database.posts_dead_tuples": {yaml.LineComment(" n_dead_tup for the posts table from pg_stat_user_tables")},
"$.database.posts_last_autovacuum": {yaml.LineComment(" last autovacuum on posts; null if never autovacuumed (then omitted)")},
// file_store: local driver only fields
"$.file_store.filesystem_type": {yaml.LineComment(" local driver only (e.g. ext4, xfs); omitted for s3 and other remote drivers")},
"$.file_store.total_mb": {yaml.LineComment(" local driver only; capacity of the volume hosting FileSettings.Directory")},
"$.file_store.available_mb": {yaml.LineComment(" local driver only; free space remaining on that volume")},
}
func (ps *PlatformService) GenerateSupportPacket(rctx request.CTX, options *model.SupportPacketOptions) ([]model.FileData, error) {
functions := map[string]func(request.CTX) (*model.FileData, error){
"diagnostics": ps.getSupportPacketDiagnostics,
"config": ps.getSanitizedConfigFile,
"heap profile": ps.getHeapProfile,
"goroutines": ps.getGoroutineProfile,
}
cpuProfileDur := cpuProfileDuration
if options != nil && options.CPUProfileDuration != nil {
cpuProfileDur = *options.CPUProfileDuration
}
if cpuProfileDur > 0 {
functions["cpu profile"] = func(rctx request.CTX) (*model.FileData, error) {
return ps.getCPUProfile(rctx, cpuProfileDur)
}
}
if options != nil && options.IncludeLogs {
functions["mattermost log"] = ps.GetLogFile
}
var (
fileDatas []model.FileData
rErr *multierror.Error
)
for name, fn := range functions {
fileData, err := fn(rctx)
if err != nil {
rctx.Logger().Error("Failed to generate file for Support Packet",
mlog.String("file", name),
mlog.Err(err),
)
rErr = multierror.Append(rErr, err)
}
if fileData != nil {
fileDatas = append(fileDatas, *fileData)
}
}
if options != nil && options.IncludeLogs {
advancedLogs, err := ps.GetAdvancedLogs(rctx)
if err != nil {
rctx.Logger().Error("Failed to read advanced log files for Support Packet", mlog.Err(err))
rErr = multierror.Append(rErr, err)
}
for _, log := range advancedLogs {
fileDatas = append(fileDatas, *log)
}
}
return fileDatas, rErr.ErrorOrNil()
}
func (ps *PlatformService) getSupportPacketDiagnostics(rctx request.CTX) (*model.FileData, error) {
var (
rErr *multierror.Error
err error
d model.SupportPacketDiagnostics
)
d.Version = model.CurrentSupportPacketVersion
/* License */
if license := ps.License(); license != nil {
d.License.Company = license.Customer.Company
d.License.Users = model.SafeDereference(license.Features.Users)
d.License.SkuShortName = license.SkuShortName
d.License.IsTrial = license.IsTrial
d.License.IsGovSKU = license.IsGovSku
d.License.IsNonProduction = license.IsNonProduction
}
/* Server */
d.Server.OS = runtime.GOOS
d.Server.Architecture = runtime.GOARCH
// Note: These values represent the host machine's resources, not any
// container limits (e.g., Docker or Kubernetes) that may be in effect.
d.Server.CPUCores = runtime.NumCPU()
totalMemoryBytes, err := getTotalMemory()
if err != nil {
rErr = multierror.Append(rErr, errors.Wrap(err, "error while getting total memory"))
}
d.Server.TotalMemoryMB = totalMemoryBytes / 1024 / 1024
containerLimits, err := getContainerLimits()
if err != nil {
rctx.Logger().Debug("Failed to get container limits for Support Packet", mlog.Err(err))
} else {
d.Server.ContainerCPULimit = containerLimits.CPULimit
d.Server.ContainerMemoryLimitMB = containerLimits.MemoryLimitMB
}
d.Server.Hostname, err = os.Hostname()
if err != nil {
rErr = multierror.Append(errors.Wrap(err, "error while getting hostname"))
}
d.Server.ProcessID = os.Getpid()
d.Server.StartedAt = ps.startTime.UTC()
if hostUptimeSeconds, hostUptimeErr := getHostUptimeSeconds(); hostUptimeErr == nil {
d.Server.HostStartedAt = time.Now().Add(-time.Duration(hostUptimeSeconds) * time.Second).UTC()
}
d.Server.Version = model.CurrentVersion
d.Server.BuildHash = model.BuildHash
d.Server.GoVersion = runtime.Version()
installationType := ps.installTypeOverride
if installationType == "" {
installationType = os.Getenv(envVarInstallType)
}
if installationType == "" {
installationType = unknownDataPoint
}
d.Server.InstallationType = installationType
d.Server.OpenFileDescriptors, err = getOpenFileDescriptors()
if err != nil {
rErr = multierror.Append(rErr, errors.Wrap(err, "error while getting open file descriptor count"))
}
d.Server.MaxFileDescriptors, err = getMaxFileDescriptors()
if err != nil {
rErr = multierror.Append(rErr, errors.Wrap(err, "error while getting max file descriptor limit"))
}
/* Config */
d.Config.Source = ps.DescribeConfig()
/* DB */
d.Database.Type, d.Database.SchemaVersion, err = ps.DatabaseTypeAndSchemaVersion()
if err != nil {
rErr = multierror.Append(errors.Wrap(err, "error while getting DB type and schema version"))
}
databaseVersion, err := ps.Store.GetDbVersion(false)
if err != nil {
rErr = multierror.Append(errors.Wrap(err, "error while getting DB version"))
} else {
d.Database.Version = databaseVersion
}
d.Database.MasterConnections = ps.Store.TotalMasterDbConnections()
d.Database.ReplicaConnections = ps.Store.TotalReadDbConnections()
d.Database.SearchConnections = ps.Store.TotalSearchDbConnections()
err = ps.applyStoreDiagnostics(rctx, &d)
if err != nil {
rErr = multierror.Append(rErr, err)
}
/* File store */
d.FileStore.Status = model.StatusOk
err = ps.FileBackend().TestConnection()
if err != nil {
d.FileStore.Status = model.StatusFail
d.FileStore.Error = err.Error()
}
d.FileStore.Driver = ps.FileBackend().DriverName()
if d.FileStore.Driver == model.ImageDriverLocal {
dir := model.SafeDereference(ps.Config().FileSettings.Directory)
if dir == "" {
dir = model.FileSettingsDefaultDirectory
}
di, diskErr := getDiskInfo(dir)
if diskErr != nil {
rErr = multierror.Append(errors.Wrap(diskErr, "error while getting disk space info"))
} else {
d.FileStore.FilesystemType = di.FilesystemType
d.FileStore.TotalMB = di.TotalMB
d.FileStore.AvailableMB = di.AvailableMB
}
}
/* Websockets */
d.Websocket.Connections = ps.TotalWebsocketConnections()
/* Cluster */
if cluster := ps.Cluster(); cluster != nil {
d.Cluster.ID = cluster.GetClusterId()
clusterInfo, e := cluster.GetClusterInfos()
if e != nil {
rErr = multierror.Append(rErr, errors.Wrap(e, "error while getting cluster infos"))
} else {
d.Cluster.NumberOfNodes = max(len(clusterInfo), 1) // clusterInfo is empty if the node is the only one in the cluster
}
}
/* LDAP */
if ldap := ps.LdapDiagnostic(); ldap != nil && (*ps.Config().LdapSettings.Enable || *ps.Config().LdapSettings.EnableSync) {
d.LDAP.Status = model.StatusOk
appErr := ldap.RunTest(rctx)
if appErr != nil {
d.LDAP.Status = model.StatusFail
d.LDAP.Error = appErr.Error()
}
severName, serverVersion := unknownDataPoint, unknownDataPoint
// Only if the LDAP test was successful, try to get the LDAP server info
if d.LDAP.Status == model.StatusOk {
severName, serverVersion, err = ldap.GetVendorNameAndVendorVersion(rctx)
if err != nil {
rErr = multierror.Append(errors.Wrap(err, "error while getting LDAP vendor info"))
}
if severName == "" {
severName = unknownDataPoint
}
if serverVersion == "" {
serverVersion = unknownDataPoint
}
}
d.LDAP.ServerName = severName
d.LDAP.ServerVersion = serverVersion
} else {
d.LDAP.Status = model.StatusDisabled
}
/* SAML */
if idpDescriptorURL := model.SafeDereference(ps.Config().SamlSettings.IdpDescriptorURL); idpDescriptorURL != "" {
d.SAML.ProviderType = detectSAMLProviderType(idpDescriptorURL)
}
if samlDiagnostic := ps.SamlDiagnostic(); samlDiagnostic != nil && model.SafeDereference(ps.Config().SamlSettings.Enable) {
if err = samlDiagnostic.RunSupportPacketTest(rctx, ps.Config().SamlSettings); err != nil {
d.SAML.Status = model.StatusFail
d.SAML.Error = err.Error()
} else {
d.SAML.Status = model.StatusOk
}
} else {
d.SAML.Status = model.StatusDisabled
}
/* Elastic Search */
if se := ps.SearchEngine.ElasticsearchEngine; se != nil {
d.ElasticSearch.Backend = *ps.Config().ElasticsearchSettings.Backend
d.ElasticSearch.ServerVersion = se.GetFullVersion()
d.ElasticSearch.ServerPlugins = se.GetPlugins()
if *ps.Config().ElasticsearchSettings.EnableIndexing {
appErr := se.TestConfig(rctx, ps.Config())
if appErr != nil {
d.ElasticSearch.Status = model.StatusFail
d.ElasticSearch.Error = appErr.Error()
} else {
d.ElasticSearch.Status = model.StatusOk
}
} else {
d.ElasticSearch.Status = model.StatusDisabled
}
} else {
d.ElasticSearch.Status = model.StatusDisabled
}
/* Email Notifications */
if model.SafeDereference(ps.Config().EmailSettings.SendEmailNotifications) {
emailSettings := ps.Config().EmailSettings
hostname := utils.GetHostnameFromSiteURL(model.SafeDereference(ps.Config().ServiceSettings.SiteURL))
mailCfg := &mail.SMTPConfig{
Hostname: hostname,
ConnectionSecurity: model.SafeDereference(emailSettings.ConnectionSecurity),
SkipServerCertificateVerification: model.SafeDereference(emailSettings.SkipServerCertificateVerification),
ServerName: model.SafeDereference(emailSettings.SMTPServer),
Server: model.SafeDereference(emailSettings.SMTPServer),
Port: model.SafeDereference(emailSettings.SMTPPort),
ServerTimeout: model.SafeDereference(emailSettings.SMTPServerTimeout),
Username: model.SafeDereference(emailSettings.SMTPUsername),
Password: model.SafeDereference(emailSettings.SMTPPassword),
EnableSMTPAuth: model.SafeDereference(emailSettings.EnableSMTPAuth),
SendEmailNotifications: true,
FeedbackName: model.SafeDereference(emailSettings.FeedbackName),
FeedbackEmail: model.SafeDereference(emailSettings.FeedbackEmail),
ReplyToAddress: model.SafeDereference(emailSettings.ReplyToAddress),
}
if smtpErr := mail.TestConnection(mailCfg); smtpErr != nil {
d.Notifications.Email.Status = model.StatusFail
d.Notifications.Email.Error = smtpErr.Error()
} else {
d.Notifications.Email.Status = model.StatusOk
}
} else {
d.Notifications.Email.Status = model.StatusDisabled
}
/* OAuth2 / OpenID Connect Providers */
d.OAuthProviders.GitLab = probeOAuthProvider(rctx.Context(), &ps.Config().GitLabSettings)
d.OAuthProviders.Google = probeOAuthProvider(rctx.Context(), &ps.Config().GoogleSettings)
d.OAuthProviders.Office365 = probeOAuthProvider(rctx.Context(), ps.Config().Office365Settings.SSOSettings())
d.OAuthProviders.OpenID = probeOAuthProvider(rctx.Context(), &ps.Config().OpenIdSettings)
/* Push Notifications */
if model.SafeDereference(ps.Config().EmailSettings.SendPushNotifications) {
pushServerURL := model.SafeDereference(ps.Config().EmailSettings.PushNotificationServer)
if pushErr := ps.testPushProxyConnection(rctx.Context(), pushServerURL); pushErr != nil {
d.Notifications.Push.Status = model.StatusFail
d.Notifications.Push.Error = pushErr.Error()
} else {
d.Notifications.Push.Status = model.StatusOk
}
} else {
d.Notifications.Push.Status = model.StatusDisabled
}
b, err := yaml.MarshalWithOptions(&d, yaml.WithComment(diagnosticsYAMLComments))
if err != nil {
rErr = multierror.Append(errors.Wrap(err, "failed to marshal Support Packet into yaml"))
}
fileData := &model.FileData{
Filename: "diagnostics.yaml",
Body: b,
}
return fileData, rErr.ErrorOrNil()
}
func (ps *PlatformService) applyStoreDiagnostics(rctx request.CTX, diagnostics *model.SupportPacketDiagnostics) error {
storeDiagnostics, err := ps.Store.GetDiagnostics(rctx)
if storeDiagnostics == nil {
if err != nil {
return errors.Wrap(err, "error while collecting support packet database diagnostics")
}
return nil
}
diagnostics.Database.MasterConnectionsInUse = storeDiagnostics.MasterConnectionsInUse
diagnostics.Database.MasterConnectionsIdle = storeDiagnostics.MasterConnectionsIdle
diagnostics.Database.MasterPoolWaitCount = storeDiagnostics.MasterPoolWaitCount
diagnostics.Database.MasterPoolWaitDurationMs = storeDiagnostics.MasterPoolWaitDurationMs
diagnostics.Database.MasterConnectionsClosedMaxIdle = storeDiagnostics.MasterConnectionsClosedMaxIdle
diagnostics.Database.MasterConnectionsClosedMaxLifetime = storeDiagnostics.MasterConnectionsClosedMaxLifetime
diagnostics.Database.ReplicaConnectionsInUse = storeDiagnostics.ReplicaConnectionsInUse
diagnostics.Database.ReplicaConnectionsIdle = storeDiagnostics.ReplicaConnectionsIdle
diagnostics.Database.ReplicaPoolWaitCount = storeDiagnostics.ReplicaPoolWaitCount
diagnostics.Database.ReplicaPoolWaitDurationMs = storeDiagnostics.ReplicaPoolWaitDurationMs
diagnostics.Database.ReplicaConnectionsClosedMaxIdle = storeDiagnostics.ReplicaConnectionsClosedMaxIdle
diagnostics.Database.ReplicaConnectionsClosedMaxLifetime = storeDiagnostics.ReplicaConnectionsClosedMaxLifetime
diagnostics.Database.CacheHitRatio = storeDiagnostics.CacheHitRatio
diagnostics.Database.Deadlocks = storeDiagnostics.Deadlocks
diagnostics.Database.TempFiles = storeDiagnostics.TempFiles
diagnostics.Database.TempBytesMB = storeDiagnostics.TempBytesMB
diagnostics.Database.Rollbacks = storeDiagnostics.Rollbacks
diagnostics.Database.IdleInTransactionCount = storeDiagnostics.IdleInTransactionCount
diagnostics.Database.LongestQueryDurationSeconds = storeDiagnostics.LongestQueryDurationSeconds
diagnostics.Database.WaitingForLockCount = storeDiagnostics.WaitingForLockCount
diagnostics.Database.PostsDeadTuples = storeDiagnostics.PostsDeadTuples
diagnostics.Database.PostsLastAutovacuum = storeDiagnostics.PostsLastAutovacuum
if err != nil {
return errors.Wrap(err, "error while collecting support packet database diagnostics")
}
return nil
}
// probeOAuthProvider checks connectivity for an OAuth2/OpenID Connect provider.
// If the provider has a DiscoveryEndpoint configured, it issues an HTTP GET to
// that URL and verifies the response is a valid OIDC discovery document.
// Otherwise it probes the TokenEndpoint host: any HTTP response (including
// 4xx/5xx) is treated as reachable, since token endpoints typically reject GETs.
func probeOAuthProvider(ctx context.Context, sso *model.SSOSettings) model.OAuthProviderStatus {
if !model.SafeDereference(sso.Enable) {
return model.OAuthProviderStatus{Status: model.StatusDisabled}
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if discoveryEndpoint := model.SafeDereference(sso.DiscoveryEndpoint); discoveryEndpoint != "" {
if err := probeOIDCDiscovery(ctx, discoveryEndpoint); err != nil {
return model.OAuthProviderStatus{Status: model.StatusFail, Error: err.Error()}
}
return model.OAuthProviderStatus{Status: model.StatusOk}
}
if tokenEndpoint := model.SafeDereference(sso.TokenEndpoint); tokenEndpoint != "" {
if err := probeOAuthTokenEndpoint(ctx, tokenEndpoint); err != nil {
return model.OAuthProviderStatus{Status: model.StatusFail, Error: err.Error()}
}
return model.OAuthProviderStatus{Status: model.StatusOk}
}
return model.OAuthProviderStatus{Status: model.StatusFail, Error: "no discovery or token endpoint configured"}
}
func probeOIDCDiscovery(ctx context.Context, discoveryURL string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer drainAndCloseBody(resp.Body)
if resp.StatusCode >= http.StatusBadRequest {
return fmt.Errorf("discovery endpoint returned unexpected status %d", resp.StatusCode)
}
// Cap the discovery document at 1 MiB; real OIDC discovery responses are a few KiB.
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return errors.Wrap(err, "failed to read discovery response")
}
var doc struct {
Issuer string `json:"issuer"`
}
if err := json.Unmarshal(body, &doc); err != nil {
return errors.Wrap(err, "discovery endpoint did not return valid JSON")
}
if doc.Issuer == "" {
return fmt.Errorf("discovery endpoint response missing required 'issuer' field")
}
return nil
}
func probeOAuthTokenEndpoint(ctx context.Context, tokenURL string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer drainAndCloseBody(resp.Body)
return nil
}
// drainAndCloseBody fully reads and discards an HTTP response body (up to 1 MiB
// to bound a misbehaving server) and closes it. Draining before closing allows
// net/http to return the underlying TCP connection to the idle pool for
// keep-alive reuse on subsequent requests.
func drainAndCloseBody(body io.ReadCloser) {
_, _ = io.Copy(io.Discard, io.LimitReader(body, 1<<20))
_ = body.Close()
}
// TODO: move this into its own push proxy package once one exists (see also pushNotificationClient in server.go)
func (ps *PlatformService) testPushProxyConnection(ctx context.Context, serverURL string) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
versionURL, err := url.JoinPath(serverURL, "version")
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, versionURL, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer drainAndCloseBody(resp.Body)
if resp.StatusCode >= http.StatusBadRequest {
return fmt.Errorf("push proxy returned unexpected status %d", resp.StatusCode)
}
return nil
}
func (ps *PlatformService) getSanitizedConfigFile(rctx request.CTX) (*model.FileData, error) {
config := ps.getSanitizedConfig(rctx, &model.SanitizeOptions{PartiallyRedactDataSources: true})
spConfig := model.SupportPacketConfig{
Config: config,
FeatureFlags: *config.FeatureFlags,
}
sanitizedConfigPrettyJSON, err := json.MarshalIndent(spConfig, "", " ")
if err != nil {
return nil, errors.Wrap(err, "failed to sanitized config into json")
}
fileData := &model.FileData{
Filename: "sanitized_config.json",
Body: sanitizedConfigPrettyJSON,
}
return fileData, nil
}
func (ps *PlatformService) getCPUProfile(_ request.CTX, duration time.Duration) (*model.FileData, error) {
var b bytes.Buffer
err := rpprof.StartCPUProfile(&b)
if err != nil {
return nil, errors.Wrap(err, "failed to start CPU profile")
}
time.Sleep(duration)
rpprof.StopCPUProfile()
fileData := &model.FileData{
Filename: "cpu.prof",
Body: b.Bytes(),
}
return fileData, nil
}
func (ps *PlatformService) getHeapProfile(_ request.CTX) (*model.FileData, error) {
var b bytes.Buffer
err := rpprof.Lookup("heap").WriteTo(&b, 0)
if err != nil {
return nil, errors.Wrap(err, "failed to lookup heap profile")
}
fileData := &model.FileData{
Filename: "heap.prof",
Body: b.Bytes(),
}
return fileData, nil
}
func (ps *PlatformService) getGoroutineProfile(_ request.CTX) (*model.FileData, error) {
var b bytes.Buffer
err := rpprof.Lookup("goroutine").WriteTo(&b, 2)
if err != nil {
return nil, errors.Wrap(err, "failed to lookup goroutine profile")
}
fileData := &model.FileData{
Filename: "goroutines",
Body: b.Bytes(),
}
return fileData, nil
}
// detectSAMLProviderType attempts to identify the SAML provider type based on the IdpDescriptorURL.
// It returns "unknown" if the provider cannot be identified.
func detectSAMLProviderType(idpDescriptorURL string) string {
if idpDescriptorURL == "" {
return unknownDataPoint
}
// Normalize URL to lowercase for case-insensitive matching
normalizedURL := strings.ToLower(idpDescriptorURL)
// Check for common SAML provider patterns in the EntityID/IdpDescriptorURL
// Order matters: more specific patterns should come before generic ones
switch {
case strings.Contains(normalizedURL, "login.microsoftonline.com") || strings.Contains(normalizedURL, "sts.windows.net"):
return "Azure AD"
case strings.Contains(normalizedURL, ".okta.com") || strings.Contains(normalizedURL, ".oktapreview.com"):
return "Okta"
case strings.Contains(normalizedURL, ".auth0.com"):
return "Auth0"
case strings.Contains(normalizedURL, ".onelogin.com"):
return "OneLogin"
case strings.Contains(normalizedURL, "accounts.google.com"):
return "Google Workspace"
case strings.Contains(normalizedURL, "sso.jumpcloud.com"):
return "JumpCloud"
case strings.Contains(normalizedURL, "duo.com/saml2"):
return "Duo"
case strings.Contains(normalizedURL, ".centrify.com"):
return "Centrify"
case strings.Contains(normalizedURL, "/realms/"):
return "Keycloak"
case strings.Contains(normalizedURL, "/adfs") || strings.Contains(normalizedURL, "/federationmetadata/"):
return "ADFS"
case strings.Contains(normalizedURL, "shibboleth.net") || strings.Contains(normalizedURL, "/idp/shibboleth"):
return "Shibboleth"
default:
return unknownDataPoint
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build darwin
package platform
import (
"fmt"
"time"
"golang.org/x/sys/unix"
)
// getHostUptimeSeconds uses kern.boottime sysctl to return the number of seconds
// the host OS has been running.
func getHostUptimeSeconds() (int64, error) {
tv, err := unix.SysctlTimeval("kern.boottime")
if err != nil {
return 0, fmt.Errorf("failed to get kern.boottime: %w", err)
}
bootTime := time.Unix(tv.Sec, int64(tv.Usec)*int64(time.Microsecond))
return int64(time.Since(bootTime).Seconds()), nil
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build darwin
package platform
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetHostUptimeSeconds(t *testing.T) {
t.Run("returns a positive value from kern.boottime", func(t *testing.T) {
seconds, err := getHostUptimeSeconds()
require.NoError(t, err)
assert.Positive(t, seconds)
})
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build linux
package platform
import (
"fmt"
"os"
"strconv"
"strings"
)
// getHostUptimeSeconds reads /proc/uptime and returns the number of seconds
// the host OS has been running.
func getHostUptimeSeconds() (int64, error) {
return parseUptimeFile("/proc/uptime")
}
// parseUptimeFile reads an uptime file in the /proc/uptime format and returns
// the number of seconds as an int64. It is a separate function to allow unit
// tests to supply a synthetic file path.
func parseUptimeFile(path string) (int64, error) {
data, err := os.ReadFile(path)
if err != nil {
return 0, fmt.Errorf("failed to read /proc/uptime: %w", err)
}
fields := strings.Fields(string(data))
if len(fields) == 0 {
return 0, fmt.Errorf("unexpected /proc/uptime format")
}
f, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
return 0, fmt.Errorf("failed to parse /proc/uptime value: %w", err)
}
return int64(f), nil
}

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build linux
package platform
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetHostUptimeSeconds(t *testing.T) {
t.Run("returns a positive value from /proc/uptime", func(t *testing.T) {
seconds, err := getHostUptimeSeconds()
require.NoError(t, err)
assert.Positive(t, seconds)
})
t.Run("error on unreadable file", func(t *testing.T) {
missingPath := t.TempDir() + "/uptime"
_, err := parseUptimeFile(missingPath)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to read")
})
t.Run("error on empty file", func(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "uptime")
require.NoError(t, err)
require.NoError(t, f.Close())
_, err = parseUptimeFile(f.Name())
require.Error(t, err)
assert.Contains(t, err.Error(), "unexpected /proc/uptime format")
})
t.Run("error on non-numeric first field", func(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "uptime")
require.NoError(t, err)
_, err = f.WriteString("notanumber 0.00\n")
require.NoError(t, err)
require.NoError(t, f.Close())
_, err = parseUptimeFile(f.Name())
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse /proc/uptime value")
})
t.Run("parses valid uptime correctly", func(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "uptime")
require.NoError(t, err)
_, err = f.WriteString("12345.67 890.12\n")
require.NoError(t, err)
require.NoError(t, f.Close())
seconds, err := parseUptimeFile(f.Name())
require.NoError(t, err)
assert.Equal(t, int64(12345), seconds)
})
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build !linux && !darwin
package platform
import "errors"
// ErrHostUptimeUnsupportedPlatform is returned when host uptime detection is not supported on the current platform.
var ErrHostUptimeUnsupportedPlatform = errors.New("host uptime detection not supported on this platform")
// getHostUptimeSeconds returns an error on non-Linux platforms.
func getHostUptimeSeconds() (int64, error) {
return 0, ErrHostUptimeUnsupportedPlatform
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//go:build !linux && !darwin
package platform
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetHostUptimeSeconds(t *testing.T) {
seconds, err := getHostUptimeSeconds()
require.Error(t, err)
assert.ErrorIs(t, err, ErrHostUptimeUnsupportedPlatform)
assert.Equal(t, int64(0), seconds)
}

View File

@@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"crypto/sha256"
"encoding/base64"
)
func getKeyHash(key string) string {
hash := sha256.New()
hash.Write([]byte(key))
return base64.StdEncoding.EncodeToString(hash.Sum(nil))
}
// allocateCacheTargets is used to fill target value types
// for getting items from cache.
func allocateCacheTargets[T any](l int) []any {
toPass := make([]any, 0, l)
for range l {
toPass = append(toPass, new(T))
}
return toPass
}

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
type BroadcastHook interface {
// Process takes a WebSocket event and modifies it in some way. It is passed a HookedWebSocketEvent which allows
// safe modification of the event.
Process(msg *HookedWebSocketEvent, webConn *WebConn, args map[string]any) error
}
func (h *Hub) runBroadcastHooks(msg *model.WebSocketEvent, webConn *WebConn, hookIDs []string, hookArgs []map[string]any) *model.WebSocketEvent {
if len(hookIDs) == 0 {
return msg
}
hookedEvent := MakeHookedWebSocketEvent(msg)
for i, hookID := range hookIDs {
hook := h.broadcastHooks[hookID]
args := hookArgs[i]
if hook == nil {
mlog.Warn("runBroadcastHooks: Unable to find broadcast hook", mlog.String("hook_id", hookID))
continue
}
err := hook.Process(hookedEvent, webConn, args)
if err != nil {
mlog.Warn("runBroadcastHooks: Error processing hook", mlog.String("hook_id", hookID), mlog.Err(err))
}
}
return hookedEvent.Event()
}
// HookedWebSocketEvent is a wrapper for model.WebSocketEvent that is intended to provide a similar interface, except
// it ensures the original WebSocket event is not modified.
type HookedWebSocketEvent struct {
original *model.WebSocketEvent
copy *model.WebSocketEvent
}
func MakeHookedWebSocketEvent(event *model.WebSocketEvent) *HookedWebSocketEvent {
return &HookedWebSocketEvent{
original: event,
}
}
func (he *HookedWebSocketEvent) Add(key string, value any) {
he.copyIfNecessary()
he.copy.Add(key, value)
}
func (he *HookedWebSocketEvent) EventType() model.WebsocketEventType {
if he.copy == nil {
return he.original.EventType()
}
return he.copy.EventType()
}
// Get returns a value from the WebSocket event data. You should never mutate a value returned by this method.
func (he *HookedWebSocketEvent) Get(key string) any {
if he.copy == nil {
return he.original.GetData()[key]
}
return he.copy.GetData()[key]
}
// copyIfNecessary should be called by any mutative method to ensure that the copy is instantiated.
func (he *HookedWebSocketEvent) copyIfNecessary() {
if he.copy == nil {
he.copy = he.original.RemovePrecomputedJSON()
}
}
func (he *HookedWebSocketEvent) Event() *model.WebSocketEvent {
if he.copy == nil {
return he.original
}
return he.copy
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const broadcastTest = "test_broadcast_hook"
type testBroadcastHook struct{}
func (h *testBroadcastHook) Process(msg *HookedWebSocketEvent, webConn *WebConn, args map[string]any) error {
if args["makes_changes"].(bool) {
changesMade, _ := msg.Get("changes_made").(int)
msg.Add("changes_made", changesMade+1)
}
return nil
}
func TestRunBroadcastHooks(t *testing.T) {
mainHelper.Parallel(t)
hub := &Hub{
broadcastHooks: map[string]BroadcastHook{
broadcastTest: &testBroadcastHook{},
},
}
webConn := &WebConn{}
t.Run("should not allocate a new object when no hooks are passed", func(t *testing.T) {
event := model.NewWebSocketEvent(model.WebsocketEventPosted, "", "", "", nil, "")
result := hub.runBroadcastHooks(event, webConn, nil, nil)
assert.Same(t, event, result)
})
t.Run("should not allocate a new object when a hook is not making changes", func(t *testing.T) {
event := model.NewWebSocketEvent(model.WebsocketEventPosted, "", "", "", nil, "")
hookIDs := []string{
broadcastTest,
}
hookArgs := []map[string]any{
{
"makes_changes": false,
},
}
result := hub.runBroadcastHooks(event, webConn, hookIDs, hookArgs)
assert.Same(t, event, result)
})
t.Run("should allocate a new object and remove when a hook makes changes", func(t *testing.T) {
event := model.NewWebSocketEvent(model.WebsocketEventPosted, "", "", "", nil, "")
hookIDs := []string{
broadcastTest,
}
hookArgs := []map[string]any{
{
"makes_changes": true,
},
}
result := hub.runBroadcastHooks(event, webConn, hookIDs, hookArgs)
assert.NotSame(t, event, result)
model.AssertNotSameMap(t, event.GetData(), result.GetData())
assert.Equal(t, map[string]any{}, event.GetData())
assert.Equal(t, result.GetData(), map[string]any{
"changes_made": 1,
})
})
t.Run("should not allocate a new object when multiple hooks are not making changes", func(t *testing.T) {
event := model.NewWebSocketEvent(model.WebsocketEventPosted, "", "", "", nil, "")
hookIDs := []string{
broadcastTest,
broadcastTest,
broadcastTest,
}
hookArgs := []map[string]any{
{
"makes_changes": false,
},
{
"makes_changes": false,
},
{
"makes_changes": false,
},
}
result := hub.runBroadcastHooks(event, webConn, hookIDs, hookArgs)
assert.Same(t, event, result)
})
t.Run("should be able to make changes from only one of make hooks", func(t *testing.T) {
event := model.NewWebSocketEvent(model.WebsocketEventPosted, "", "", "", nil, "")
var hookIDs []string
var hookArgs []map[string]any
for i := range 10 {
hookIDs = append(hookIDs, broadcastTest)
hookArgs = append(hookArgs, map[string]any{
"makes_changes": i == 6,
})
}
result := hub.runBroadcastHooks(event, webConn, hookIDs, hookArgs)
assert.NotSame(t, event, result)
model.AssertNotSameMap(t, event.GetData(), result.GetData())
assert.Equal(t, event.GetData(), map[string]any{})
assert.Equal(t, result.GetData(), map[string]any{
"changes_made": 1,
})
})
t.Run("should be able to make changes from multiple hooks", func(t *testing.T) {
event := model.NewWebSocketEvent(model.WebsocketEventPosted, "", "", "", nil, "")
var hookIDs []string
var hookArgs []map[string]any
for range 10 {
hookIDs = append(hookIDs, broadcastTest)
hookArgs = append(hookArgs, map[string]any{
"makes_changes": true,
})
}
result := hub.runBroadcastHooks(event, webConn, hookIDs, hookArgs)
assert.NotSame(t, event, result)
model.AssertNotSameMap(t, event.GetData(), result.GetData())
assert.Equal(t, event.GetData(), map[string]any{})
assert.Equal(t, result.GetData(), map[string]any{
"changes_made": 10,
})
})
t.Run("should not remove precomputed JSON when a hook doesn't make changes", func(t *testing.T) {
event := model.NewWebSocketEvent(model.WebsocketEventPosted, "", "", "", nil, "")
event = event.PrecomputeJSON()
// Ensure that the event has precomputed JSON because changes aren't included when ToJSON is called again
originalJSON, _ := event.ToJSON()
event.Add("data", 1234)
eventJSON, _ := event.ToJSON()
require.Equal(t, string(originalJSON), string(eventJSON))
hookIDs := []string{
broadcastTest,
}
hookArgs := []map[string]any{
{
"makes_changes": false,
},
}
result := hub.runBroadcastHooks(event, webConn, hookIDs, hookArgs)
eventJSON, _ = event.ToJSON()
assert.Equal(t, string(originalJSON), string(eventJSON))
resultJSON, _ := result.ToJSON()
assert.Equal(t, originalJSON, resultJSON)
})
t.Run("should remove precomputed JSON when a hook makes changes", func(t *testing.T) {
event := model.NewWebSocketEvent(model.WebsocketEventPosted, "", "", "", nil, "")
event = event.PrecomputeJSON()
// Ensure that the event has precomputed JSON because changes aren't included when ToJSON is called again
originalJSON, _ := event.ToJSON()
event.Add("data", 1234)
eventJSON, _ := event.ToJSON()
require.Equal(t, originalJSON, eventJSON)
hookIDs := []string{
broadcastTest,
}
hookArgs := []map[string]any{
{
"makes_changes": true,
},
}
result := hub.runBroadcastHooks(event, webConn, hookIDs, hookArgs)
eventJSON, _ = event.ToJSON()
assert.Equal(t, string(originalJSON), string(eventJSON))
resultJSON, _ := result.ToJSON()
assert.NotEqual(t, originalJSON, resultJSON)
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,285 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"bytes"
"errors"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)
type hookRunner struct {
}
func (h *hookRunner) RunMultiHook(hookRunnerFunc func(hooks plugin.Hooks, _ *model.Manifest) bool, hookId int) {
}
func (h *hookRunner) HooksForPlugin(id string) (plugin.Hooks, error) {
return nil, errors.New("not implemented")
}
func (h *hookRunner) GetPluginsEnvironment() *plugin.Environment {
return nil
}
func TestWebConnAddDeadQueue(t *testing.T) {
th := Setup(t)
wc := th.Service.NewWebConn(&WebConnConfig{
WebSocket: &websocket.Conn{},
}, th.Suite, &hookRunner{})
for i := range 2 {
msg := &model.WebSocketEvent{}
msg = msg.SetSequence(int64(i))
wc.addToDeadQueue(msg)
}
for i := range 2 {
assert.Equal(t, int64(i), wc.deadQueue[i].GetSequence())
}
// Should push out the first two elements
for i := range deadQueueSize {
msg := &model.WebSocketEvent{}
msg = msg.SetSequence(int64(i + 2))
wc.addToDeadQueue(msg)
}
for i := range deadQueueSize {
assert.Equal(t, int64(i+2), wc.deadQueue[(i+2)%deadQueueSize].GetSequence())
}
}
func TestWebConnIsInDeadQueue(t *testing.T) {
th := Setup(t)
wc := th.Service.NewWebConn(&WebConnConfig{
WebSocket: &websocket.Conn{},
}, th.Suite, &hookRunner{})
var i int
for ; i < 2; i++ {
msg := &model.WebSocketEvent{}
msg = msg.SetSequence(int64(i))
wc.addToDeadQueue(msg)
}
wc.Sequence = int64(0)
ok, ind := wc.isInDeadQueue(wc.Sequence)
assert.True(t, ok)
assert.Equal(t, 0, ind)
assert.True(t, wc.hasMsgLoss())
wc.Sequence = int64(1)
ok, ind = wc.isInDeadQueue(wc.Sequence)
assert.True(t, ok)
assert.Equal(t, 1, ind)
assert.True(t, wc.hasMsgLoss())
wc.Sequence = int64(2)
ok, ind = wc.isInDeadQueue(wc.Sequence)
assert.False(t, ok)
assert.Equal(t, 0, ind)
assert.False(t, wc.hasMsgLoss())
for ; i < deadQueueSize+2; i++ {
msg := &model.WebSocketEvent{}
msg = msg.SetSequence(int64(i))
wc.addToDeadQueue(msg)
}
wc.Sequence = int64(129)
ok, ind = wc.isInDeadQueue(wc.Sequence)
assert.True(t, ok)
assert.Equal(t, 1, ind)
wc.Sequence = int64(128)
ok, ind = wc.isInDeadQueue(wc.Sequence)
assert.True(t, ok)
assert.Equal(t, 0, ind)
wc.Sequence = int64(2)
ok, ind = wc.isInDeadQueue(wc.Sequence)
assert.True(t, ok)
assert.Equal(t, 2, ind)
assert.True(t, wc.hasMsgLoss())
wc.Sequence = int64(0)
ok, ind = wc.isInDeadQueue(wc.Sequence)
assert.False(t, ok)
assert.Equal(t, 0, ind)
wc.Sequence = int64(130)
ok, ind = wc.isInDeadQueue(wc.Sequence)
assert.False(t, ok)
assert.Equal(t, 0, ind)
assert.False(t, wc.hasMsgLoss())
}
func TestWebConnClearDeadQueue(t *testing.T) {
th := Setup(t)
wc := th.Service.NewWebConn(&WebConnConfig{
WebSocket: &websocket.Conn{},
}, th.Suite, &hookRunner{})
var i int
for ; i < 2; i++ {
msg := &model.WebSocketEvent{}
msg = msg.SetSequence(int64(i))
wc.addToDeadQueue(msg)
}
wc.clearDeadQueue()
assert.Equal(t, 0, wc.deadQueuePointer)
}
func TestWebConnDrainDeadQueue(t *testing.T) {
th := Setup(t)
var dialConn = func(t *testing.T, th *TestHelper, addr net.Addr) *WebConn {
d := websocket.Dialer{}
c, _, err := d.Dial("ws://"+addr.String()+"/ws", nil)
require.NoError(t, err)
cfg := &WebConnConfig{
WebSocket: c,
}
return th.Service.NewWebConn(cfg, th.Suite, &hookRunner{})
}
t.Run("Empty Queue", func(t *testing.T) {
var handler = func(t *testing.T) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
upgrader := &websocket.Upgrader{}
conn, err := upgrader.Upgrade(w, req, nil)
cnt := 0
for err == nil {
_, _, err = conn.ReadMessage()
cnt++
}
assert.Equal(t, 1, cnt)
if _, ok := err.(*websocket.CloseError); !ok {
require.NoError(t, err)
}
}
}
s := httptest.NewServer(handler(t))
defer s.Close()
wc := dialConn(t, th, s.Listener.Addr())
defer wc.WebSocket.Close()
wc.clearDeadQueue()
err := wc.drainDeadQueue(0)
require.NoError(t, err)
})
var handler = func(t *testing.T, seqNum int64, limit int) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
upgrader := &websocket.Upgrader{}
conn, err := upgrader.Upgrade(w, req, nil)
var buf []byte
i := seqNum
for err == nil {
_, buf, err = conn.ReadMessage()
if err != nil && len(buf) > 0 {
ev, jsonErr := model.WebSocketEventFromJSON(bytes.NewReader(buf))
require.NoError(t, jsonErr)
require.LessOrEqual(t, int(i), limit)
assert.Equal(t, i, ev.GetSequence())
i++
}
}
if _, ok := err.(*websocket.CloseError); !ok {
require.NoError(t, err)
}
}
}
run := func(seqNum int64, limit int) {
s := httptest.NewServer(handler(t, seqNum, limit))
defer s.Close()
wc := dialConn(t, th, s.Listener.Addr())
defer wc.WebSocket.Close()
for i := range limit {
msg := model.NewWebSocketEvent("", "", "", "", map[string]bool{}, "")
msg = msg.SetSequence(int64(i))
wc.addToDeadQueue(msg)
}
wc.Sequence = seqNum
ok, index := wc.isInDeadQueue(wc.Sequence)
require.True(t, ok)
err := wc.drainDeadQueue(index)
require.NoError(t, err)
}
t.Run("Half-full Queue", func(t *testing.T) {
t.Run("Middle", func(t *testing.T) { run(int64(2), 10) })
t.Run("Beginning", func(t *testing.T) { run(int64(0), 10) })
t.Run("End", func(t *testing.T) { run(int64(9), 10) })
t.Run("Full", func(t *testing.T) { run(int64(deadQueueSize-1), deadQueueSize) })
})
t.Run("Cycled Queue", func(t *testing.T) {
t.Run("First un-overwritten", func(t *testing.T) { run(int64(10), deadQueueSize+10) })
t.Run("End", func(t *testing.T) { run(int64(127), deadQueueSize+10) })
t.Run("Cycled End", func(t *testing.T) { run(int64(137), deadQueueSize+10) })
t.Run("Overwritten First", func(t *testing.T) { run(int64(128), deadQueueSize+10) })
})
}
func TestWebConnRejectBinaryFrameUnauthenticated(t *testing.T) {
th := Setup(t)
readPumpDone := make(chan struct{})
upgradeErrCh := make(chan error, 1)
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upgrader := &websocket.Upgrader{}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
upgradeErrCh <- err
return
}
upgradeErrCh <- nil
wc := th.Service.NewWebConn(&WebConnConfig{
WebSocket: conn,
}, th.Suite, &hookRunner{})
require.False(t, wc.IsAuthenticated())
go func() {
wc.readPump()
close(readPumpDone)
}()
}))
defer s.Close()
d := websocket.Dialer{}
clientConn, _, err := d.Dial("ws://"+s.Listener.Addr().String()+"/ws", nil)
require.NoError(t, err)
defer clientConn.Close()
require.NoError(t, <-upgradeErrCh)
err = clientConn.WriteMessage(websocket.BinaryMessage, []byte{0x01, 0x02, 0x03})
require.NoError(t, err)
select {
case <-readPumpDone:
case <-time.After(5 * time.Second):
require.Fail(t, "readPump did not exit after receiving binary frame")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
// deliveryRecorderSpy collects the (marker, userID) pairs the hub hands to the recorder.
type deliveryRecorderSpy struct {
calls []deliveryRecorderCall
}
type deliveryRecorderCall struct {
marker *model.PostDeliveryMarker
userID string
}
func (s *deliveryRecorderSpy) record(marker *model.PostDeliveryMarker, userID string) {
s.calls = append(s.calls, deliveryRecorderCall{marker: marker, userID: userID})
}
// newDeliveryTestConn builds an authenticated WebConn with a send buffer of the given size,
// registered in connIndex.
func newDeliveryTestConn(t *testing.T, th *TestHelper, connIndex *hubConnectionIndex, userID string, sendBuffer int) *WebConn {
t.Helper()
wc := &WebConn{
Platform: th.Service,
Suite: th.Suite,
UserId: userID,
send: make(chan model.WebSocketMessage, sendBuffer),
}
wc.SetConnectionID(model.NewId())
wc.SetSession(&model.Session{UserId: userID})
wc.Active.Store(true)
wc.SetSessionExpiresAt(model.GetMillis() + 100000)
require.NoError(t, connIndex.Add(wc))
return wc
}
func TestHubBroadcastDelivery(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
marker := &model.PostDeliveryMarker{
PostId: model.NewId(),
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
}
// Addressed to the recipient directly, so ShouldSendEvent resolves without needing
// channel membership plumbed into the index.
newEvent := func() *model.WebSocketEvent {
event := model.NewWebSocketEvent(model.WebsocketEventPosted, "", "", th.BasicUser2.Id, nil, "")
event.Add("post", "{}")
return event
}
t.Run("records when the event is enqueued", func(t *testing.T) {
spy := &deliveryRecorderSpy{}
th.Service.SetPostDeliveryRecorder(spy.record)
t.Cleanup(func() { th.Service.SetPostDeliveryRecorder(nil) })
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger, false)
wc := newDeliveryTestConn(t, th, connIndex, th.BasicUser2.Id, 1)
hub := th.Service.GetHubForUserId(th.BasicUser2.Id)
hub.broadcastToConn(connIndex, wc, newEvent(), marker, nil, nil)
require.Len(t, spy.calls, 1)
require.Equal(t, marker, spy.calls[0].marker)
require.Equal(t, th.BasicUser2.Id, spy.calls[0].userID)
require.Len(t, wc.send, 1)
})
t.Run("records nothing when the connection is not registered", func(t *testing.T) {
spy := &deliveryRecorderSpy{}
th.Service.SetPostDeliveryRecorder(spy.record)
t.Cleanup(func() { th.Service.SetPostDeliveryRecorder(nil) })
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger, false)
wc := newDeliveryTestConn(t, th, connIndex, th.BasicUser2.Id, 1)
connIndex.Remove(wc)
hub := th.Service.GetHubForUserId(th.BasicUser2.Id)
hub.broadcastToConn(connIndex, wc, newEvent(), marker, nil, nil)
require.Empty(t, spy.calls)
})
t.Run("records nothing when the send buffer is full", func(t *testing.T) {
spy := &deliveryRecorderSpy{}
th.Service.SetPostDeliveryRecorder(spy.record)
t.Cleanup(func() { th.Service.SetPostDeliveryRecorder(nil) })
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger, false)
// A zero-capacity buffer with no reader means the send always hits the default branch.
wc := newDeliveryTestConn(t, th, connIndex, th.BasicUser2.Id, 0)
hub := th.Service.GetHubForUserId(th.BasicUser2.Id)
hub.broadcastToConn(connIndex, wc, newEvent(), marker, nil, nil)
require.Empty(t, spy.calls)
})
t.Run("records nothing for the post's author", func(t *testing.T) {
spy := &deliveryRecorderSpy{}
th.Service.SetPostDeliveryRecorder(spy.record)
t.Cleanup(func() { th.Service.SetPostDeliveryRecorder(nil) })
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger, false)
// BasicUser wrote the post the marker describes.
wc := newDeliveryTestConn(t, th, connIndex, th.BasicUser.Id, 1)
authorEvent := model.NewWebSocketEvent(model.WebsocketEventPosted, "", "", th.BasicUser.Id, nil, "")
authorEvent.Add("post", "{}")
hub := th.Service.GetHubForUserId(th.BasicUser.Id)
hub.broadcastToConn(connIndex, wc, authorEvent, marker, nil, nil)
require.Empty(t, spy.calls, "the author's own post echo is not a delivery")
require.Len(t, wc.send, 1, "the event still reaches the author")
})
t.Run("records nothing without a marker", func(t *testing.T) {
spy := &deliveryRecorderSpy{}
th.Service.SetPostDeliveryRecorder(spy.record)
t.Cleanup(func() { th.Service.SetPostDeliveryRecorder(nil) })
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger, false)
wc := newDeliveryTestConn(t, th, connIndex, th.BasicUser2.Id, 1)
hub := th.Service.GetHubForUserId(th.BasicUser2.Id)
hub.broadcastToConn(connIndex, wc, newEvent(), nil, nil, nil)
require.Empty(t, spy.calls)
require.Len(t, wc.send, 1)
})
t.Run("no recorder configured is a no-op", func(t *testing.T) {
th.Service.SetPostDeliveryRecorder(nil)
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger, false)
wc := newDeliveryTestConn(t, th, connIndex, th.BasicUser2.Id, 1)
hub := th.Service.GetHubForUserId(th.BasicUser2.Id)
require.NotPanics(t, func() {
hub.broadcastToConn(connIndex, wc, newEvent(), marker, nil, nil)
})
})
}

View File

@@ -0,0 +1,955 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"bytes"
"encoding/json"
"fmt"
"iter"
"net"
"net/http"
"net/http/httptest"
"runtime"
"slices"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
platform_mocks "github.com/mattermost/mattermost/server/v8/channels/app/platform/mocks"
"github.com/mattermost/mattermost/server/v8/channels/testlib"
)
func dummyWebsocketHandler(t *testing.T) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
upgrader := &websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
conn, err := upgrader.Upgrade(w, req, nil)
for err == nil {
_, _, err = conn.ReadMessage()
}
if _, ok := err.(*websocket.CloseError); !ok {
require.NoError(t, err)
}
}
}
func registerDummyWebConn(t *testing.T, th *TestHelper, addr net.Addr, session *model.Session) *WebConn {
d := websocket.Dialer{}
c, _, err := d.Dial("ws://"+addr.String()+"/ws", nil)
require.NoError(t, err)
cfg := &WebConnConfig{
WebSocket: c,
Session: *session,
TFunc: i18n.IdentityTfunc(),
Locale: "en",
}
wc := th.Service.NewWebConn(cfg, th.Suite, &hookRunner{})
require.NoError(t, th.Service.HubRegister(wc))
go wc.Pump()
return wc
}
func TestHubStopWithMultipleConnections(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
s := httptest.NewServer(dummyWebsocketHandler(t))
defer s.Close()
session, err := th.Service.CreateSession(th.Context, &model.Session{
UserId: th.BasicUser.Id,
})
require.NoError(t, err)
wc1 := registerDummyWebConn(t, th, s.Listener.Addr(), session)
wc2 := registerDummyWebConn(t, th, s.Listener.Addr(), session)
wc3 := registerDummyWebConn(t, th, s.Listener.Addr(), session)
defer wc1.Close()
defer wc2.Close()
defer wc3.Close()
}
// TestHubStopRaceCondition verifies that attempts to use the hub after it has shutdown does not
// block the caller indefinitely.
func TestHubStopRaceCondition(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
s := httptest.NewServer(dummyWebsocketHandler(t))
session, err := th.Service.CreateSession(th.Context, &model.Session{
UserId: th.BasicUser.Id,
})
require.NoError(t, err)
wc1 := registerDummyWebConn(t, th, s.Listener.Addr(), session)
defer wc1.Close()
hub := th.Service.hubs[0]
th.Shutdown(t)
done := make(chan bool)
go func() {
wc4 := registerDummyWebConn(t, th, s.Listener.Addr(), session)
wc5 := registerDummyWebConn(t, th, s.Listener.Addr(), session)
require.NoError(t, hub.Register(wc4))
require.NoError(t, hub.Register(wc5))
hub.UpdateActivity("userId", "sessionToken", 0)
for i := 0; i <= broadcastQueueSize; i++ {
hub.Broadcast(model.NewWebSocketEvent("", "", "", "", nil, ""))
}
hub.InvalidateUser("userId")
hub.Unregister(wc4)
hub.Unregister(wc5)
close(done)
}()
select {
case <-done:
case <-time.After(15 * time.Second):
require.FailNow(t, "hub call did not return within 15 seconds after stop")
}
}
func TestHubSessionRevokeRace(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
// This needs to be false for the condition to trigger
th.Service.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.ExtendSessionLengthWithActivity = false
})
s := httptest.NewServer(dummyWebsocketHandler(t))
defer s.Close()
session, err := th.Service.CreateSession(th.Context, &model.Session{
UserId: model.NewId(),
})
require.NoError(t, err)
wc1 := registerDummyWebConn(t, th, s.Listener.Addr(), session)
hub := th.Service.GetHubForUserId(wc1.UserId)
done := make(chan bool)
time.Sleep(2 * time.Second)
// We override the LastActivityAt which happens in NewWebConn.
// This is needed to call RevokeSessionById which triggers the race.
err = th.Service.AddSessionToCache(session)
require.NoError(t, err)
go func() {
for i := 0; i <= broadcastQueueSize; i++ {
hub.Broadcast(model.NewWebSocketEvent("", "teamID", "", "", nil, ""))
}
close(done)
}()
// This call should happen _after_ !wc.IsAuthenticated() and _before_wc.isMemberOfTeam().
// There's no guarantee this will happen. But that's our best bet to trigger this race.
wc1.InvalidateCache()
for range 10 {
// If broadcast buffer has not emptied,
// we sleep for a second and check again
if len(hub.broadcast) > 0 {
time.Sleep(time.Second)
continue
}
}
if len(hub.broadcast) > 0 {
require.Fail(t, "hub is deadlocked")
}
}
func TestHubConnIndex(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
_, err := th.Service.Store.Channel().SaveMember(th.Context, &model.ChannelMember{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
NotifyProps: model.GetDefaultChannelNotifyProps(),
SchemeGuest: th.BasicUser.IsGuest(),
SchemeUser: !th.BasicUser.IsGuest(),
})
require.NoError(t, err)
_, err = th.Service.Store.Channel().SaveMember(th.Context, &model.ChannelMember{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser2.Id,
NotifyProps: model.GetDefaultChannelNotifyProps(),
SchemeGuest: th.BasicUser2.IsGuest(),
SchemeUser: !th.BasicUser2.IsGuest(),
})
require.NoError(t, err)
for _, fastIterate := range []bool{true, false} {
t.Run(fmt.Sprintf("fastIterate=%t", fastIterate), func(t *testing.T) {
t.Run("Basic", func(t *testing.T) {
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger, fastIterate)
// User1
wc1 := &WebConn{
Platform: th.Service,
Suite: th.Suite,
UserId: model.NewId(),
}
wc1.SetConnectionID(model.NewId())
wc1.SetSession(&model.Session{})
// User2
wc2 := &WebConn{
Platform: th.Service,
Suite: th.Suite,
UserId: model.NewId(),
}
wc2.SetConnectionID(model.NewId())
wc2.SetSession(&model.Session{})
wc3 := &WebConn{
Platform: th.Service,
Suite: th.Suite,
UserId: wc2.UserId,
}
wc3.SetConnectionID(model.NewId())
wc3.SetSession(&model.Session{})
wc4 := &WebConn{
Platform: th.Service,
Suite: th.Suite,
UserId: wc2.UserId,
}
wc4.SetConnectionID(model.NewId())
wc4.SetSession(&model.Session{})
errAdd := connIndex.Add(wc1)
require.NoError(t, errAdd)
err = connIndex.Add(wc2)
require.NoError(t, err)
err = connIndex.Add(wc3)
require.NoError(t, err)
err = connIndex.Add(wc4)
require.NoError(t, err)
t.Run("Basic", func(t *testing.T) {
assert.True(t, connIndex.Has(wc1))
assert.True(t, connIndex.Has(wc2))
assert.ElementsMatch(t, slices.Collect(connIndex.ForUser(wc2.UserId)), []*WebConn{wc2, wc3, wc4})
assert.ElementsMatch(t, slices.Collect(connIndex.ForUser(wc1.UserId)), []*WebConn{wc1})
assert.True(t, connIndex.Has(wc2))
assert.True(t, connIndex.Has(wc1))
assert.Len(t, connIndex.All(), 4)
})
t.Run("RemoveMiddleUser2", func(t *testing.T) {
connIndex.Remove(wc3) // Remove from middle from user2
assert.ElementsMatch(t, slices.Collect(connIndex.ForUser(wc2.UserId)), []*WebConn{wc2, wc4})
assert.ElementsMatch(t, slices.Collect(connIndex.ForUser(wc1.UserId)), []*WebConn{wc1})
assert.True(t, connIndex.Has(wc2))
assert.False(t, connIndex.Has(wc3))
assert.True(t, connIndex.Has(wc4))
assert.Len(t, connIndex.All(), 3)
})
t.Run("RemoveUser1", func(t *testing.T) {
connIndex.Remove(wc1) // Remove sole connection from user1
assert.ElementsMatch(t, slices.Collect(connIndex.ForUser(wc2.UserId)), []*WebConn{wc2, wc4})
assert.ElementsMatch(t, slices.Collect(connIndex.ForUser(wc1.UserId)), []*WebConn{})
assert.Len(t, slices.Collect(connIndex.ForUser(wc1.UserId)), 0)
assert.Len(t, connIndex.All(), 2)
assert.False(t, connIndex.Has(wc1))
assert.True(t, connIndex.Has(wc2))
})
t.Run("RemoveEndUser2", func(t *testing.T) {
connIndex.Remove(wc4) // Remove from end from user2
assert.ElementsMatch(t, slices.Collect(connIndex.ForUser(wc2.UserId)), []*WebConn{wc2})
assert.ElementsMatch(t, slices.Collect(connIndex.ForUser(wc1.UserId)), []*WebConn{})
assert.True(t, connIndex.Has(wc2))
assert.False(t, connIndex.Has(wc3))
assert.False(t, connIndex.Has(wc4))
assert.Len(t, connIndex.All(), 1)
})
})
t.Run("ByConnectionId", func(t *testing.T) {
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger, fastIterate)
// User1
wc1ID := model.NewId()
wc1 := &WebConn{
Platform: th.Service,
Suite: th.Suite,
UserId: th.BasicUser.Id,
}
wc1.SetConnectionID(wc1ID)
wc1.SetSession(&model.Session{})
// User2
wc2ID := model.NewId()
wc2 := &WebConn{
Platform: th.Service,
Suite: th.Suite,
UserId: th.BasicUser2.Id,
}
wc2.SetConnectionID(wc2ID)
wc2.SetSession(&model.Session{})
wc3ID := model.NewId()
wc3 := &WebConn{
Platform: th.Service,
Suite: th.Suite,
UserId: wc2.UserId,
}
wc3.SetConnectionID(wc3ID)
wc3.SetSession(&model.Session{})
t.Run("no connections", func(t *testing.T) {
assert.False(t, connIndex.Has(wc1))
assert.False(t, connIndex.Has(wc2))
assert.False(t, connIndex.Has(wc3))
assert.Empty(t, connIndex.byConnectionId)
})
t.Run("adding", func(t *testing.T) {
err = connIndex.Add(wc1)
require.NoError(t, err)
err = connIndex.Add(wc3)
require.NoError(t, err)
assert.Len(t, connIndex.byConnectionId, 2)
assert.Equal(t, wc1, connIndex.ForConnection(wc1ID))
assert.Equal(t, wc3, connIndex.ForConnection(wc3ID))
assert.Equal(t, (*WebConn)(nil), connIndex.ForConnection(wc2ID))
})
t.Run("removing", func(t *testing.T) {
connIndex.Remove(wc3)
assert.Len(t, connIndex.byConnectionId, 1)
assert.Equal(t, wc1, connIndex.ForConnection(wc1ID))
assert.Equal(t, (*WebConn)(nil), connIndex.ForConnection(wc3ID))
assert.Equal(t, (*WebConn)(nil), connIndex.ForConnection(wc2ID))
})
})
})
}
t.Run("ByChannelId", func(t *testing.T) {
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger, true)
// User1
wc1ID := model.NewId()
wc1 := &WebConn{
Platform: th.Service,
Suite: th.Suite,
UserId: th.BasicUser.Id,
}
wc1.SetConnectionID(wc1ID)
wc1.SetSession(&model.Session{})
// User2
wc2ID := model.NewId()
wc2 := &WebConn{
Platform: th.Service,
Suite: th.Suite,
UserId: th.BasicUser2.Id,
}
wc2.SetConnectionID(wc2ID)
wc2.SetSession(&model.Session{})
wc3ID := model.NewId()
wc3 := &WebConn{
Platform: th.Service,
Suite: th.Suite,
UserId: wc2.UserId,
}
wc3.SetConnectionID(wc3ID)
wc3.SetSession(&model.Session{})
err = connIndex.Add(wc1)
require.NoError(t, err)
err = connIndex.Add(wc2)
require.NoError(t, err)
err = connIndex.Add(wc3)
require.NoError(t, err)
t.Run("ForChannel", func(t *testing.T) {
require.Len(t, connIndex.byChannelID, 1)
ids := make([]string, 0)
for c := range connIndex.ForChannel(th.BasicChannel.Id) {
ids = append(ids, c.GetConnectionID())
}
require.ElementsMatch(t, []string{wc1ID, wc2ID, wc3ID}, ids)
require.Len(t, slices.Collect(connIndex.ForChannel("notexist")), 0)
})
ch := th.CreateChannel(t, th.BasicTeam)
_, err = th.Service.Store.Channel().SaveMember(th.Context, &model.ChannelMember{
ChannelId: ch.Id,
UserId: th.BasicUser2.Id,
NotifyProps: model.GetDefaultChannelNotifyProps(),
SchemeGuest: th.BasicUser2.IsGuest(),
SchemeUser: !th.BasicUser2.IsGuest(),
})
require.NoError(t, err)
t.Run("InvalidateCMCacheForUser", func(t *testing.T) {
require.NoError(t, connIndex.InvalidateCMCacheForUser(th.BasicUser2.Id))
require.Len(t, connIndex.byChannelID, 2)
require.Len(t, slices.Collect(connIndex.ForChannel(th.BasicChannel.Id)), 3)
require.Len(t, slices.Collect(connIndex.ForChannel(ch.Id)), 2)
})
t.Run("Remove", func(t *testing.T) {
connIndex.Remove(wc3)
require.Len(t, connIndex.byChannelID, 2)
require.Len(t, slices.Collect(connIndex.ForChannel(th.BasicChannel.Id)), 2)
})
})
}
func TestHubConnIndexIncorrectRemoval(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger, false)
// User2
wc2 := &WebConn{
Platform: th.Service,
Suite: th.Suite,
UserId: model.NewId(),
}
wc2.SetConnectionID("first")
wc2.SetSession(&model.Session{})
wc3 := &WebConn{
Platform: th.Service,
Suite: th.Suite,
UserId: wc2.UserId,
}
wc3.SetConnectionID("myID")
wc3.SetSession(&model.Session{})
wc4 := &WebConn{
Platform: th.Service,
Suite: th.Suite,
UserId: wc2.UserId,
}
wc4.SetConnectionID("last")
wc4.SetSession(&model.Session{})
err := connIndex.Add(wc2)
require.NoError(t, err)
err = connIndex.Add(wc3)
require.NoError(t, err)
err = connIndex.Add(wc4)
require.NoError(t, err)
for wc := range connIndex.ForUser(wc2.UserId) {
if !connIndex.Has(wc) {
require.Failf(t, "Failed to find connection", "connection: %v", wc)
continue
}
if connIndex.ForConnection("myID") != nil {
connIndex.Remove(wc)
}
}
}
func TestHubConnIndexInactive(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
connIndex := newHubConnectionIndex(2*time.Second, th.Service.Store, th.Service.logger, false)
// User1
wc1 := &WebConn{
Platform: th.Service,
UserId: model.NewId(),
}
wc1.Active.Store(true)
wc1.SetConnectionID("conn1")
wc1.SetSession(&model.Session{})
// User2
wc2 := &WebConn{
Platform: th.Service,
UserId: model.NewId(),
}
wc2.Active.Store(true)
wc2.SetConnectionID("conn2")
wc2.SetSession(&model.Session{})
wc3 := &WebConn{
Platform: th.Service,
UserId: wc2.UserId,
}
wc3.Active.Store(false)
wc3.SetConnectionID("conn3")
wc3.SetSession(&model.Session{})
err := connIndex.Add(wc1)
require.NoError(t, err)
err = connIndex.Add(wc2)
require.NoError(t, err)
err = connIndex.Add(wc3)
require.NoError(t, err)
assert.Nil(t, connIndex.RemoveInactiveByConnectionID(wc2.UserId, "conn2"))
assert.Equal(t, connIndex.ForUserActiveCount(wc2.UserId), 1)
assert.NotNil(t, connIndex.RemoveInactiveByConnectionID(wc2.UserId, "conn3"))
assert.Equal(t, connIndex.ForUserActiveCount(wc2.UserId), 1)
assert.Nil(t, connIndex.RemoveInactiveByConnectionID(wc1.UserId, "conn3"))
assert.False(t, connIndex.Has(wc3))
assert.Len(t, slices.Collect(connIndex.ForUser(wc2.UserId)), 1)
wc3.lastUserActivityAt = model.GetMillis()
err = connIndex.Add(wc3)
require.NoError(t, err)
connIndex.RemoveInactiveConnections()
assert.True(t, connIndex.Has(wc3))
assert.Len(t, slices.Collect(connIndex.ForUser(wc2.UserId)), 2)
assert.Equal(t, connIndex.ForUserActiveCount(wc2.UserId), 1)
assert.Len(t, connIndex.All(), 3)
wc3.lastUserActivityAt = model.GetMillis() - (time.Minute).Milliseconds()
connIndex.RemoveInactiveConnections()
assert.False(t, connIndex.Has(wc3))
assert.Len(t, slices.Collect(connIndex.ForUser(wc2.UserId)), 1)
assert.Equal(t, connIndex.ForUserActiveCount(wc2.UserId), 1)
assert.Len(t, connIndex.All(), 2)
}
func TestReliableWebSocketSend(t *testing.T) {
mainHelper.Parallel(t)
testCluster := &testlib.FakeClusterInterface{}
th := SetupWithCluster(t, testCluster)
ev := model.NewWebSocketEvent("test_unreliable_event", "", "", "", nil, "")
ev = ev.SetBroadcast(&model.WebsocketBroadcast{})
th.Service.Publish(ev)
ev2 := model.NewWebSocketEvent("test_reliable_event", "", "", "", nil, "")
ev2 = ev2.SetBroadcast(&model.WebsocketBroadcast{
ReliableClusterSend: true,
})
th.Service.Publish(ev2)
messages := testCluster.GetMessages()
evJSON, err := ev.ToJSON()
require.NoError(t, err)
ev2JSON, err := ev2.ToJSON()
require.NoError(t, err)
require.Contains(t, messages, &model.ClusterMessage{
Event: model.ClusterEventPublish,
Data: evJSON,
SendType: model.ClusterSendBestEffort,
})
require.Contains(t, messages, &model.ClusterMessage{
Event: model.ClusterEventPublish,
Data: ev2JSON,
SendType: model.ClusterSendReliable,
})
}
func TestHubIsRegistered(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
session, err := th.Service.CreateSession(th.Context, &model.Session{
UserId: th.BasicUser.Id,
})
require.NoError(t, err)
mockSuite := &platform_mocks.SuiteIFace{}
mockSuite.On("GetSession", session.Token).Return(session, nil)
mockSuite.On("MFARequired", mock.Anything, mock.Anything).Return(nil)
th.Suite = mockSuite
s := httptest.NewServer(dummyWebsocketHandler(t))
defer s.Close()
wc1 := registerDummyWebConn(t, th, s.Listener.Addr(), session)
wc2 := registerDummyWebConn(t, th, s.Listener.Addr(), session)
wc3 := registerDummyWebConn(t, th, s.Listener.Addr(), session)
defer wc1.Close()
defer wc2.Close()
defer wc3.Close()
assert.True(t, th.Service.SessionIsRegistered(*wc1.session.Load()))
assert.True(t, th.Service.SessionIsRegistered(*wc2.session.Load()))
assert.True(t, th.Service.SessionIsRegistered(*wc3.session.Load()))
session4, err := th.Service.CreateSession(th.Context, &model.Session{
UserId: th.BasicUser2.Id,
})
require.NoError(t, err)
assert.False(t, th.Service.SessionIsRegistered(*session4))
}
func TestHubWebConnCount(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
session, err := th.Service.CreateSession(th.Context, &model.Session{
UserId: th.BasicUser.Id,
})
require.NoError(t, err)
mockSuite := &platform_mocks.SuiteIFace{}
mockSuite.On("GetSession", session.Token).Return(session, nil)
mockSuite.On("MFARequired", mock.Anything, mock.Anything).Return(nil)
th.Suite = mockSuite
s := httptest.NewServer(dummyWebsocketHandler(t))
defer s.Close()
wc1 := registerDummyWebConn(t, th, s.Listener.Addr(), session)
wc2 := registerDummyWebConn(t, th, s.Listener.Addr(), session)
defer wc1.Close()
assert.Equal(t, 2, th.Service.WebConnCountForUser(th.BasicUser.Id))
wc2.Close()
assert.Equal(t, 1, th.Service.WebConnCountForUser(th.BasicUser.Id))
assert.Equal(t, 0, th.Service.WebConnCountForUser("none"))
}
var globalIter iter.Seq[*WebConn]
func BenchmarkHubConnIndexIteratorForUser(b *testing.B) {
th := Setup(b)
connIndex := newHubConnectionIndex(2*time.Second, th.Service.Store, th.Service.logger, false)
// User1
wc1 := &WebConn{
Platform: th.Service,
UserId: model.NewId(),
}
wc1.Active.Store(true)
wc1.SetConnectionID("conn1")
wc1.SetSession(&model.Session{})
// User2
wc2 := &WebConn{
Platform: th.Service,
UserId: model.NewId(),
}
wc2.Active.Store(true)
wc2.SetConnectionID("conn2")
wc2.SetSession(&model.Session{})
wc3 := &WebConn{
Platform: th.Service,
UserId: wc2.UserId,
}
wc3.Active.Store(false)
wc3.SetConnectionID("conn3")
wc3.SetSession(&model.Session{})
require.NoError(b, connIndex.Add(wc1))
require.NoError(b, connIndex.Add(wc2))
require.NoError(b, connIndex.Add(wc3))
b.Run("2 users", func(b *testing.B) {
for b.Loop() {
globalIter = connIndex.ForUser(wc2.UserId)
}
})
wc4 := &WebConn{
Platform: th.Service,
UserId: wc2.UserId,
}
wc4.Active.Store(false)
wc4.SetConnectionID("conn4")
wc4.SetSession(&model.Session{})
require.NoError(b, connIndex.Add(wc4))
b.Run("3 users", func(b *testing.B) {
for b.Loop() {
globalIter = connIndex.ForUser(wc2.UserId)
}
})
wc5 := &WebConn{
Platform: th.Service,
UserId: wc2.UserId,
}
wc5.Active.Store(false)
wc5.SetConnectionID("conn5")
wc5.SetSession(&model.Session{})
require.NoError(b, connIndex.Add(wc5))
b.Run("4 users", func(b *testing.B) {
for b.Loop() {
globalIter = connIndex.ForUser(wc2.UserId)
}
})
}
func BenchmarkHubConnIndexIteratorForChannel(b *testing.B) {
th := Setup(b).InitBasic(b)
_, err := th.Service.Store.Channel().SaveMember(th.Context, &model.ChannelMember{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser.Id,
NotifyProps: model.GetDefaultChannelNotifyProps(),
SchemeGuest: th.BasicUser.IsGuest(),
SchemeUser: !th.BasicUser.IsGuest(),
})
require.NoError(b, err)
_, err = th.Service.Store.Channel().SaveMember(th.Context, &model.ChannelMember{
ChannelId: th.BasicChannel.Id,
UserId: th.BasicUser2.Id,
NotifyProps: model.GetDefaultChannelNotifyProps(),
SchemeGuest: th.BasicUser2.IsGuest(),
SchemeUser: !th.BasicUser2.IsGuest(),
})
require.NoError(b, err)
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger, true)
// User1
wc1ID := model.NewId()
wc1 := &WebConn{
Platform: th.Service,
Suite: th.Suite,
UserId: th.BasicUser.Id,
}
wc1.SetConnectionID(wc1ID)
wc1.SetSession(&model.Session{})
// User2
wc2ID := model.NewId()
wc2 := &WebConn{
Platform: th.Service,
Suite: th.Suite,
UserId: th.BasicUser2.Id,
}
wc2.SetConnectionID(wc2ID)
wc2.SetSession(&model.Session{})
wc3ID := model.NewId()
wc3 := &WebConn{
Platform: th.Service,
Suite: th.Suite,
UserId: wc2.UserId,
}
wc3.SetConnectionID(wc3ID)
wc3.SetSession(&model.Session{})
require.NoError(b, connIndex.Add(wc1))
require.NoError(b, connIndex.Add(wc2))
require.NoError(b, connIndex.Add(wc3))
for b.Loop() {
globalIter = connIndex.ForChannel(th.BasicChannel.Id)
}
}
// Always run this with -benchtime=0.1s
// See: https://github.com/golang/go/issues/27217.
func BenchmarkHubConnIndex(b *testing.B) {
th := Setup(b).InitBasic(b)
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger, false)
// User1
wc1 := &WebConn{
Platform: th.Service,
Suite: th.Suite,
UserId: model.NewId(),
}
// User2
wc2 := &WebConn{
Platform: th.Service,
Suite: th.Suite,
UserId: model.NewId(),
}
b.Run("Add", func(b *testing.B) {
for b.Loop() {
err := connIndex.Add(wc1)
require.NoError(b, err)
err = connIndex.Add(wc2)
require.NoError(b, err)
// Cleanup
b.StopTimer()
connIndex.Remove(wc1)
connIndex.Remove(wc2)
b.StartTimer()
}
})
b.Run("Remove", func(b *testing.B) {
for b.Loop() {
// Setup
b.StopTimer()
err := connIndex.Add(wc1)
require.NoError(b, err)
err = connIndex.Add(wc2)
require.NoError(b, err)
b.StartTimer()
connIndex.Remove(wc1)
connIndex.Remove(wc2)
}
})
}
func TestHubConnIndexRemoveMemLeak(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
connIndex := newHubConnectionIndex(1*time.Second, th.Service.Store, th.Service.logger, false)
wc := &WebConn{
Platform: th.Service,
Suite: th.Suite,
}
wc.SetConnectionID(model.NewId())
wc.SetSession(&model.Session{})
ch := make(chan struct{})
runtime.SetFinalizer(wc, func(*WebConn) {
close(ch)
})
err := connIndex.Add(wc)
require.NoError(t, err)
connIndex.Remove(wc)
runtime.GC()
timer := time.NewTimer(3 * time.Second)
defer timer.Stop()
select {
case <-ch:
case <-timer.C:
require.Fail(t, "timeout waiting for collection of wc")
}
assert.Len(t, connIndex.byConnection, 0)
}
var hubSink *Hub
func BenchmarkGetHubForUserId(b *testing.B) {
th := Setup(b).InitBasic(b)
err := th.Service.Start(nil)
require.NoError(b, err)
for b.Loop() {
hubSink = th.Service.GetHubForUserId(th.BasicUser.Id)
}
}
func TestClusterBroadcast(t *testing.T) {
mainHelper.Parallel(t)
testCluster := &testlib.FakeClusterInterface{}
th := SetupWithCluster(t, testCluster)
ev := model.NewWebSocketEvent("test_event", "", "", "", nil, "")
broadcast := &model.WebsocketBroadcast{
ContainsSanitizedData: true,
ContainsSensitiveData: true,
}
ev = ev.SetBroadcast(broadcast)
th.Service.Publish(ev)
messages := testCluster.GetMessages()
var clusterEvent struct {
Event string `json:"event"`
Data map[string]any `json:"data"`
Broadcast *model.WebsocketBroadcast `json:"broadcast"`
Sequence int64 `json:"seq"`
}
err := json.Unmarshal(messages[0].Data, &clusterEvent)
require.NoError(t, err)
require.Equal(t, clusterEvent.Broadcast, broadcast)
}
func TestClusterBroadcastHooks(t *testing.T) {
mainHelper.Parallel(t)
t.Run("should send broadcast hook information across cluster", func(t *testing.T) {
testCluster := &testlib.FakeClusterInterface{}
th := SetupWithCluster(t, testCluster)
hookID := broadcastTest
hookArgs := map[string]any{
"makes_changes": true,
}
event := model.NewWebSocketEvent(model.WebsocketEventPosted, "", "", "", nil, "")
event.GetBroadcast().AddHook(hookID, hookArgs)
th.Service.Publish(event)
received, err := model.WebSocketEventFromJSON(bytes.NewReader(testCluster.GetMessages()[0].Data))
require.NoError(t, err)
assert.Equal(t, []string{hookID}, received.GetBroadcast().BroadcastHooks)
assert.Equal(t, []map[string]any{hookArgs}, received.GetBroadcast().BroadcastHookArgs)
})
t.Run("should not preserve type information for args", func(t *testing.T) {
// This behaviour isn't ideal, but this test confirms that it hasn't changed
testCluster := &testlib.FakeClusterInterface{}
th := SetupWithCluster(t, testCluster)
hookID := "test_broadcast_hook_with_args"
hookArgs := map[string]any{
"user": &model.User{Id: "user1"},
"array": []string{"a", "b", "c"},
}
event := model.NewWebSocketEvent(model.WebsocketEventPosted, "", "", "", nil, "")
event.GetBroadcast().AddHook(hookID, hookArgs)
th.Service.Publish(event)
received, err := model.WebSocketEventFromJSON(bytes.NewReader(testCluster.GetMessages()[0].Data))
require.NoError(t, err)
assert.Equal(t, []string{hookID}, received.GetBroadcast().BroadcastHooks)
assert.IsType(t, map[string]any{}, received.GetBroadcast().BroadcastHookArgs[0]["user"])
assert.IsType(t, []any{}, received.GetBroadcast().BroadcastHookArgs[0]["array"])
})
}

View File

@@ -0,0 +1,162 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"bytes"
"encoding/json"
"fmt"
"github.com/mattermost/mattermost/server/public/model"
)
func (ps *PlatformService) GetWSQueues(userID, connectionID string, seqNum int64) (*model.WSQueues, error) {
hub := ps.GetHubForUserId(userID)
if hub == nil {
return nil, nil
}
connRes := hub.CheckConn(userID, connectionID)
if connRes == nil {
return nil, nil
}
aq := connRes.ActiveQueue
dq := connRes.DeadQueue
dqPtr := connRes.DeadQueuePointer
// Nothing was written on this server. Early return.
if dq[0] == nil {
return nil, nil
}
// Check if seq_num-1 == last value in the dead queue.
if perfectMatch := !_hasMsgLoss(dq, dqPtr, seqNum); perfectMatch {
close(aq)
aqSlice, err := ps.marshalAQ(aq, connectionID, userID)
if err != nil {
return nil, fmt.Errorf("failed to get from active queue: %w", err)
}
// send only aq
return &model.WSQueues{
ActiveQ: aqSlice,
ReuseCount: connRes.ReuseCount,
}, nil
}
// Check if seq_num is somewhere else in the dead queue.
if ok, index := _isInDeadQueue(dq, seqNum); ok {
close(aq)
aqSlice, err := ps.marshalAQ(aq, connectionID, userID)
if err != nil {
return nil, fmt.Errorf("failed to get from active queue: %w", err)
}
dqSlice, err := ps.marshalDQ(dq, index, dqPtr)
if err != nil {
return nil, fmt.Errorf("failed to get from dead queue: %w", err)
}
// send aq + drainedDq.
return &model.WSQueues{
ActiveQ: aqSlice,
DeadQ: dqSlice,
ReuseCount: connRes.ReuseCount,
}, nil
}
// Nothing matched.
return nil, nil
}
func (ps *PlatformService) marshalAQ(aq <-chan model.WebSocketMessage, connID, userID string) ([]model.ActiveQueueItem, error) {
aqSlice := make([]model.ActiveQueueItem, 0)
for msg := range aq {
evtType := model.WebSocketMsgTypeResponse
_, evtOk := msg.(*model.WebSocketEvent)
if evtOk {
evtType = model.WebSocketMsgTypeEvent
}
buf, err := msg.ToJSON()
if err != nil {
return nil, fmt.Errorf("failed to marshal websocket event: %w, connection_id=%s, user_id=%s", err, connID, userID)
}
aqSlice = append(aqSlice, model.ActiveQueueItem{
Buf: json.RawMessage(buf),
Type: evtType,
})
}
return aqSlice, nil
}
func (ps *PlatformService) UnmarshalAQItem(aqItem model.ActiveQueueItem) (model.WebSocketMessage, error) {
var item model.WebSocketMessage
var err error
if aqItem.Type == model.WebSocketMsgTypeEvent {
item, err = model.WebSocketEventFromJSON(bytes.NewReader(aqItem.Buf))
} else if aqItem.Type == model.WebSocketMsgTypeResponse {
item, err = model.WebSocketResponseFromJSON(bytes.NewReader(aqItem.Buf))
} else {
return nil, fmt.Errorf("unknown websocket message type: %q", aqItem.Type)
}
return item, err
}
// marshalDQ is the same as drainDeadQueue, except it writes to a byte slice
// instead of the network. To be refactored into a single method.
func (ps *PlatformService) marshalDQ(dq []*model.WebSocketEvent, index, dqPtr int) ([]json.RawMessage, error) {
if len(dq) == 0 || dq[0] == nil {
return nil, nil
}
dqSlice := make([]json.RawMessage, 0)
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
// This means pointer hasn't rolled over.
if dq[dqPtr] == nil {
// Clear till the end of queue.
for i := index; i < dqPtr; i++ {
buf.Reset()
err := dq[i].Encode(enc, &buf)
if err != nil {
return nil, fmt.Errorf("error in encoding websocket message in dead queue: %w", err)
}
dqSlice = append(dqSlice, bytes.Clone(buf.Bytes()))
}
return dqSlice, nil
}
// We go on until next sequence number is smaller than previous one.
// Which means it has rolled over.
currPtr := index
for {
buf.Reset()
err := dq[currPtr].Encode(enc, &buf)
if err != nil {
return nil, fmt.Errorf("error in encoding websocket message in dead queue: %w", err)
}
dqSlice = append(dqSlice, bytes.Clone(buf.Bytes()))
oldSeq := dq[currPtr].GetSequence()
currPtr = (currPtr + 1) % deadQueueSize
newSeq := dq[currPtr].GetSequence()
if oldSeq > newSeq {
break
}
}
return dqSlice, nil
}
func (ps *PlatformService) UnmarshalDQ(buf []json.RawMessage) ([]*model.WebSocketEvent, int, error) {
dqPtr := 0
dq := make([]*model.WebSocketEvent, deadQueueSize)
for _, dqItem := range buf {
item, err := model.WebSocketEventFromJSON(bytes.NewReader(dqItem))
if err != nil {
return nil, 0, err
}
// Same as active queue, this can never be out of bounds because all dead queues
// are of deadQueueSize.
dq[dqPtr] = item
dqPtr = (dqPtr + 1) % deadQueueSize
}
return dq, dqPtr, nil
}

View File

@@ -0,0 +1,118 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMarshalAQ(t *testing.T) {
ps := PlatformService{}
events := []model.WebSocketMessage{
model.NewWebSocketEvent(model.WebsocketEventPosted, "t1", "c1", "u1", nil, ""),
model.NewWebSocketEvent(model.WebsocketEventReactionAdded, "t2", "c1", "u1", nil, ""),
model.NewWebSocketEvent(model.WebsocketEventReactionRemoved, "t3", "c1", "u1", nil, ""),
model.NewWebSocketResponse("hi", 10, nil),
}
aq := make(chan model.WebSocketMessage, 10)
for _, ev := range events {
aq <- ev
}
close(aq)
queue, err := ps.marshalAQ(aq, "connID", "u1")
require.NoError(t, err)
assert.Len(t, queue, 4)
var gotEvents []model.WebSocketMessage
for _, item := range queue {
msg, err := ps.UnmarshalAQItem(item)
require.NoError(t, err)
gotEvents = append(gotEvents, msg)
}
assert.Equal(t, events, gotEvents)
}
func TestMarshalDQ(t *testing.T) {
ps := PlatformService{}
// Nothing in case of dead queue is empty
got, err := ps.marshalDQ([]*model.WebSocketEvent{}, 0, 0)
require.NoError(t, err)
require.Nil(t, got)
events := []*model.WebSocketEvent{
model.NewWebSocketEvent(model.WebsocketEventPosted, "t1", "c1", "u1", nil, ""),
model.NewWebSocketEvent(model.WebsocketEventReactionAdded, "t2", "c1", "u1", nil, "").SetSequence(1),
model.NewWebSocketEvent(model.WebsocketEventReactionRemoved, "t3", "c1", "u1", nil, "").SetSequence(2),
nil,
nil,
}
got, err = ps.marshalDQ(events, 0, 3)
require.NoError(t, err)
require.Len(t, got, 3)
gotEvents, dqPtr, err := ps.UnmarshalDQ(got)
require.NoError(t, err)
assert.Equal(t, 3, dqPtr)
assert.Equal(t, events[:3], gotEvents[:3])
}
func TestUnmarshalDQFullBuffer(t *testing.T) {
ps := PlatformService{}
t.Run("dq full", func(t *testing.T) {
// Create exactly deadQueueSize events
events := make([]*model.WebSocketEvent, deadQueueSize)
for i := range deadQueueSize {
events[i] = model.NewWebSocketEvent(model.WebsocketEventPosted, "t1", "c1", "u1", nil, "").SetSequence(int64(i))
}
// Set up a scenario where the buffer is already filled and has wrapped around
// Use index 0 and simulate that the dqPtr has wrapped around to 0 again
got, err := ps.marshalDQ(events, 0, 0)
require.NoError(t, err)
require.Len(t, got, deadQueueSize)
// Unmarshal the full buffer back
gotEvents, dqPtr, err := ps.UnmarshalDQ(got)
require.NoError(t, err)
// Check that dqPtr wraps around to 0, not deadQueueSize
assert.Equal(t, 0, dqPtr, "dqPtr should be 0 for a full buffer (deadQueueSize % deadQueueSize = 0)")
// Verify all events were unmarshaled correctly
assert.Equal(t, events, gotEvents)
})
t.Run("dq rollover", func(t *testing.T) {
// Alternative test: Create a simulation of the circular buffer behavior
// This test fills up to the max and ensures wraparound works correctly
events := make([]*model.WebSocketEvent, deadQueueSize)
for i := range deadQueueSize {
// Create events with sequence numbers that show wraparound
// Last event will have highest sequence to demonstrate the break condition
// Seq nos: 100 - 228
events[i] = model.NewWebSocketEvent(model.WebsocketEventPosted, "t1", "c1", "u1", nil, "").SetSequence(int64(i + 100))
}
// Marshal only the last entry wrapping to the first to test wraparound detection
got2, err := ps.marshalDQ(events, deadQueueSize-1, 0)
require.NoError(t, err)
require.Len(t, got2, 1) // Just the last element
// Unmarshal this single element
gotEvents2, dqPtr2, err := ps.UnmarshalDQ(got2)
require.NoError(t, err)
assert.Equal(t, 1, dqPtr2, "dqPtr should be 1 for a 1-element buffer (1 % deadQueueSize = 1)")
assert.Equal(t, events[deadQueueSize-1], gotEvents2[0])
})
}

View File

@@ -0,0 +1,146 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package platform
import (
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
type webSocketHandler interface {
ServeWebSocket(*WebConn, *model.WebSocketRequest)
}
type WebSocketRouter struct {
handlers map[string]webSocketHandler
}
func (wr *WebSocketRouter) Handle(action string, handler webSocketHandler) {
wr.handlers[action] = handler
}
func (wr *WebSocketRouter) ServeWebSocket(conn *WebConn, r *model.WebSocketRequest) {
if r.Action == "" {
err := model.NewAppError("ServeWebSocket", "api.web_socket_router.no_action.app_error", nil, "", http.StatusBadRequest)
returnWebSocketError(conn.Platform, conn, r, err)
return
}
if r.Seq <= 0 {
err := model.NewAppError("ServeWebSocket", "api.web_socket_router.bad_seq.app_error", nil, "", http.StatusBadRequest)
returnWebSocketError(conn.Platform, conn, r, err)
return
}
if r.Action == string(model.WebsocketAuthenticationChallenge) {
if conn.GetSessionToken() != "" {
return
}
token, ok := r.Data["token"].(string)
if !ok {
conn.WebSocket.Close()
return
}
session, err := conn.Suite.GetSession(token)
if err != nil {
conn.Platform.Log().Warn("Error while getting session token", mlog.Err(err))
conn.WebSocket.Close()
return
}
conn.SetSession(session)
conn.SetSessionToken(session.Token)
conn.UserId = session.UserId
nErr := conn.Platform.HubRegister(conn)
if nErr != nil {
conn.Platform.Log().Error("Error while registering to hub", mlog.String("user_id", conn.UserId), mlog.Err(nErr))
conn.WebSocket.Close()
return
}
conn.Platform.Go(func() {
conn.Platform.SetStatusOnline(session.UserId, false)
conn.Platform.UpdateLastActivityAtIfNeeded(*session)
})
resp := model.NewWebSocketResponse(model.StatusOk, r.Seq, nil)
hub := conn.Platform.GetHubForUserId(conn.UserId)
if hub == nil {
return
}
hub.SendMessage(conn, resp)
return
}
if r.Action == string(model.WebsocketPresenceIndicator) {
if chID, ok := r.Data["channel_id"].(string); ok {
// Set active channel
conn.SetActiveChannelID(chID)
}
if teamID, ok := r.Data["team_id"].(string); ok {
// Set active team
conn.SetActiveTeamID(teamID)
}
if thChannelID, ok := r.Data["thread_channel_id"].(string); ok {
// Set the channelID of the active thread.
if isThreadView, ok := r.Data["is_thread_view"].(bool); ok && isThreadView {
conn.SetActiveThreadViewThreadChannelID(thChannelID)
} else {
conn.SetActiveRHSThreadChannelID(thChannelID)
}
}
resp := model.NewWebSocketResponse(model.StatusOk, r.Seq, nil)
hub := conn.Platform.GetHubForUserId(conn.UserId)
if hub == nil {
return
}
hub.SendMessage(conn, resp)
return
}
if !conn.IsAuthenticated() {
err := model.NewAppError("ServeWebSocket", "api.web_socket_router.not_authenticated.app_error", nil, "", http.StatusUnauthorized)
returnWebSocketError(conn.Platform, conn, r, err)
return
}
handler, ok := wr.handlers[r.Action]
if !ok {
err := model.NewAppError("ServeWebSocket", "api.web_socket_router.bad_action.app_error", nil, "", http.StatusInternalServerError)
returnWebSocketError(conn.Platform, conn, r, err)
return
}
handler.ServeWebSocket(conn, r)
}
func returnWebSocketError(ps *PlatformService, conn *WebConn, r *model.WebSocketRequest, err *model.AppError) {
logF := mlog.Error
if err.StatusCode >= http.StatusBadRequest && err.StatusCode < http.StatusInternalServerError {
logF = mlog.Debug
}
logF(
"websocket routing error.",
mlog.Int("seq", r.Seq),
mlog.String("user_id", conn.UserId),
mlog.String("system_message", err.SystemMessage(i18n.T)),
mlog.Err(err),
)
hub := ps.GetHubForUserId(conn.UserId)
if hub == nil {
return
}
err.WipeDetailed()
errorResp := model.NewWebSocketError(r.Seq, err)
hub.SendMessage(conn, errorResp)
}