snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
11
server/channels/app/platform/.mockery.yaml
Normal file
11
server/channels/app/platform/.mockery.yaml
Normal 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:
|
||||
155
server/channels/app/platform/busy.go
Normal file
155
server/channels/app/platform/busy.go
Normal 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
|
||||
}
|
||||
181
server/channels/app/platform/busy_test.go
Normal file
181
server/channels/app/platform/busy_test.go
Normal 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() {}
|
||||
259
server/channels/app/platform/cluster.go
Normal file
259
server/channels/app/platform/cluster.go
Normal 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
|
||||
}
|
||||
77
server/channels/app/platform/cluster_discovery.go
Normal file
77
server/channels/app/platform/cluster_discovery.go
Normal 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()
|
||||
}
|
||||
27
server/channels/app/platform/cluster_discovery_test.go
Normal file
27
server/channels/app/platform/cluster_discovery_test.go
Normal 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)
|
||||
}
|
||||
176
server/channels/app/platform/cluster_handlers.go
Normal file
176
server/channels/app/platform/cluster_handlers.go
Normal 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
|
||||
}
|
||||
96
server/channels/app/platform/cluster_handlers_revoke_test.go
Normal file
96
server/channels/app/platform/cluster_handlers_revoke_test.go
Normal 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")
|
||||
}
|
||||
111
server/channels/app/platform/cluster_handlers_test.go
Normal file
111
server/channels/app/platform/cluster_handlers_test.go
Normal 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(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
62
server/channels/app/platform/cluster_test.go
Normal file
62
server/channels/app/platform/cluster_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
418
server/channels/app/platform/config.go
Normal file
418
server/channels/app/platform/config.go
Normal 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)
|
||||
}
|
||||
193
server/channels/app/platform/config_test.go
Normal file
193
server/channels/app/platform/config_test.go
Normal 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()
|
||||
})
|
||||
}
|
||||
}
|
||||
89
server/channels/app/platform/container_limits_linux.go
Normal file
89
server/channels/app/platform/container_limits_linux.go
Normal 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
|
||||
}
|
||||
87
server/channels/app/platform/container_limits_linux_test.go
Normal file
87
server/channels/app/platform/container_limits_linux_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
19
server/channels/app/platform/container_limits_other.go
Normal file
19
server/channels/app/platform/container_limits_other.go
Normal 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
|
||||
}
|
||||
51
server/channels/app/platform/disk_darwin.go
Normal file
51
server/channels/app/platform/disk_darwin.go
Normal 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
|
||||
}
|
||||
}
|
||||
74
server/channels/app/platform/disk_linux.go
Normal file
74
server/channels/app/platform/disk_linux.go
Normal 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"
|
||||
}
|
||||
}
|
||||
21
server/channels/app/platform/disk_other.go
Normal file
21
server/channels/app/platform/disk_other.go
Normal 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
|
||||
}
|
||||
51
server/channels/app/platform/enterprise.go
Normal file
51
server/channels/app/platform/enterprise.go
Normal 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
|
||||
}
|
||||
24
server/channels/app/platform/errors.go
Normal file
24
server/channels/app/platform/errors.go
Normal 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")
|
||||
)
|
||||
30
server/channels/app/platform/fd_darwin.go
Normal file
30
server/channels/app/platform/fd_darwin.go
Normal 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
|
||||
}
|
||||
35
server/channels/app/platform/fd_linux.go
Normal file
35
server/channels/app/platform/fd_linux.go
Normal 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
|
||||
}
|
||||
16
server/channels/app/platform/fd_other.go
Normal file
16
server/channels/app/platform/fd_other.go
Normal 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
|
||||
}
|
||||
27
server/channels/app/platform/fd_test.go
Normal file
27
server/channels/app/platform/fd_test.go
Normal 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)
|
||||
}
|
||||
121
server/channels/app/platform/feature_flags.go
Normal file
121
server/channels/app/platform/feature_flags.go
Normal 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
|
||||
}
|
||||
}
|
||||
112
server/channels/app/platform/goroutines.go
Normal file
112
server/channels/app/platform/goroutines.go
Normal 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
|
||||
}
|
||||
}
|
||||
79
server/channels/app/platform/goroutines_test.go
Normal file
79
server/channels/app/platform/goroutines_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
325
server/channels/app/platform/helper_test.go
Normal file
325
server/channels/app/platform/helper_test.go
Normal 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
|
||||
}
|
||||
444
server/channels/app/platform/license.go
Normal file
444
server/channels/app/platform/license.go
Normal 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)
|
||||
}
|
||||
145
server/channels/app/platform/license_test.go
Normal file
145
server/channels/app/platform/license_test.go
Normal 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)
|
||||
}
|
||||
25
server/channels/app/platform/link_cache.go
Normal file
25
server/channels/app/platform/link_cache.go
Normal 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
|
||||
}
|
||||
353
server/channels/app/platform/log.go
Normal file
353
server/channels/app/platform/log.go
Normal 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
|
||||
}
|
||||
424
server/channels/app/platform/log_test.go
Normal file
424
server/channels/app/platform/log_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
41
server/channels/app/platform/main_test.go
Normal file
41
server/channels/app/platform/main_test.go
Normal 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)
|
||||
}
|
||||
19
server/channels/app/platform/memory_darwin.go
Normal file
19
server/channels/app/platform/memory_darwin.go
Normal 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
|
||||
}
|
||||
21
server/channels/app/platform/memory_linux.go
Normal file
21
server/channels/app/platform/memory_linux.go
Normal 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
|
||||
}
|
||||
17
server/channels/app/platform/memory_other.go
Normal file
17
server/channels/app/platform/memory_other.go
Normal 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
|
||||
}
|
||||
359
server/channels/app/platform/metrics.go
Normal file
359
server/channels/app/platform/metrics.go
Normal 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
|
||||
}
|
||||
168
server/channels/app/platform/metrics_test.go
Normal file
168
server/channels/app/platform/metrics_test.go
Normal 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)
|
||||
}
|
||||
220
server/channels/app/platform/mocks/SuiteIFace.go
Normal file
220
server/channels/app/platform/mocks/SuiteIFace.go
Normal 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
|
||||
}
|
||||
144
server/channels/app/platform/options.go
Normal file
144
server/channels/app/platform/options.go
Normal 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
|
||||
}
|
||||
}
|
||||
74
server/channels/app/platform/search_backfill.go
Normal file
74
server/channels/app/platform/search_backfill.go
Normal 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")
|
||||
}
|
||||
150
server/channels/app/platform/search_backfill_test.go
Normal file
150
server/channels/app/platform/search_backfill_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
95
server/channels/app/platform/searchengine.go
Normal file
95
server/channels/app/platform/searchengine.go
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
735
server/channels/app/platform/searchengine_test.go
Normal file
735
server/channels/app/platform/searchengine_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
389
server/channels/app/platform/searchengine_watcher.go
Normal file
389
server/channels/app/platform/searchengine_watcher.go
Normal 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)
|
||||
}
|
||||
740
server/channels/app/platform/service.go
Normal file
740
server/channels/app/platform/service.go
Normal 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
|
||||
}
|
||||
264
server/channels/app/platform/service_test.go
Normal file
264
server/channels/app/platform/service_test.go
Normal 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)
|
||||
}
|
||||
344
server/channels/app/platform/session.go
Normal file
344
server/channels/app/platform/session.go
Normal 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
|
||||
}
|
||||
332
server/channels/app/platform/session_test.go
Normal file
332
server/channels/app/platform/session_test.go
Normal 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)
|
||||
}
|
||||
174
server/channels/app/platform/shared_channel_notifier.go
Normal file
174
server/channels/app/platform/shared_channel_notifier.go
Normal 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
|
||||
}
|
||||
105
server/channels/app/platform/shared_channel_notifier_test.go
Normal file
105
server/channels/app/platform/shared_channel_notifier_test.go
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
90
server/channels/app/platform/shared_channel_service_iface.go
Normal file
90
server/channels/app/platform/shared_channel_service_iface.go
Normal 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
|
||||
}
|
||||
590
server/channels/app/platform/status.go
Normal file
590
server/channels/app/platform/status.go
Normal 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
|
||||
}
|
||||
256
server/channels/app/platform/status_test.go
Normal file
256
server/channels/app/platform/status_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
641
server/channels/app/platform/support_packet.go
Normal file
641
server/channels/app/platform/support_packet.go
Normal 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
|
||||
}
|
||||
}
|
||||
1277
server/channels/app/platform/support_packet_test.go
Normal file
1277
server/channels/app/platform/support_packet_test.go
Normal file
File diff suppressed because it is too large
Load Diff
24
server/channels/app/platform/uptime_darwin.go
Normal file
24
server/channels/app/platform/uptime_darwin.go
Normal 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
|
||||
}
|
||||
21
server/channels/app/platform/uptime_darwin_test.go
Normal file
21
server/channels/app/platform/uptime_darwin_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
41
server/channels/app/platform/uptime_linux.go
Normal file
41
server/channels/app/platform/uptime_linux.go
Normal 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
|
||||
}
|
||||
63
server/channels/app/platform/uptime_linux_test.go
Normal file
63
server/channels/app/platform/uptime_linux_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
16
server/channels/app/platform/uptime_other.go
Normal file
16
server/channels/app/platform/uptime_other.go
Normal 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
|
||||
}
|
||||
20
server/channels/app/platform/uptime_other_test.go
Normal file
20
server/channels/app/platform/uptime_other_test.go
Normal 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)
|
||||
}
|
||||
25
server/channels/app/platform/utils.go
Normal file
25
server/channels/app/platform/utils.go
Normal 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
|
||||
}
|
||||
90
server/channels/app/platform/web_broadcast_hook.go
Normal file
90
server/channels/app/platform/web_broadcast_hook.go
Normal 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
|
||||
}
|
||||
207
server/channels/app/platform/web_broadcast_hook_test.go
Normal file
207
server/channels/app/platform/web_broadcast_hook_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
1082
server/channels/app/platform/web_conn.go
Normal file
1082
server/channels/app/platform/web_conn.go
Normal file
File diff suppressed because it is too large
Load Diff
285
server/channels/app/platform/web_conn_test.go
Normal file
285
server/channels/app/platform/web_conn_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
1070
server/channels/app/platform/web_hub.go
Normal file
1070
server/channels/app/platform/web_hub.go
Normal file
File diff suppressed because it is too large
Load Diff
159
server/channels/app/platform/web_hub_delivery_test.go
Normal file
159
server/channels/app/platform/web_hub_delivery_test.go
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
955
server/channels/app/platform/web_hub_test.go
Normal file
955
server/channels/app/platform/web_hub_test.go
Normal 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"])
|
||||
})
|
||||
}
|
||||
162
server/channels/app/platform/websocket_reliable.go
Normal file
162
server/channels/app/platform/websocket_reliable.go
Normal 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
|
||||
}
|
||||
118
server/channels/app/platform/websocket_reliable_test.go
Normal file
118
server/channels/app/platform/websocket_reliable_test.go
Normal 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])
|
||||
})
|
||||
}
|
||||
146
server/channels/app/platform/websocket_router.go
Normal file
146
server/channels/app/platform/websocket_router.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user