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

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

View File

@@ -0,0 +1,13 @@
with-expecter: false
packages:
github.com/mattermost/mattermost/server/public/plugin:
config:
dir: "public/plugin/plugintest"
filename: "{{.InterfaceNameLower}}.go"
mockname: "{{.InterfaceName}}"
outpkg: "plugintest"
note: "Regenerate this file using `make plugin-mocks`."
interfaces:
API:
Hooks:
Driver:

1794
server/public/plugin/api.go Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"encoding/json"
"github.com/mattermost/mattermost/server/public/model"
)
// MakeAuditRecord creates a new audit record with basic information for plugin use.
// This function creates a minimal audit record that can be populated with additional data.
// Use this when you don't have access to request context or want to manually populate fields.
func MakeAuditRecord(event string, initialStatus string) *model.AuditRecord {
return &model.AuditRecord{
EventName: event,
Status: initialStatus,
Meta: make(map[string]any),
Actor: model.AuditEventActor{
UserId: "",
SessionId: "",
Client: "",
IpAddress: "",
XForwardedFor: "",
},
EventData: model.AuditEventData{
Parameters: map[string]any{},
PriorState: make(map[string]any),
ResultState: make(map[string]any),
ObjectType: "",
},
}
}
// MakeAuditRecordWithContext creates a new audit record populated with plugin context information.
// This is the recommended way for plugins to create audit records when they have request context.
// The Context should come from plugin hook parameters or HTTP request handlers.
func MakeAuditRecordWithContext(event string, initialStatus string, ctx *Context, userId, apiPath string) *model.AuditRecord {
rec := MakeAuditRecord(event, initialStatus)
rec.AddMeta(model.AuditKeyAPIPath, apiPath)
rec.Actor.UserId = userId
rec.Actor.SessionId = ctx.SessionId
rec.Actor.Client = ctx.UserAgent
rec.Actor.IpAddress = ctx.IPAddress
return rec
}
func makeAuditRecordGobSafe(record model.AuditRecord) model.AuditRecord {
record.EventData.Parameters = makeMapGobSafe(record.EventData.Parameters)
record.EventData.PriorState = makeMapGobSafe(record.EventData.PriorState)
record.EventData.ResultState = makeMapGobSafe(record.EventData.ResultState)
record.Meta = makeMapGobSafe(record.Meta)
return record
}
// makeMapGobSafe converts map data to a gob-safe representation via JSON round-trip.
// This eliminates problematic types like nil pointers in interfaces that cause gob
// encoding to fail when sending audit data over RPC via the plugin API.
func makeMapGobSafe(m map[string]any) map[string]any {
jsonBytes, err := json.Marshal(m)
if err != nil {
return map[string]any{"error": "failed to serialize audit data"}
}
var gobSafe map[string]any
if err := json.Unmarshal(jsonBytes, &gobSafe); err != nil {
return map[string]any{"error": "failed to deserialize audit data"}
}
return gobSafe
}

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"fmt"
"go/ast"
"go/token"
"github.com/mattermost/mattermost/server/public/plugin/checker/internal/asthelpers"
"github.com/mattermost/mattermost/server/public/plugin/checker/internal/version"
)
func checkAPIVersionComments(pkgPath string) (result, error) {
pkg, err := asthelpers.GetPackage(pkgPath)
if err != nil {
return result{}, err
}
apiInterface, err := asthelpers.FindInterface("API", pkg.Syntax)
if err != nil {
return result{}, err
}
invalidMethods := findInvalidMethods(apiInterface.Methods.List)
return result{Errors: renderErrors(pkg.Fset, invalidMethods)}, nil
}
func findInvalidMethods(methods []*ast.Field) []*ast.Field {
var invalid []*ast.Field
for _, m := range methods {
if !hasValidMinimumVersionComment(m.Doc.Text()) {
invalid = append(invalid, m)
}
}
return invalid
}
func hasValidMinimumVersionComment(s string) bool {
return version.ExtractMinimumVersionFromComment(s) != ""
}
func renderErrors(fset *token.FileSet, methods []*ast.Field) []string {
var out []string
for _, m := range methods {
out = append(out, renderWithFilePosition(fset, m.Pos(), fmt.Sprintf("missing a minimum server version comment on method %s", m.Names[0].Name)))
}
return out
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCheckAPIVersionComments(t *testing.T) {
testCases := []struct {
name, pkgPath, err string
expected result
}{
{
name: "valid comments",
pkgPath: "github.com/mattermost/mattermost/server/public/plugin/checker/internal/test/valid",
err: "",
},
{
name: "invalid comments",
pkgPath: "github.com/mattermost/mattermost/server/public/plugin/checker/internal/test/invalid",
expected: result{
Errors: []string{"internal/test/invalid/invalid.go:15:2: missing a minimum server version comment on method InvalidMethod"},
},
},
{
name: "missing API interface",
pkgPath: "github.com/mattermost/mattermost/server/public/plugin/checker/internal/test/missing",
err: "could not find API interface",
},
{
name: "non-existent package path",
pkgPath: "github.com/mattermost/mattermost/server/public/plugin/checker/internal/test/does_not_exist",
err: "could not find API interface",
},
}
// Enable debug flag to have packagesdriver/sizes.go print stderr of `go list` command.
// We want to surface any error text that may exist in stderr of this command.
// t.Setenv prevents t.Parallel — env var has no config equivalent
t.Setenv("GOPACKAGESPRINTGOLISTERRORS", "true")
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
res, err := checkAPIVersionComments(tc.pkgPath)
assert.Equal(t, res, tc.expected)
if tc.err != "" {
assert.EqualError(t, err, tc.err)
} else {
assert.NoError(t, err)
}
})
}
}

View File

@@ -0,0 +1,127 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package asthelpers
import (
"go/ast"
"go/types"
"github.com/pkg/errors"
"golang.org/x/tools/go/packages"
)
func GetPackage(pkgPath string) (*packages.Package, error) {
cfg := &packages.Config{
Mode: packages.NeedName | packages.NeedTypes | packages.NeedSyntax | packages.NeedTypesInfo,
}
pkgs, err := packages.Load(cfg, pkgPath)
if err != nil {
return nil, err
}
if len(pkgs) == 0 {
return nil, errors.Errorf("could not find package %s", pkgPath)
}
return pkgs[0], nil
}
func FindInterface(name string, files []*ast.File) (*ast.InterfaceType, error) {
iface, _, err := FindInterfaceWithIdent(name, files)
return iface, err
}
func FindInterfaceWithIdent(name string, files []*ast.File) (*ast.InterfaceType, *ast.Ident, error) {
var (
ident *ast.Ident
iface *ast.InterfaceType
)
for _, f := range files {
ast.Inspect(f, func(n ast.Node) bool {
if t, ok := n.(*ast.TypeSpec); ok {
if iface != nil {
return false
}
if i, ok := t.Type.(*ast.InterfaceType); ok && t.Name.Name == name {
ident = t.Name
iface = i
return false
}
}
return true
})
if iface != nil {
return iface, ident, nil
}
}
return nil, nil, errors.Errorf("could not find %s interface", name)
}
func FindMethodsCalledOnType(info *types.Info, typ types.Type, caller *ast.FuncDecl) []string {
var methods []string
ast.Inspect(caller, func(n ast.Node) bool {
if s, ok := n.(*ast.SelectorExpr); ok {
var receiver *ast.Ident
switch r := s.X.(type) {
case *ast.Ident:
// Left-hand side of the selector is an identifier, eg:
//
// a := p.API
// a.GetTeams()
//
receiver = r
case *ast.SelectorExpr:
// Left-hand side of the selector is a selector, eg:
//
// p.API.GetTeams()
//
receiver = r.Sel
}
if receiver != nil {
obj := info.ObjectOf(receiver)
if obj != nil && types.Identical(obj.Type(), typ) {
methods = append(methods, s.Sel.Name)
}
return false
}
}
return true
})
return methods
}
func FindReceiverMethods(receiverName string, files []*ast.File) []*ast.FuncDecl {
var fns []*ast.FuncDecl
for _, f := range files {
ast.Inspect(f, func(n ast.Node) bool {
if fn, ok := n.(*ast.FuncDecl); ok {
r := extractReceiverTypeName(fn)
if r == receiverName {
fns = append(fns, fn)
}
}
return true
})
}
return fns
}
func extractReceiverTypeName(fn *ast.FuncDecl) string {
if fn.Recv != nil {
t := fn.Recv.List[0].Type
// Unwrap the pointer type (a star expression)
if se, ok := t.(*ast.StarExpr); ok {
t = se.X
}
if id, ok := t.(*ast.Ident); ok {
return id.Name
}
}
return ""
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package invalid
type API interface {
// ValidMethod is a fake method for testing the
// plugin comment checker with a valid comment.
//
// Minimum server version: 1.2.3
ValidMethod()
// InvalidMethod is a fake method for testing the
// plugin comment checker with an invalid comment.
InvalidMethod()
}
type Helpers interface {
// Minimum server version: 1.1
LowerVersionMethod()
// Minimum server version: 1.3
HigherVersionMethod()
}
type HelpersImpl struct {
api API
}
func (h *HelpersImpl) LowerVersionMethod() {
h.api.ValidMethod()
}
func (h *HelpersImpl) HigherVersionMethod() {
h.api.ValidMethod()
}

View File

@@ -0,0 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package missing
// SomeType is a fake interface for testing the plugin comment checker.
type SomeType any

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package valid
type API interface {
// ValidMethod is a fake method for testing the
// plugin comment checker with a valid comment.
//
// Minimum server version: 1.2.3
ValidMethod()
// Minimum server version: 1.5
NewerValidMethod()
}
type Helpers interface {
// Minimum server version: 1.2.3
ValidHelperMethod()
// Minimum server version: 1.5
NewerValidHelperMethod()
// Minimum server version: 1.5
IndirectReferenceMethod()
}
type HelpersImpl struct {
api API
}
func (h *HelpersImpl) ValidHelperMethod() {
h.api.ValidMethod()
}
func (h *HelpersImpl) NewerValidHelperMethod() {
h.api.NewerValidMethod()
h.api.ValidMethod()
}
func (h *HelpersImpl) IndirectReferenceMethod() {
a := h.api
a.NewerValidMethod()
}

View File

@@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package version
import (
"regexp"
"strings"
)
var versionCommentRE = regexp.MustCompile(`^Minimum server version: (\d+\.\d+(?:\.\d+[\w-]*)?)$`)
func ExtractMinimumVersionFromComment(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
if len(lines) > 0 {
lastLine := lines[len(lines)-1]
if m := versionCommentRE.FindStringSubmatch(lastLine); len(m) >= 1 {
return m[1]
}
}
return ""
}

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package version
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestExtractVersionFromComment(t *testing.T) {
testCases := []struct {
input string
expected string
}{
{
input: "This is a comment.\n\nMinimum server version: 1.2.3-rc1\n",
expected: "1.2.3-rc1",
},
{
input: "This is a comment.\n\nMinimum server version: 1.2.3\n",
expected: "1.2.3",
},
{
input: "This is a comment.\n\nMinimum server version: 1.2\n",
expected: "1.2",
},
{
input: "This is a comment.\n\nMinimum server version: 1\n",
expected: "",
},
{
input: "This is a comment.\n",
expected: "",
},
{
input: "",
expected: "",
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("%+v", tc), func(t *testing.T) {
assert.Equal(t, tc.expected, ExtractMinimumVersionFromComment(tc.input))
})
}
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package version
import (
"regexp"
"strconv"
"strings"
)
type V string
func (v V) GreaterThanOrEqualTo(other V) bool {
return !v.LessThan(other)
}
func (v V) LessThan(other V) bool {
leftParts, leftCount := split(v)
rightParts, rightCount := split(other)
length := max(leftCount, rightCount)
for i := range length {
var left, right string
if i < leftCount {
left = leftParts[i]
}
if i < rightCount {
right = rightParts[i]
}
if left == right {
continue
}
leftInt := parseInt(left)
rightInt := parseInt(right)
isNumericalComparison := leftInt != nil && rightInt != nil
if isNumericalComparison {
return *leftInt < *rightInt
}
return left < right
}
return false
}
func split(v V) ([]string, int) {
var chunks []string
for part := range strings.SplitSeq(string(v), ".") {
chunks = append(chunks, splitNumericalChunks(part)...)
}
return chunks, len(chunks)
}
var numericalOrAlphaRE = regexp.MustCompile(`(\d+|\D+)`)
func splitNumericalChunks(s string) []string {
return numericalOrAlphaRE.FindAllString(s, -1)
}
func parseInt(s string) *int64 {
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
return &n
}
return nil
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package version
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestVersionComparison(t *testing.T) {
testCases := []struct {
a, b V
}{
{
a: V("1.2"),
b: V("1.10"),
},
{
a: V("1.2.1"),
b: V("1.2.3"),
},
{
a: V("1.2"),
b: V("1.2.3"),
},
{
a: V("1.2.1"),
b: V("1.2.3"),
},
{
a: V("1.1"),
b: V("1.2.3"),
},
{
a: V("1.2.3"),
b: V("1.3"),
},
{
a: V("1.2.1-rc2"),
b: V("1.2.1-rc10"),
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("%+v", tc), func(t *testing.T) {
assert.True(t, tc.a.LessThan(tc.b))
assert.False(t, tc.b.LessThan(tc.a))
assert.True(t, tc.b.GreaterThanOrEqualTo(tc.a))
assert.False(t, tc.a.GreaterThanOrEqualTo(tc.b))
})
}
assert.True(t, V("1.2").GreaterThanOrEqualTo("1.2"))
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"fmt"
"os"
"slices"
"strings"
)
const pluginPackagePath = "github.com/mattermost/mattermost/server/public/plugin"
type result struct {
Warnings []string
Errors []string
}
type checkFn func(pkgPath string) (result, error)
var checks = []checkFn{
checkAPIVersionComments,
}
func main() {
var res result
for _, check := range checks {
res = runCheck(res, check)
}
var msgs []string
msgs = append(msgs, res.Errors...)
msgs = append(msgs, res.Warnings...)
slices.Sort(msgs)
if len(msgs) > 0 {
fmt.Fprintln(os.Stderr, "#", pluginPackagePath)
fmt.Fprintln(os.Stderr, strings.Join(msgs, "\n"))
}
if len(res.Errors) > 0 {
os.Exit(1)
}
}
func runCheck(prev result, fn checkFn) result {
res, err := fn(pluginPackagePath)
if err != nil {
prev.Errors = append(prev.Errors, err.Error())
return prev
}
if len(res.Warnings) > 0 {
prev.Warnings = append(prev.Warnings, mapWarnings(res.Warnings)...)
}
if len(res.Errors) > 0 {
prev.Errors = append(prev.Errors, res.Errors...)
}
return prev
}
func mapWarnings(ss []string) []string {
var out []string
for _, s := range ss {
out = append(out, "[warn] "+s)
}
return out
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"fmt"
"go/token"
"os"
"path/filepath"
)
func renderWithFilePosition(fset *token.FileSet, pos token.Pos, msg string) string {
var cwd string
if d, err := os.Getwd(); err == nil {
cwd = d
}
fpos := fset.Position(pos)
filename, err := filepath.Rel(cwd, fpos.Filename)
if err != nil {
// If deriving a relative path fails for some reason,
// we prefer to still print the absolute path to the file.
filename = fpos.Filename
}
return fmt.Sprintf("%s:%d:%d: %s", filename, fpos.Line, fpos.Column, msg)
}

View File

@@ -0,0 +1,106 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"context"
"github.com/hashicorp/go-plugin"
)
const (
InternalKeyPrefix = "mmi_"
BotUserKey = InternalKeyPrefix + "botid"
)
// WithTestContext provides a context typically used to terminate a plugin from a unit test.
func WithTestContext(ctx context.Context) func(*plugin.ServeConfig) error {
return func(config *plugin.ServeConfig) error {
if config.Test == nil {
config.Test = &plugin.ServeTestConfig{}
}
config.Test.Context = ctx
return nil
}
}
// WithTestReattachConfigCh configures the channel to receive the ReattachConfig used to reattach
// an externally launched plugin instance with the Mattermost server.
func WithTestReattachConfigCh(reattachConfigCh chan<- *plugin.ReattachConfig) func(*plugin.ServeConfig) error {
return func(config *plugin.ServeConfig) error {
if config.Test == nil {
config.Test = &plugin.ServeTestConfig{}
}
config.Test.ReattachConfigCh = reattachConfigCh
return nil
}
}
// WithTestCloseCh provides a channel that signals when the plugin exits.
func WithTestCloseCh(closeCh chan<- struct{}) func(*plugin.ServeConfig) error {
return func(config *plugin.ServeConfig) error {
if config.Test == nil {
config.Test = &plugin.ServeTestConfig{}
}
config.Test.CloseCh = closeCh
return nil
}
}
// Starts the serving of a Mattermost plugin over net/rpc. gRPC is not supported.
//
// Call this when your plugin is ready to start. Options allow configuring plugins for testing
// scenarios.
func ClientMain(pluginImplementation any, opts ...func(config *plugin.ServeConfig) error) {
impl, ok := pluginImplementation.(interface {
SetAPI(api API)
SetDriver(driver Driver)
})
if !ok {
panic("Plugin implementation given must embed plugin.MattermostPlugin")
}
impl.SetAPI(nil)
impl.SetDriver(nil)
pluginMap := map[string]plugin.Plugin{
"hooks": &hooksPlugin{hooks: pluginImplementation},
}
serveConfig := &plugin.ServeConfig{
HandshakeConfig: handshake,
Plugins: pluginMap,
}
for _, opt := range opts {
err := opt(serveConfig)
if err != nil {
panic("failed to start serving plugin: " + err.Error())
}
}
plugin.Serve(serveConfig)
}
type MattermostPlugin struct {
// API exposes the plugin api, and becomes available just prior to the OnActive hook.
API API
Driver Driver
}
// SetAPI persists the given API interface to the plugin. It is invoked just prior to the
// OnActivate hook, exposing the API for use by the plugin.
func (p *MattermostPlugin) SetAPI(api API) {
p.API = api
}
// SetDriver sets the RPC client implementation to talk with the server.
func (p *MattermostPlugin) SetDriver(driver Driver) {
p.Driver = driver
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"bytes"
"encoding/gob"
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/require"
)
// TestReceiveSharedChannelAttachmentSyncMsgReturns_GobRoundTrip pins the fix
// for the gob-encoding bug in apiRPCServer.ReceiveSharedChannelAttachmentSyncMsg.
// The hook may return errors wrapped with fmt.Errorf("...%w", err), producing
// values of the unexported type *fmt.wrapError that gob refuses to encode.
// The RPC server must run the error through encodableError before assigning
// it to the returns struct. Without that, the RPC connection breaks and
// every subsequent plugin to server call returns zero values.
func TestReceiveSharedChannelAttachmentSyncMsgReturns_GobRoundTrip(t *testing.T) {
wrapped := fmt.Errorf("attachment sync failed: %w", errors.New("upstream boom"))
t.Run("raw wrapped error fails to gob-encode (reproduces the bug)", func(t *testing.T) {
returns := Z_ReceiveSharedChannelAttachmentSyncMsgReturns{B: wrapped}
var buf bytes.Buffer
err := gob.NewEncoder(&buf).Encode(&returns)
require.Error(t, err, "raw *fmt.wrapError must not be gob-encodable; if this assertion ever fails the bug guarded by encodableError no longer exists")
require.Contains(t, err.Error(), "fmt.wrapError")
})
t.Run("encodableError-wrapped error round-trips through gob", func(t *testing.T) {
returns := Z_ReceiveSharedChannelAttachmentSyncMsgReturns{B: encodableError(wrapped)}
var buf bytes.Buffer
require.NoError(t, gob.NewEncoder(&buf).Encode(&returns))
var decoded Z_ReceiveSharedChannelAttachmentSyncMsgReturns
require.NoError(t, gob.NewDecoder(&buf).Decode(&decoded))
require.Error(t, decoded.B)
require.Equal(t, wrapped.Error(), decoded.B.Error())
})
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
// Context passes through metadata about the request or hook event.
// For requests this is built in app/plugin_requests.go
// For hooks, app.PluginContext() is called.
type Context struct {
SessionId string
ConnectionId string
RequestId string
IPAddress string
AcceptLanguage string
UserAgent string
}

View File

@@ -0,0 +1,223 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"context"
"io"
"sync"
"time"
)
// rpcRequestContext carries only deadline metadata. Explicit cancellation is signaled
// separately over the context stream. Values are not propagated because contexts cannot
// enumerate them, and their keys and values may rely on process-local identity.
type rpcRequestContext struct {
DeadlineUnixNano int64
}
type closeReason byte
// Cancellation writes one closeReason byte before closing the stream.
// Normal closure is represented by EOF; unknown reasons cancel the context.
const (
rpcStreamClosed closeReason = iota
rpcStreamCanceled
rpcStreamDeadlineExceeded
)
func openRPCRequestContext(muxBroker rpcMuxBroker, streamID uint32, requestContext rpcRequestContext) (context.Context, func(), error) {
if streamID == 0 {
return context.Background(), func() {}, nil
}
connection, err := muxBroker.Dial(streamID)
if err != nil {
return nil, nil, err
}
ctx, cancel := requestContext.Context()
go watchRPCContext(ctx, connection, cancel)
cleanup := func() {
cancel()
_ = connection.Close()
}
return ctx, cleanup, nil
}
func newRPCRequestContext(ctx context.Context) rpcRequestContext {
deadline, ok := ctx.Deadline()
if !ok {
return rpcRequestContext{}
}
return rpcRequestContext{DeadlineUnixNano: deadline.UnixNano()}
}
func (c rpcRequestContext) Context() (context.Context, context.CancelFunc) {
if c.DeadlineUnixNano != 0 {
return context.WithDeadline(context.Background(), time.Unix(0, c.DeadlineUnixNano))
}
return context.WithCancel(context.Background())
}
// rpcStreamCloser owns a per-request mux stream whose MuxBroker.Accept runs concurrently
// with request cancellation. Close or Cancel may therefore run before Set receives the
// accepted connection. In that case, Set signals the saved cancellation reason when needed
// and immediately closes the late connection so it cannot outlive the request. Set returns
// false when it closes a connection this way.
type rpcStreamCloser struct {
mutex sync.Mutex
stream io.Closer
closed bool
reason closeReason
}
func (c *rpcStreamCloser) Set(stream io.Closer) bool {
c.mutex.Lock()
defer c.mutex.Unlock()
if c.closed {
if c.reason != rpcStreamClosed {
if writer, ok := stream.(io.Writer); ok {
_, _ = writer.Write([]byte{byte(c.reason)})
}
}
_ = stream.Close()
return false
}
c.stream = stream
return true
}
func (c *rpcStreamCloser) Close() error {
return c.close(rpcStreamClosed)
}
func (c *rpcStreamCloser) Cancel(err error) error {
reason := rpcStreamCanceled
if err == context.DeadlineExceeded {
reason = rpcStreamDeadlineExceeded
}
return c.close(reason)
}
func (c *rpcStreamCloser) close(reason closeReason) error {
c.mutex.Lock()
if c.closed {
c.mutex.Unlock()
return nil
}
c.closed = true
c.reason = reason
stream := c.stream
c.mutex.Unlock()
if stream != nil {
if reason != rpcStreamClosed {
if writer, ok := stream.(io.Writer); ok {
_, _ = writer.Write([]byte{byte(reason)})
}
}
return stream.Close()
}
return nil
}
// contextReadCloser closes the underlying stream when the context ends, unblocking an
// active Read and ensuring it reports ctx.Err instead of a transport error.
type contextReadCloser struct {
ctx context.Context
reader io.ReadCloser
closeOnce sync.Once
closeErr error
stopContextCallback func() bool
onClose func()
}
func newContextReadCloser(ctx context.Context, reader io.ReadCloser, onClose func()) io.ReadCloser {
r := &contextReadCloser{
ctx: ctx,
reader: reader,
onClose: onClose,
}
if ctx.Done() != nil {
r.stopContextCallback = context.AfterFunc(ctx, func() {
_ = r.closeFromContext()
})
}
return r
}
func (r *contextReadCloser) Read(p []byte) (int, error) {
if err := r.ctx.Err(); err != nil {
_ = r.Close()
return 0, err
}
n, err := r.reader.Read(p)
if ctxErr := r.ctx.Err(); ctxErr != nil {
_ = r.Close()
return 0, ctxErr
}
if err != nil {
_ = r.Close()
}
return n, err
}
func (r *contextReadCloser) Close() error {
if r.stopContextCallback != nil {
r.stopContextCallback()
}
return r.close()
}
func (r *contextReadCloser) closeFromContext() error {
return r.close()
}
func (r *contextReadCloser) close() error {
r.closeOnce.Do(func() {
r.closeErr = r.reader.Close()
if r.onClose != nil {
r.onClose()
}
})
return r.closeErr
}
// watchRPCContext cancels immediately on EOF or explicit cancellation. For deadline
// expiry, it lets the reconstructed deadline fire locally so ctx.Err is DeadlineExceeded.
func watchRPCContext(ctx context.Context, stream io.ReadCloser, cancel context.CancelFunc) {
defer func() { _ = stream.Close() }()
var buffer [1]byte
if _, err := io.ReadFull(stream, buffer[:]); err != nil {
cancel()
return
}
switch closeReason(buffer[0]) {
case rpcStreamDeadlineExceeded:
// The sender reports DeadlineExceeded for the deadline copied into ctx.
// Wait for that deadline to expire so ctx.Err() returns DeadlineExceeded
// instead of Canceled.
if _, ok := ctx.Deadline(); ok {
<-ctx.Done()
} else {
cancel()
}
case rpcStreamCanceled, rpcStreamClosed:
cancel()
default:
// Unknown reasons cancel the context.
cancel()
}
}

View File

@@ -0,0 +1,604 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"bytes"
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/rpc"
"strings"
"sync"
"testing"
"testing/iotest"
"time"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
const rpcTestTimeout = 10 * time.Second
type blockingReadCloser struct {
closed chan struct{}
}
func (r *blockingReadCloser) Read(p []byte) (int, error) {
<-r.closed
return 0, io.ErrClosedPipe
}
func (r *blockingReadCloser) Close() error {
select {
case <-r.closed:
default:
close(r.closed)
}
return nil
}
func TestRPCRequestContextDeadline(t *testing.T) {
deadline := time.Now().Add(time.Minute).Round(0)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()
remoteCtx, remoteCancel := newRPCRequestContext(ctx).Context()
defer remoteCancel()
remoteDeadline, ok := remoteCtx.Deadline()
require.True(t, ok)
require.Equal(t, deadline, remoteDeadline)
}
func TestWatchRPCContextTransportErrorCancels(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go watchRPCContext(ctx, io.NopCloser(iotest.ErrReader(errors.New("transport error"))), cancel)
select {
case <-ctx.Done():
require.ErrorIs(t, ctx.Err(), context.Canceled)
case <-time.After(rpcTestTimeout):
require.FailNow(t, "transport error did not cancel context")
}
}
func TestWatchRPCContextUnknownReasonCancels(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go watchRPCContext(ctx, io.NopCloser(bytes.NewReader([]byte{255})), cancel)
select {
case <-ctx.Done():
require.ErrorIs(t, ctx.Err(), context.Canceled)
case <-time.After(rpcTestTimeout):
require.FailNow(t, "unknown close reason did not cancel context")
}
}
func TestContextReadCloserCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
reader := &blockingReadCloser{closed: make(chan struct{})}
body := newContextReadCloser(ctx, reader, nil)
readDone := make(chan error, 1)
go func() {
_, err := body.Read(make([]byte, 1))
readDone <- err
}()
cancel()
select {
case err := <-readDone:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(rpcTestTimeout):
require.FailNow(t, "read did not stop after cancellation")
}
require.NoError(t, body.Close())
require.NoError(t, body.Close())
}
func TestContextReadCloserCancellationRunsCleanupWithoutReadOrClose(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
reader := &blockingReadCloser{closed: make(chan struct{})}
cleanupDone := make(chan struct{})
body := newContextReadCloser(ctx, reader, func() {
close(cleanupDone)
})
cancel()
select {
case <-cleanupDone:
case <-time.After(rpcTestTimeout):
require.FailNow(t, "cleanup did not run after cancellation")
}
select {
case <-reader.closed:
default:
require.FailNow(t, "reader was not closed after cancellation")
}
require.NoError(t, body.Close())
}
func TestRPCStreamCloserCloseBeforeSet(t *testing.T) {
stream := &rpcStreamCloser{}
require.NoError(t, stream.Close())
closer := &recordingWriteCloser{}
require.False(t, stream.Set(closer))
require.True(t, closer.closed)
require.NoError(t, stream.Close())
}
type recordingWriteCloser struct {
bytes.Buffer
closed bool
}
func (c *recordingWriteCloser) Close() error {
c.closed = true
return nil
}
func TestRPCStreamCloserCancelBeforeSet(t *testing.T) {
testCases := []struct {
name string
err error
reason closeReason
}{
{name: "canceled", err: context.Canceled, reason: rpcStreamCanceled},
{name: "deadline exceeded", err: context.DeadlineExceeded, reason: rpcStreamDeadlineExceeded},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
stream := &rpcStreamCloser{}
require.NoError(t, stream.Cancel(testCase.err))
closer := &recordingWriteCloser{}
require.False(t, stream.Set(closer))
require.Equal(t, []byte{byte(testCase.reason)}, closer.Bytes())
require.True(t, closer.closed)
require.NoError(t, stream.Close())
})
}
}
func TestPluginHTTPAlreadyCanceledClosesRequestBody(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
body := &recordingWriteCloser{}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/plugin/path", body)
require.NoError(t, err)
response := (&apiRPCClient{}).PluginHTTP(request)
require.Nil(t, response)
require.True(t, body.closed)
}
func TestHooksRPCClientServeHTTPAlreadyCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
body := &recordingWriteCloser{}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/plugin/path", body)
require.NoError(t, err)
muxBroker := newTestHooksRPCMuxBroker()
hooks := &hooksRPCClient{contextHTTP: true, muxBroker: muxBroker}
hooks.implemented[ServeHTTPID] = true
hooks.ServeHTTP(&Context{}, httptest.NewRecorder(), request)
require.True(t, body.closed)
require.Zero(t, muxBroker.nextID, "canceled request allocated a mux stream")
}
type blockingResponseWriter struct {
header http.Header
writeStarted chan struct{}
writeRelease chan struct{}
}
func (w *blockingResponseWriter) Header() http.Header {
return w.header
}
func (w *blockingResponseWriter) Write(p []byte) (int, error) {
close(w.writeStarted)
<-w.writeRelease
return len(p), nil
}
func (w *blockingResponseWriter) WriteHeader(statusCode int) {
}
type responseWritingRPCServer struct {
muxBroker rpcMuxBroker
}
func (s *responseWritingRPCServer) ServeHTTP(args *Z_ServeHTTPArgs, returns *struct{}) error {
_, cleanupContext, err := openRPCRequestContext(s.muxBroker, args.RequestContextStream, args.RequestContext)
if err != nil {
return err
}
defer cleanupContext()
connection, err := s.muxBroker.Dial(args.ResponseWriterStream)
if err != nil {
return err
}
responseWriter := connectHTTPResponseWriter(connection)
defer responseWriter.Close()
_, err = responseWriter.Write([]byte("response"))
return err
}
func TestHooksRPCClientServeHTTPCancellationWaitsForResponseWriter(t *testing.T) {
muxBroker := newTestHooksRPCMuxBroker()
serverConnection, clientConnection := net.Pipe()
server := rpc.NewServer()
require.NoError(t, server.RegisterName("Plugin", &responseWritingRPCServer{muxBroker: muxBroker}))
go server.ServeConn(serverConnection)
client := rpc.NewClient(clientConnection)
defer client.Close()
hooks := &hooksRPCClient{client: client, log: mlog.CreateConsoleTestLogger(t), muxBroker: muxBroker, contextHTTP: true}
hooks.implemented[ServeHTTPID] = true
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "/plugin/path", nil)
require.NoError(t, err)
writeStarted := make(chan struct{})
writeRelease := make(chan struct{})
defer close(writeRelease)
writer := &blockingResponseWriter{
header: make(http.Header),
writeStarted: writeStarted,
writeRelease: writeRelease,
}
serveHTTPDone := make(chan struct{})
go func() {
hooks.ServeHTTP(&Context{}, writer, request)
close(serveHTTPDone)
}()
select {
case <-writeStarted:
case <-time.After(rpcTestTimeout):
require.FailNow(t, "response write did not start")
}
cancel()
require.Never(t, func() bool {
select {
case <-serveHTTPDone:
return true
default:
return false
}
}, 100*time.Millisecond, 10*time.Millisecond, "ServeHTTP returned during an in-flight response write")
writeRelease <- struct{}{}
select {
case <-serveHTTPDone:
case <-time.After(rpcTestTimeout):
require.FailNow(t, "ServeHTTP did not return after the response write finished")
}
}
type stalledServeHTTPRPCServer struct {
started chan struct{}
release chan struct{}
}
func (s *stalledServeHTTPRPCServer) ServeHTTP(args *Z_ServeHTTPArgs, returns *struct{}) error {
close(s.started)
<-s.release
return nil
}
type delayedAcceptMuxBroker struct {
release chan struct{}
nextID uint32
}
func (b *delayedAcceptMuxBroker) NextId() uint32 {
b.nextID++
return b.nextID
}
func (b *delayedAcceptMuxBroker) Accept(id uint32) (net.Conn, error) {
<-b.release
hostConnection, pluginConnection := net.Pipe()
_ = pluginConnection.Close()
return hostConnection, nil
}
func (b *delayedAcceptMuxBroker) AcceptAndServe(id uint32, server any) {
}
func (b *delayedAcceptMuxBroker) Dial(id uint32) (net.Conn, error) {
return nil, errors.New("unexpected Dial")
}
func TestHooksRPCClientServeHTTPCancellationBeforeResponseStreamAcceptance(t *testing.T) {
muxBroker := &delayedAcceptMuxBroker{release: make(chan struct{})}
serverConnection, clientConnection := net.Pipe()
server := rpc.NewServer()
serverStarted := make(chan struct{})
serverRelease := make(chan struct{})
defer close(serverRelease)
defer close(muxBroker.release)
require.NoError(t, server.RegisterName("Plugin", &stalledServeHTTPRPCServer{started: serverStarted, release: serverRelease}))
go server.ServeConn(serverConnection)
client := rpc.NewClient(clientConnection)
defer client.Close()
hooks := &hooksRPCClient{client: client, log: mlog.CreateConsoleTestLogger(t), muxBroker: muxBroker, contextHTTP: true}
hooks.implemented[ServeHTTPID] = true
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "/plugin/path", nil)
require.NoError(t, err)
serveHTTPDone := make(chan struct{})
go func() {
hooks.ServeHTTP(&Context{}, httptest.NewRecorder(), request)
close(serveHTTPDone)
}()
select {
case <-serverStarted:
case <-time.After(rpcTestTimeout):
require.FailNow(t, "ServeHTTP RPC did not start")
}
cancel()
select {
case <-serveHTTPDone:
case <-time.After(time.Second):
require.FailNow(t, "ServeHTTP waited for a response stream that was not accepted")
}
}
type legacyRPCServer struct {
muxBroker *testHooksRPCMuxBroker
serveHTTPStarted chan struct{}
serveHTTPRelease chan struct{}
}
type testHooksRPCMuxBroker struct {
nextID uint32
mutex sync.Mutex
streams map[uint32]chan net.Conn
acceptErrors map[uint32]error
}
func newTestHooksRPCMuxBroker() *testHooksRPCMuxBroker {
return &testHooksRPCMuxBroker{streams: make(map[uint32]chan net.Conn)}
}
func (b *testHooksRPCMuxBroker) NextId() uint32 {
b.nextID++
return b.nextID
}
func (b *testHooksRPCMuxBroker) Accept(id uint32) (net.Conn, error) {
if err := b.acceptErrors[id]; err != nil {
return nil, err
}
hostConnection, pluginConnection := net.Pipe()
b.stream(id) <- pluginConnection
return hostConnection, nil
}
func (b *testHooksRPCMuxBroker) AcceptAndServe(id uint32, server any) {
}
func (b *testHooksRPCMuxBroker) Dial(id uint32) (net.Conn, error) {
select {
case connection := <-b.stream(id):
return connection, nil
case <-time.After(rpcTestTimeout):
return nil, errors.New("timeout waiting for stream")
}
}
func (b *testHooksRPCMuxBroker) stream(id uint32) chan net.Conn {
b.mutex.Lock()
defer b.mutex.Unlock()
stream, ok := b.streams[id]
if !ok {
stream = make(chan net.Conn, 1)
b.streams[id] = stream
}
return stream
}
type LegacyOnActivateArgs struct {
APIMuxId uint32
DriverMuxId uint32
}
type LegacyOnActivateReturns struct {
A error
}
type LegacyServeHTTPArgs struct {
ResponseWriterStream uint32
Request *HTTPRequestSubset
Context *Context
RequestBodyStream uint32
}
func (s *legacyRPCServer) OnActivate(args *LegacyOnActivateArgs, returns *LegacyOnActivateReturns) error {
return nil
}
func (s *legacyRPCServer) Ping(args struct{}, reply *bool) error {
*reply = true
return nil
}
func (s *legacyRPCServer) PluginHTTP(args *Z_PluginHTTPArgs, returns *Z_PluginHTTPReturns) error {
returns.Response = &http.Response{StatusCode: http.StatusOK}
returns.ResponseBody = []byte("legacy")
return nil
}
type successfulPluginHTTPStreamRPCServer struct{}
func (s *successfulPluginHTTPStreamRPCServer) PluginHTTPStream(args *Z_PluginHTTPStreamArgs, returns *Z_PluginHTTPStreamReturns) error {
returns.StatusCode = http.StatusOK
return nil
}
func TestPluginHTTPStreamResponseSetupFailureClosesRequestBody(t *testing.T) {
muxBroker := newTestHooksRPCMuxBroker()
muxBroker.acceptErrors = map[uint32]error{1: errors.New("response stream failed")}
serverConnection, clientConnection := net.Pipe()
server := rpc.NewServer()
require.NoError(t, server.RegisterName("Plugin", &successfulPluginHTTPStreamRPCServer{}))
go server.ServeConn(serverConnection)
client := rpc.NewClient(clientConnection)
defer client.Close()
body := &blockingReadCloser{closed: make(chan struct{})}
request, err := http.NewRequestWithContext(context.Background(), http.MethodPost, "/plugin/path", body)
require.NoError(t, err)
response, err := (&apiRPCClient{client: client, muxBroker: muxBroker}).pluginHTTPStream(request, false)
if response != nil && response.Body != nil {
require.NoError(t, response.Body.Close())
}
require.Nil(t, response)
require.ErrorContains(t, err, "response stream failed")
select {
case <-body.closed:
default:
require.FailNow(t, "request body was not closed after response stream setup failed")
}
requestConnection, err := muxBroker.Dial(2)
require.NoError(t, err)
require.NoError(t, requestConnection.Close())
}
func (s *legacyRPCServer) ServeHTTP(args *LegacyServeHTTPArgs, returns *struct{}) error {
requestConnection, err := s.muxBroker.Dial(args.RequestBodyStream)
if err != nil {
return err
}
requestBody := connectIOReader(requestConnection)
body := make([]byte, len("request"))
_, err = io.ReadFull(requestBody, body)
_ = requestBody.Close()
if err != nil {
return err
}
close(s.serveHTTPStarted)
<-s.serveHTTPRelease
responseConnection, err := s.muxBroker.Dial(args.ResponseWriterStream)
if err != nil {
return err
}
responseWriter := connectHTTPResponseWriter(responseConnection)
defer responseWriter.Close()
responseWriter.Header().Set("X-Legacy-Plugin", "true")
responseWriter.WriteHeader(http.StatusCreated)
_, err = responseWriter.Write(append([]byte("legacy:"), body...))
return err
}
func TestLegacyPluginHTTPFallbackLeavesConnectionUsable(t *testing.T) {
muxBroker := newTestHooksRPCMuxBroker()
serverConnection, clientConnection := net.Pipe()
server := rpc.NewServer()
require.NoError(t, server.RegisterName("Plugin", &legacyRPCServer{}))
go server.ServeConn(serverConnection)
client := rpc.NewClient(clientConnection)
defer client.Close()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "/legacy", nil)
require.NoError(t, err)
response := (&apiRPCClient{client: client, muxBroker: muxBroker}).PluginHTTP(request)
require.NotNil(t, response)
body, err := io.ReadAll(response.Body)
require.NoError(t, err)
require.NoError(t, response.Body.Close())
require.Equal(t, http.StatusOK, response.StatusCode)
require.Equal(t, "legacy", string(body))
var pong bool
require.NoError(t, client.Call("Plugin.Ping", struct{}{}, &pong))
require.True(t, pong)
}
func TestLegacyHooksRPCClientServeHTTP(t *testing.T) {
muxBroker := newTestHooksRPCMuxBroker()
serveHTTPStarted := make(chan struct{})
serveHTTPRelease := make(chan struct{})
serverConnection, clientConnection := net.Pipe()
server := rpc.NewServer()
require.NoError(t, server.RegisterName("Plugin", &legacyRPCServer{
muxBroker: muxBroker,
serveHTTPStarted: serveHTTPStarted,
serveHTTPRelease: serveHTTPRelease,
}))
go server.ServeConn(serverConnection)
client := rpc.NewClient(clientConnection)
defer client.Close()
hooks := &hooksRPCClient{client: client, log: mlog.CreateConsoleTestLogger(t), muxBroker: muxBroker}
hooks.implemented[ServeHTTPID] = true
require.NoError(t, hooks.OnActivate())
require.False(t, hooks.contextHTTP)
ctx, cancel := context.WithCancel(context.Background())
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "/legacy", strings.NewReader("request"))
require.NoError(t, err)
recorder := httptest.NewRecorder()
serveHTTPDone := make(chan struct{})
go func() {
hooks.ServeHTTP(&Context{RequestId: "legacy"}, recorder, request)
close(serveHTTPDone)
}()
select {
case <-serveHTTPStarted:
case <-time.After(rpcTestTimeout):
require.FailNow(t, "legacy ServeHTTP did not start")
}
cancel()
require.Never(t, func() bool {
select {
case <-serveHTTPDone:
return true
default:
return false
}
}, 100*time.Millisecond, 10*time.Millisecond, "legacy ServeHTTP unexpectedly stopped on context cancellation")
var pong bool
require.NoError(t, client.Call("Plugin.Ping", struct{}{}, &pong))
require.True(t, pong)
close(serveHTTPRelease)
select {
case <-serveHTTPDone:
case <-time.After(rpcTestTimeout):
require.FailNow(t, "legacy ServeHTTP did not finish after release")
}
require.Equal(t, http.StatusCreated, recorder.Code)
require.Equal(t, "true", recorder.Header().Get("X-Legacy-Plugin"))
require.Equal(t, "legacy:request", recorder.Body.String())
}

View File

@@ -0,0 +1,459 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"database/sql/driver"
"log"
"net/rpc"
)
// dbRPCClient contains the client-side logic to handle the RPC communication
// with the server. It's API is hand-written because we do not expect
// new methods to be added very frequently.
type dbRPCClient struct {
client *rpc.Client
}
// dbRPCServer is the server-side component which is responsible for calling
// the driver methods and properly encoding the responses back to the RPC client.
type dbRPCServer struct {
dbImpl Driver
}
var _ Driver = &dbRPCClient{}
type Z_DbStrErrReturn struct {
A string
B error
}
type Z_DbErrReturn struct {
A error
}
type Z_DbInt64ErrReturn struct {
A int64
B error
}
type Z_DbBoolReturn struct {
A bool
}
func (db *dbRPCClient) Conn(isMaster bool) (string, error) {
ret := &Z_DbStrErrReturn{}
err := db.client.Call("Plugin.Conn", isMaster, ret)
if err != nil {
log.Printf("error during Plugin.Conn: %v", err)
}
ret.B = decodableError(ret.B)
return ret.A, ret.B
}
func (db *dbRPCServer) Conn(isMaster bool, ret *Z_DbStrErrReturn) error {
ret.A, ret.B = db.dbImpl.Conn(isMaster)
ret.B = encodableError(ret.B)
return nil
}
func (db *dbRPCClient) ConnPing(connID string) error {
ret := &Z_DbErrReturn{}
err := db.client.Call("Plugin.ConnPing", connID, ret)
if err != nil {
log.Printf("error during Plugin.ConnPing: %v", err)
}
ret.A = decodableError(ret.A)
return ret.A
}
func (db *dbRPCServer) ConnPing(connID string, ret *Z_DbErrReturn) error {
ret.A = db.dbImpl.ConnPing(connID)
ret.A = encodableError(ret.A)
return nil
}
func (db *dbRPCClient) ConnClose(connID string) error {
ret := &Z_DbErrReturn{}
err := db.client.Call("Plugin.ConnClose", connID, ret)
if err != nil {
log.Printf("error during Plugin.ConnClose: %v", err)
}
ret.A = decodableError(ret.A)
return ret.A
}
func (db *dbRPCServer) ConnClose(connID string, ret *Z_DbErrReturn) error {
ret.A = db.dbImpl.ConnClose(connID)
ret.A = encodableError(ret.A)
return nil
}
type Z_DbTxArgs struct {
A string
B driver.TxOptions
}
func (db *dbRPCClient) Tx(connID string, opts driver.TxOptions) (string, error) {
args := &Z_DbTxArgs{
A: connID,
B: opts,
}
ret := &Z_DbStrErrReturn{}
err := db.client.Call("Plugin.Tx", args, ret)
if err != nil {
log.Printf("error during Plugin.Tx: %v", err)
}
ret.B = decodableError(ret.B)
return ret.A, ret.B
}
func (db *dbRPCServer) Tx(args *Z_DbTxArgs, ret *Z_DbStrErrReturn) error {
ret.A, ret.B = db.dbImpl.Tx(args.A, args.B)
ret.B = encodableError(ret.B)
return nil
}
func (db *dbRPCClient) TxCommit(txID string) error {
ret := &Z_DbErrReturn{}
err := db.client.Call("Plugin.TxCommit", txID, ret)
if err != nil {
log.Printf("error during Plugin.TxCommit: %v", err)
}
ret.A = decodableError(ret.A)
return ret.A
}
func (db *dbRPCServer) TxCommit(txID string, ret *Z_DbErrReturn) error {
ret.A = db.dbImpl.TxCommit(txID)
ret.A = encodableError(ret.A)
return nil
}
func (db *dbRPCClient) TxRollback(txID string) error {
ret := &Z_DbErrReturn{}
err := db.client.Call("Plugin.TxRollback", txID, ret)
if err != nil {
log.Printf("error during Plugin.TxRollback: %v", err)
}
ret.A = decodableError(ret.A)
return ret.A
}
func (db *dbRPCServer) TxRollback(txID string, ret *Z_DbErrReturn) error {
ret.A = db.dbImpl.TxRollback(txID)
ret.A = encodableError(ret.A)
return nil
}
type Z_DbStmtArgs struct {
A string
B string
}
func (db *dbRPCClient) Stmt(connID, q string) (string, error) {
args := &Z_DbStmtArgs{
A: connID,
B: q,
}
ret := &Z_DbStrErrReturn{}
err := db.client.Call("Plugin.Stmt", args, ret)
if err != nil {
log.Printf("error during Plugin.Stmt: %v", err)
}
ret.B = decodableError(ret.B)
return ret.A, ret.B
}
func (db *dbRPCServer) Stmt(args *Z_DbStmtArgs, ret *Z_DbStrErrReturn) error {
ret.A, ret.B = db.dbImpl.Stmt(args.A, args.B)
ret.B = encodableError(ret.B)
return nil
}
func (db *dbRPCClient) StmtClose(stID string) error {
ret := &Z_DbErrReturn{}
err := db.client.Call("Plugin.StmtClose", stID, ret)
if err != nil {
log.Printf("error during Plugin.StmtClose: %v", err)
}
ret.A = decodableError(ret.A)
return ret.A
}
func (db *dbRPCServer) StmtClose(stID string, ret *Z_DbErrReturn) error {
ret.A = db.dbImpl.StmtClose(stID)
ret.A = encodableError(ret.A)
return nil
}
type Z_DbIntReturn struct {
A int
}
func (db *dbRPCClient) StmtNumInput(stID string) int {
ret := &Z_DbIntReturn{}
err := db.client.Call("Plugin.StmtNumInput", stID, ret)
if err != nil {
log.Printf("error during Plugin.StmtNumInput: %v", err)
}
return ret.A
}
func (db *dbRPCServer) StmtNumInput(stID string, ret *Z_DbIntReturn) error {
ret.A = db.dbImpl.StmtNumInput(stID)
return nil
}
type Z_DbStmtQueryArgs struct {
A string
B []driver.NamedValue
}
func (db *dbRPCClient) StmtQuery(stID string, argVals []driver.NamedValue) (string, error) {
args := &Z_DbStmtQueryArgs{
A: stID,
B: argVals,
}
ret := &Z_DbStrErrReturn{}
err := db.client.Call("Plugin.StmtQuery", args, ret)
if err != nil {
log.Printf("error during Plugin.StmtQuery: %v", err)
}
ret.B = decodableError(ret.B)
return ret.A, ret.B
}
func (db *dbRPCServer) StmtQuery(args *Z_DbStmtQueryArgs, ret *Z_DbStrErrReturn) error {
ret.A, ret.B = db.dbImpl.StmtQuery(args.A, args.B)
ret.B = encodableError(ret.B)
return nil
}
func (db *dbRPCClient) StmtExec(stID string, argVals []driver.NamedValue) (ResultContainer, error) {
args := &Z_DbStmtQueryArgs{
A: stID,
B: argVals,
}
ret := &Z_DbResultContErrReturn{}
err := db.client.Call("Plugin.StmtExec", args, ret)
if err != nil {
log.Printf("error during Plugin.StmtExec: %v", err)
}
ret.A.LastIDError = decodableError(ret.A.LastIDError)
ret.A.RowsAffectedError = decodableError(ret.A.RowsAffectedError)
ret.B = decodableError(ret.B)
return ret.A, ret.B
}
func (db *dbRPCServer) StmtExec(args *Z_DbStmtQueryArgs, ret *Z_DbResultContErrReturn) error {
ret.A, ret.B = db.dbImpl.StmtExec(args.A, args.B)
ret.A.LastIDError = encodableError(ret.A.LastIDError)
ret.A.RowsAffectedError = encodableError(ret.A.RowsAffectedError)
ret.B = encodableError(ret.B)
return nil
}
type Z_DbConnArgs struct {
A string
B string
C []driver.NamedValue
}
func (db *dbRPCClient) ConnQuery(connID, q string, argVals []driver.NamedValue) (string, error) {
args := &Z_DbConnArgs{
A: connID,
B: q,
C: argVals,
}
ret := &Z_DbStrErrReturn{}
err := db.client.Call("Plugin.ConnQuery", args, ret)
if err != nil {
log.Printf("error during Plugin.ConnQuery: %v", err)
}
ret.B = decodableError(ret.B)
return ret.A, ret.B
}
func (db *dbRPCServer) ConnQuery(args *Z_DbConnArgs, ret *Z_DbStrErrReturn) error {
ret.A, ret.B = db.dbImpl.ConnQuery(args.A, args.B, args.C)
ret.B = encodableError(ret.B)
return nil
}
type Z_DbResultContErrReturn struct {
A ResultContainer
B error
}
func (db *dbRPCClient) ConnExec(connID, q string, argVals []driver.NamedValue) (ResultContainer, error) {
args := &Z_DbConnArgs{
A: connID,
B: q,
C: argVals,
}
ret := &Z_DbResultContErrReturn{}
err := db.client.Call("Plugin.ConnExec", args, ret)
if err != nil {
log.Printf("error during Plugin.ConnExec: %v", err)
}
ret.A.LastIDError = decodableError(ret.A.LastIDError)
ret.A.RowsAffectedError = decodableError(ret.A.RowsAffectedError)
ret.B = decodableError(ret.B)
return ret.A, ret.B
}
func (db *dbRPCServer) ConnExec(args *Z_DbConnArgs, ret *Z_DbResultContErrReturn) error {
ret.A, ret.B = db.dbImpl.ConnExec(args.A, args.B, args.C)
ret.A.LastIDError = encodableError(ret.A.LastIDError)
ret.A.RowsAffectedError = encodableError(ret.A.RowsAffectedError)
ret.B = encodableError(ret.B)
return nil
}
type Z_DbStrSliceReturn struct {
A []string
}
func (db *dbRPCClient) RowsColumns(rowsID string) []string {
ret := &Z_DbStrSliceReturn{}
err := db.client.Call("Plugin.RowsColumns", rowsID, ret)
if err != nil {
log.Printf("error during Plugin.RowsColumns: %v", err)
}
return ret.A
}
func (db *dbRPCServer) RowsColumns(rowsID string, ret *Z_DbStrSliceReturn) error {
ret.A = db.dbImpl.RowsColumns(rowsID)
return nil
}
func (db *dbRPCClient) RowsClose(resID string) error {
ret := &Z_DbErrReturn{}
err := db.client.Call("Plugin.RowsClose", resID, ret)
if err != nil {
log.Printf("error during Plugin.RowsClose: %v", err)
}
ret.A = decodableError(ret.A)
return ret.A
}
func (db *dbRPCServer) RowsClose(resID string, ret *Z_DbErrReturn) error {
ret.A = db.dbImpl.RowsClose(resID)
ret.A = encodableError(ret.A)
return nil
}
type Z_DbRowScanReturn struct {
A error
B []driver.Value
}
type Z_DbRowScanArg struct {
A string
B []driver.Value
}
func (db *dbRPCClient) RowsNext(rowsID string, dest []driver.Value) error {
args := &Z_DbRowScanArg{
A: rowsID,
B: dest,
}
ret := &Z_DbRowScanReturn{}
err := db.client.Call("Plugin.RowsNext", args, ret)
if err != nil {
log.Printf("error during Plugin.RowsNext: %v", err)
}
ret.A = decodableError(ret.A)
copy(dest, ret.B)
return ret.A
}
func (db *dbRPCServer) RowsNext(args *Z_DbRowScanArg, ret *Z_DbRowScanReturn) error {
ret.A = db.dbImpl.RowsNext(args.A, args.B)
ret.A = encodableError(ret.A)
// Trick to populate the dest slice. RPC doesn't have a semantic to populate
// pointer type args. So the only way to pass values is via args, and only way
// to return values is via the return struct.
ret.B = args.B
return nil
}
func (db *dbRPCClient) RowsHasNextResultSet(rowsID string) bool {
ret := &Z_DbBoolReturn{}
err := db.client.Call("Plugin.RowsHasNextResultSet", rowsID, ret)
if err != nil {
log.Printf("error during Plugin.RowsHasNextResultSet: %v", err)
}
return ret.A
}
func (db *dbRPCServer) RowsHasNextResultSet(rowsID string, ret *Z_DbBoolReturn) error {
ret.A = db.dbImpl.RowsHasNextResultSet(rowsID)
return nil
}
func (db *dbRPCClient) RowsNextResultSet(rowsID string) error {
ret := &Z_DbErrReturn{}
err := db.client.Call("Plugin.RowsNextResultSet", rowsID, ret)
if err != nil {
log.Printf("error during Plugin.RowsNextResultSet: %v", err)
}
ret.A = decodableError(ret.A)
return ret.A
}
func (db *dbRPCServer) RowsNextResultSet(rowsID string, ret *Z_DbErrReturn) error {
ret.A = db.dbImpl.RowsNextResultSet(rowsID)
ret.A = encodableError(ret.A)
return nil
}
type Z_DbRowsColumnArg struct {
A string
B int
}
func (db *dbRPCClient) RowsColumnTypeDatabaseTypeName(rowsID string, index int) string {
args := &Z_DbRowsColumnArg{
A: rowsID,
B: index,
}
var ret string
err := db.client.Call("Plugin.RowsColumnTypeDatabaseTypeName", args, &ret)
if err != nil {
log.Printf("error during Plugin.RowsColumnTypeDatabaseTypeName: %v", err)
}
return ret
}
func (db *dbRPCServer) RowsColumnTypeDatabaseTypeName(args *Z_DbRowsColumnArg, ret *string) error {
*ret = db.dbImpl.RowsColumnTypeDatabaseTypeName(args.A, args.B)
return nil
}
type Z_DbRowsColumnTypePrecisionScaleReturn struct {
A int64
B int64
C bool
}
func (db *dbRPCClient) RowsColumnTypePrecisionScale(rowsID string, index int) (int64, int64, bool) {
args := &Z_DbRowsColumnArg{
A: rowsID,
B: index,
}
ret := &Z_DbRowsColumnTypePrecisionScaleReturn{}
err := db.client.Call("Plugin.RowsColumnTypePrecisionScale", args, ret)
if err != nil {
log.Printf("error during Plugin.RowsColumnTypePrecisionScale: %v", err)
}
return ret.A, ret.B, ret.C
}
func (db *dbRPCServer) RowsColumnTypePrecisionScale(args *Z_DbRowsColumnArg, ret *Z_DbRowsColumnTypePrecisionScaleReturn) error {
ret.A, ret.B, ret.C = db.dbImpl.RowsColumnTypePrecisionScale(args.A, args.B)
return nil
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// The plugin package is used by Mattermost server plugins written in go. It also enables the
// Mattermost server to manage and interact with the running plugin environment.
//
// Note that this package exports a large number of types prefixed with Z_. These are public only
// to allow their use with Hashicorp's go-plugin (and net/rpc). Do not use these directly.
package plugin

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"database/sql/driver"
)
// ResultContainer contains the output from the LastInsertID
// and RowsAffected methods for a given set of rows.
// It is used to embed another round-trip to the server,
// and helping to avoid tracking results on the server.
type ResultContainer struct {
LastID int64
LastIDError error
RowsAffected int64
RowsAffectedError error
}
// Driver is a sql driver interface that is used by plugins to perform
// raw SQL queries without opening DB connections by themselves. This interface
// is not subject to backward compatibility guarantees and is only meant to be
// used by plugins built by the Mattermost team.
type Driver interface {
// Connection
Conn(isMaster bool) (string, error)
ConnPing(connID string) error
ConnClose(connID string) error
ConnQuery(connID, q string, args []driver.NamedValue) (string, error) // rows
ConnExec(connID, q string, args []driver.NamedValue) (ResultContainer, error) // result
// Transaction
Tx(connID string, opts driver.TxOptions) (string, error)
TxCommit(txID string) error
TxRollback(txID string) error
// Statement
Stmt(connID, q string) (string, error)
StmtClose(stID string) error
StmtNumInput(stID string) int
StmtQuery(stID string, args []driver.NamedValue) (string, error) // rows
StmtExec(stID string, args []driver.NamedValue) (ResultContainer, error) // result
// Rows
RowsColumns(rowsID string) []string
RowsClose(rowsID string) error
RowsNext(rowsID string, dest []driver.Value) error
RowsHasNextResultSet(rowsID string) bool
RowsNextResultSet(rowsID string) error
RowsColumnTypeDatabaseTypeName(rowsID string, index int) string
RowsColumnTypePrecisionScale(rowsID string, index int) (int64, int64, bool)
// TODO: add this
// RowsColumnScanType(rowsID string, index int) reflect.Type
// Note: the following are not currently implemented.
// RowsColumnTypeLength(rowsID string, index int) (int64, bool)
// RowsColumnTypeNullable(rowsID string, index int) (bool, bool)
// ResetSession(ctx context.Context) error
// IsValid() bool
}
// AppDriver is an extension of the Driver interface to capture non-RPC APIs.
type AppDriver interface {
Driver
// ConnWithPluginID is only used by the server, and isn't exposed via the RPC API.
ConnWithPluginID(isMaster bool, pluginID string) (string, error)
// This is an extra method needed to shutdown connections
// after a plugin shuts down.
ShutdownConns(pluginID string)
}

View File

@@ -0,0 +1,785 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"fmt"
"hash/fnv"
"os"
"path/filepath"
"slices"
"sync"
"time"
plugin "github.com/hashicorp/go-plugin"
"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/utils"
)
var ErrNotFound = errors.New("Item not found")
type apiImplCreatorFunc func(*model.Manifest) API
// registeredPlugin stores the state for a given plugin that has been activated
// or attempted to be activated this server run.
//
// If an installed plugin is missing from the env.registeredPlugins map, then the
// plugin is configured as disabled and has not been activated during this server run.
type registeredPlugin struct {
BundleInfo *model.BundleInfo
State int
Error string
supervisor *supervisor
}
// PrepackagedPlugin is a plugin prepackaged with the server and found on startup.
type PrepackagedPlugin struct {
Path string
IconData string
Manifest *model.Manifest
SignaturePath string
}
// Environment represents the execution environment of active plugins.
//
// It is meant for use by the Mattermost server to manipulate, interact with and report on the set
// of active plugins.
type Environment struct {
registeredPlugins sync.Map
pluginHealthCheckJob *PluginHealthCheckJob
logger *mlog.Logger
metrics metricsInterface
newAPIImpl apiImplCreatorFunc
dbDriver AppDriver
pluginDir string
webappPluginDir string
prepackagedPlugins []*PrepackagedPlugin
transitionallyPrepackagedPlugins []*PrepackagedPlugin
prepackagedPluginsLock sync.RWMutex
}
func NewEnvironment(
newAPIImpl apiImplCreatorFunc,
dbDriver AppDriver,
pluginDir string,
webappPluginDir string,
logger *mlog.Logger,
metrics metricsInterface,
) (*Environment, error) {
return &Environment{
logger: logger,
metrics: metrics,
newAPIImpl: newAPIImpl,
dbDriver: dbDriver,
pluginDir: pluginDir,
webappPluginDir: webappPluginDir,
}, nil
}
// Performs a full scan of the given path.
//
// This function will return info for all subdirectories that appear to be plugins (i.e. all
// subdirectories containing plugin manifest files, regardless of whether they could actually be
// parsed).
//
// Plugins are found non-recursively and paths beginning with a dot are always ignored.
func scanSearchPath(path string) ([]*model.BundleInfo, error) {
files, err := os.ReadDir(path)
if err != nil {
return nil, err
}
var ret []*model.BundleInfo
for _, file := range files {
if !file.IsDir() || file.Name()[0] == '.' {
continue
}
info := model.BundleInfoForPath(filepath.Join(path, file.Name()))
if info.Manifest != nil {
ret = append(ret, info)
}
}
return ret, nil
}
// Returns a list of all plugins within the environment.
func (env *Environment) Available() ([]*model.BundleInfo, error) {
return scanSearchPath(env.pluginDir)
}
// Returns a list of prepackaged plugins available in the local prepackaged_plugins folder,
// excluding those in transition out of being prepackaged.
//
// The list content is immutable and should not be modified.
func (env *Environment) PrepackagedPlugins() []*PrepackagedPlugin {
env.prepackagedPluginsLock.RLock()
defer env.prepackagedPluginsLock.RUnlock()
return env.prepackagedPlugins
}
// TransitionallyPrepackagedPlugins returns a list of plugins transitionally prepackaged in the
// local prepackaged_plugins folder.
//
// The list content is immutable and should not be modified.
func (env *Environment) TransitionallyPrepackagedPlugins() []*PrepackagedPlugin {
env.prepackagedPluginsLock.RLock()
defer env.prepackagedPluginsLock.RUnlock()
return env.transitionallyPrepackagedPlugins
}
// ClearTransitionallyPrepackagedPlugins clears the list of plugins transitionally prepackaged
// in the local prepackaged_plugins folder.
func (env *Environment) ClearTransitionallyPrepackagedPlugins() {
env.prepackagedPluginsLock.RLock()
defer env.prepackagedPluginsLock.RUnlock()
env.transitionallyPrepackagedPlugins = nil
}
// Returns a list of all currently active plugins within the environment.
// The returned list should not be modified.
func (env *Environment) Active() []*model.BundleInfo {
activePlugins := []*model.BundleInfo{}
env.registeredPlugins.Range(func(key, value any) bool {
plugin := value.(registeredPlugin)
if env.IsActive(plugin.BundleInfo.Manifest.Id) {
activePlugins = append(activePlugins, plugin.BundleInfo)
}
return true
})
return activePlugins
}
// IsActive returns true if the plugin with the given id is active.
func (env *Environment) IsActive(id string) bool {
return env.GetPluginState(id) == model.PluginStateRunning
}
func (env *Environment) SetPluginError(id string, err string) {
if rp, ok := env.registeredPlugins.Load(id); ok {
p := rp.(registeredPlugin)
p.Error = err
env.registeredPlugins.Store(id, p)
}
}
func (env *Environment) getPluginError(id string) string {
if rp, ok := env.registeredPlugins.Load(id); ok {
return rp.(registeredPlugin).Error
}
return ""
}
// GetPluginState returns the current state of a plugin (disabled, running, or error)
func (env *Environment) GetPluginState(id string) int {
rp, ok := env.registeredPlugins.Load(id)
if !ok {
return model.PluginStateNotRunning
}
return rp.(registeredPlugin).State
}
// setPluginState sets the current state of a plugin (disabled, running, or error)
func (env *Environment) setPluginState(id string, state int) {
if rp, ok := env.registeredPlugins.Load(id); ok {
p := rp.(registeredPlugin)
p.State = state
env.registeredPlugins.Store(id, p)
}
}
// setPluginSupervisor records the supervisor for a registered plugin.
func (env *Environment) setPluginSupervisor(id string, supervisor *supervisor) {
if rp, ok := env.registeredPlugins.Load(id); ok {
p := rp.(registeredPlugin)
p.supervisor = supervisor
env.registeredPlugins.Store(id, p)
}
}
// PublicFilesPath returns a path and true if the plugin with the given id is active.
// It returns an empty string and false if the path is not set or invalid
func (env *Environment) PublicFilesPath(id string) (string, error) {
if !env.IsActive(id) {
return "", fmt.Errorf("plugin not found: %v", id)
}
return filepath.Join(env.pluginDir, id, "public"), nil
}
// Statuses returns a list of plugin statuses representing the state of every plugin
func (env *Environment) Statuses() (model.PluginStatuses, error) {
plugins, err := env.Available()
if err != nil {
return nil, errors.Wrap(err, "unable to get plugin statuses")
}
pluginStatuses := make(model.PluginStatuses, 0, len(plugins))
for _, plugin := range plugins {
// For now we don't handle bad manifests, we should
if plugin.Manifest == nil {
continue
}
pluginState := env.GetPluginState(plugin.Manifest.Id)
status := &model.PluginStatus{
PluginId: plugin.Manifest.Id,
PluginPath: filepath.Dir(plugin.ManifestPath),
State: pluginState,
Error: env.getPluginError(plugin.Manifest.Id),
Name: plugin.Manifest.Name,
Description: plugin.Manifest.Description,
Version: plugin.Manifest.Version,
}
pluginStatuses = append(pluginStatuses, status)
}
return pluginStatuses, nil
}
// GetManifest returns a manifest for a given pluginId.
// Returns ErrNotFound if plugin is not found.
func (env *Environment) GetManifest(pluginId string) (*model.Manifest, error) {
plugins, err := env.Available()
if err != nil {
return nil, errors.Wrap(err, "unable to get plugin statuses")
}
for _, plugin := range plugins {
if plugin.Manifest != nil && plugin.Manifest.Id == pluginId {
return plugin.Manifest, nil
}
}
return nil, ErrNotFound
}
func checkMinServerVersion(pluginInfo *model.BundleInfo) error {
if pluginInfo.Manifest.MinServerVersion == "" {
return nil
}
fulfilled, err := pluginInfo.Manifest.MeetMinServerVersion(model.CurrentVersion)
if err != nil {
return fmt.Errorf("%v: %v", err.Error(), pluginInfo.Manifest.Id)
}
if !fulfilled {
return fmt.Errorf("plugin requires Mattermost %v: %v", pluginInfo.Manifest.MinServerVersion, pluginInfo.Manifest.Id)
}
return nil
}
func (env *Environment) startPluginServer(pluginInfo *model.BundleInfo, opts ...func(*supervisor, *plugin.ClientConfig) error) error {
sup, err := newSupervisor(pluginInfo, env.newAPIImpl(pluginInfo.Manifest), env.dbDriver, env.logger, env.metrics, opts...)
if err != nil {
return errors.Wrapf(err, "unable to start plugin: %v", pluginInfo.Manifest.Id)
}
// We pre-emptively set the state to running to prevent re-entrancy issues.
// The plugin's OnActivate hook can in-turn call UpdateConfiguration
// which again calls this method. This method is guarded against multiple calls,
// but fails if it is called recursively.
//
// Therefore, setting the state to running prevents this from happening,
// and in case there is an error, the defer clause will set the proper state anyways.
env.setPluginState(pluginInfo.Manifest.Id, model.PluginStateRunning)
if err := sup.Hooks().OnActivate(); err != nil {
sup.Shutdown()
return err
}
env.setPluginSupervisor(pluginInfo.Manifest.Id, sup)
return nil
}
func (env *Environment) Activate(id string) (manifest *model.Manifest, activated bool, reterr error) {
defer func() {
if reterr != nil {
env.SetPluginError(id, reterr.Error())
} else {
env.SetPluginError(id, "")
}
}()
// Check if we are already active
if env.IsActive(id) {
return nil, false, nil
}
plugins, err := env.Available()
if err != nil {
return nil, false, err
}
var pluginInfo *model.BundleInfo
for _, p := range plugins {
if p.Manifest != nil && p.Manifest.Id == id {
if pluginInfo != nil {
return nil, false, fmt.Errorf("multiple plugins found: %v", id)
}
pluginInfo = p
}
}
if pluginInfo == nil {
return nil, false, fmt.Errorf("plugin not found: %v", id)
}
rp := newRegisteredPlugin(pluginInfo)
env.registeredPlugins.Store(id, rp)
defer func() {
if reterr == nil {
env.setPluginState(id, model.PluginStateRunning)
} else {
env.setPluginState(id, model.PluginStateFailedToStart)
}
}()
err = checkMinServerVersion(pluginInfo)
if err != nil {
return nil, false, err
}
componentActivated := false
if pluginInfo.Manifest.HasWebapp() {
var updatedManifest *model.Manifest
updatedManifest, err = env.UnpackWebappBundle(id)
if err != nil {
return nil, false, errors.Wrapf(err, "unable to generate webapp bundle: %v", id)
}
pluginInfo.Manifest.Webapp.BundleHash = updatedManifest.Webapp.BundleHash
componentActivated = true
}
if pluginInfo.Manifest.HasServer() {
err = env.startPluginServer(pluginInfo, WithExecutableFromManifest(pluginInfo))
if err != nil {
return nil, false, err
}
componentActivated = true
}
if !componentActivated {
return nil, false, fmt.Errorf("unable to start plugin: must at least have a web app or server component")
}
mlog.Debug("Plugin activated", mlog.String("plugin_id", pluginInfo.Manifest.Id), mlog.String("version", pluginInfo.Manifest.Version))
return pluginInfo.Manifest, true, nil
}
// Reattach allows the server to bind to an existing plugin instance launched elsewhere.
func (env *Environment) Reattach(manifest *model.Manifest, pluginReattachConfig *model.PluginReattachConfig) (reterr error) {
id := manifest.Id
defer func() {
if reterr != nil {
env.SetPluginError(id, reterr.Error())
} else {
env.SetPluginError(id, "")
}
}()
// Check if we are already active
if env.IsActive(id) {
return nil
}
pluginInfo := &model.BundleInfo{
Path: "",
Manifest: manifest,
ManifestPath: "",
ManifestError: nil,
}
rp := newRegisteredPlugin(pluginInfo)
env.registeredPlugins.Store(id, rp)
defer func() {
if reterr == nil {
env.setPluginState(id, model.PluginStateRunning)
} else {
env.setPluginState(id, model.PluginStateFailedToStart)
}
}()
err := checkMinServerVersion(pluginInfo)
if err != nil {
return nil
}
if !pluginInfo.Manifest.HasServer() {
return errors.New("cannot reattach plugin without server component")
}
if pluginInfo.Manifest.HasWebapp() {
env.logger.Warn("Ignoring webapp for reattached plugin", mlog.String("plugin_id", id))
}
err = env.startPluginServer(pluginInfo, WithReattachConfig(pluginReattachConfig))
if err != nil {
return nil
}
mlog.Debug("Plugin reattached", mlog.String("plugin_id", pluginInfo.Manifest.Id), mlog.String("version", pluginInfo.Manifest.Version))
return nil
}
func (env *Environment) RemovePlugin(id string) {
if _, ok := env.registeredPlugins.Load(id); ok {
env.registeredPlugins.Delete(id)
}
}
// deactivateAndTeardown runs OnDeactivate (with a 10-second timeout), then marks the plugin
// as not running and closes its RPC connection. The plugin remains reachable via
// RunMultiPluginHook* throughout OnDeactivate so it can dispatch hooks back to itself.
//
// If the plugin is not currently active, there's nothing to tear down: its state is reconciled
// to not running and no OnDeactivate is dispatched, avoiding a spurious RPC call to an
// already-closed connection.
//
// Deactivation always reconciles the plugin to PluginStateNotRunning, intentionally clearing any
// error state (e.g. PluginStateFailedToStayRunning): once deactivated, the plugin is no longer
// meant to be running, so the error no longer applies. Callers that want to record a more specific
// state deactivate first and set it afterward, as the health check job does when a plugin exceeds
// its restart limit.
func (env *Environment) deactivateAndTeardown(rp registeredPlugin) bool {
id := rp.BundleInfo.Manifest.Id
if rp.supervisor == nil || !env.IsActive(id) {
env.setPluginState(id, model.PluginStateNotRunning)
return false
}
done := make(chan struct{})
go func() {
defer close(done)
if err := rp.supervisor.Hooks().OnDeactivate(); err != nil {
env.logger.Error("Plugin OnDeactivate() error", mlog.String("plugin_id", id), mlog.Err(err))
}
}()
select {
case <-time.After(10 * time.Second):
env.logger.Warn("Plugin OnDeactivate() failed to complete in 10 seconds", mlog.String("plugin_id", id))
case <-done:
}
env.setPluginState(id, model.PluginStateNotRunning)
rp.supervisor.Shutdown()
return true
}
// Deactivate the plugin with the given id.
func (env *Environment) Deactivate(id string) bool {
p, ok := env.registeredPlugins.Load(id)
if !ok {
return false
}
return env.deactivateAndTeardown(p.(registeredPlugin))
}
// RestartPlugin deactivates, then activates the plugin with the given id.
func (env *Environment) RestartPlugin(id string) error {
env.Deactivate(id)
_, _, err := env.Activate(id)
return err
}
// Shutdown deactivates all plugins and gracefully shuts down the environment.
func (env *Environment) Shutdown() {
env.TogglePluginHealthCheckJob(false)
var wg sync.WaitGroup
env.registeredPlugins.Range(func(_, value any) bool {
rp := value.(registeredPlugin)
wg.Go(func() { env.deactivateAndTeardown(rp) })
return true
})
wg.Wait()
env.registeredPlugins.Range(func(key, value any) bool {
env.registeredPlugins.Delete(key)
return true
})
}
// UnpackWebappBundle unpacks webapp bundle for a given plugin id on disk.
func (env *Environment) UnpackWebappBundle(id string) (*model.Manifest, error) {
plugins, err := env.Available()
if err != nil {
return nil, errors.New("Unable to get available plugins")
}
var manifest *model.Manifest
for _, p := range plugins {
if p.Manifest != nil && p.Manifest.Id == id {
if manifest != nil {
return nil, fmt.Errorf("multiple plugins found: %v", id)
}
manifest = p.Manifest
}
}
if manifest == nil {
return nil, fmt.Errorf("plugin not found: %v", id)
}
bundlePath := filepath.Clean(manifest.Webapp.BundlePath)
if bundlePath == "" || bundlePath[0] == '.' {
return nil, fmt.Errorf("invalid webapp bundle path")
}
bundlePath = filepath.Join(env.pluginDir, id, bundlePath)
destinationPath := filepath.Join(env.webappPluginDir, id)
if err = os.RemoveAll(destinationPath); err != nil {
return nil, errors.Wrapf(err, "unable to remove old webapp bundle directory: %v", destinationPath)
}
if err = utils.CopyDir(filepath.Dir(bundlePath), destinationPath); err != nil {
return nil, errors.Wrapf(err, "unable to copy webapp bundle directory: %v", id)
}
sourceBundleFilepath := filepath.Join(destinationPath, filepath.Base(bundlePath))
sourceBundleFileContents, err := os.ReadFile(sourceBundleFilepath)
if err != nil {
return nil, errors.Wrapf(err, "unable to read webapp bundle: %v", id)
}
hash := fnv.New64a()
if _, err = hash.Write(sourceBundleFileContents); err != nil {
return nil, errors.Wrapf(err, "unable to generate hash for webapp bundle: %v", id)
}
manifest.Webapp.BundleHash = hash.Sum([]byte{})
if err = os.Rename(
sourceBundleFilepath,
filepath.Join(destinationPath, fmt.Sprintf("%s_%x_bundle.js", id, manifest.Webapp.BundleHash)),
); err != nil {
return nil, errors.Wrapf(err, "unable to rename webapp bundle: %v", id)
}
return manifest, nil
}
// HooksForPlugin returns the hooks API for the plugin with the given id.
//
// Consider using RunMultiPluginHook instead.
func (env *Environment) HooksForPlugin(id string) (Hooks, error) {
if p, ok := env.registeredPlugins.Load(id); ok {
rp := p.(registeredPlugin)
if rp.supervisor != nil && env.IsActive(id) {
return rp.supervisor.Hooks(), nil
}
}
return nil, fmt.Errorf("plugin not found: %v", id)
}
// HooksForPluginWithRPCErr returns the full *WithRPCErr hook surface for the named plugin.
// Returns an error if the plugin is not found or not active.
func (env *Environment) HooksForPluginWithRPCErr(id string) (HooksWithRPCErr, error) {
if p, ok := env.registeredPlugins.Load(id); ok {
rp := p.(registeredPlugin)
if rp.supervisor != nil && env.IsActive(id) {
return rp.supervisor.HooksWithRPCErr(), nil
}
}
return nil, fmt.Errorf("plugin not found: %v", id)
}
// HasPluginImplementing reports whether any active plugin implements hookId. Use this to avoid
// expensive setup when no plugin will actually be called by RunMultiPluginHook.
func (env *Environment) HasPluginImplementing(hookId int) bool {
found := false
env.registeredPlugins.Range(func(_, value any) bool {
rp := value.(registeredPlugin)
if rp.supervisor == nil || !rp.supervisor.Implements(hookId) || !env.IsActive(rp.BundleInfo.Manifest.Id) {
return true
}
found = true
return false // stop on first match
})
return found
}
// RunMultiPluginHook invokes hookRunnerFunc for each active plugin that implements the given hookId.
//
// If hookRunnerFunc returns false, iteration will not continue. The iteration order among active
// plugins is not specified.
func (env *Environment) RunMultiPluginHook(hookRunnerFunc func(hooks Hooks, manifest *model.Manifest) bool, hookId int) {
startTime := time.Now()
env.registeredPlugins.Range(func(key, value any) bool {
rp := value.(registeredPlugin)
if rp.supervisor == nil || !rp.supervisor.Implements(hookId) || !env.IsActive(rp.BundleInfo.Manifest.Id) {
return true
}
hookStartTime := time.Now()
result := hookRunnerFunc(rp.supervisor.Hooks(), rp.BundleInfo.Manifest)
if env.metrics != nil {
elapsedTime := float64(time.Since(hookStartTime)) / float64(time.Second)
env.metrics.ObservePluginMultiHookIterationDuration(rp.BundleInfo.Manifest.Id, elapsedTime)
}
return result
})
if env.metrics != nil {
elapsedTime := float64(time.Since(startTime)) / float64(time.Second)
env.metrics.ObservePluginMultiHookDuration(elapsedTime)
}
}
// RunMultiPluginHookExcluding is like RunMultiPluginHook but skips plugins whose IDs appear in
// excludePluginIDs, otherwise the semantics are the same as RunMultiPluginHook. The exclusion check
// is a linear scan.
func (env *Environment) RunMultiPluginHookExcluding(
excludePluginIDs []string,
hookRunnerFunc func(hooks Hooks, manifest *model.Manifest) bool,
hookId int,
) {
startTime := time.Now()
env.registeredPlugins.Range(func(key, value any) bool {
rp := value.(registeredPlugin)
id := rp.BundleInfo.Manifest.Id
if slices.Contains(excludePluginIDs, id) {
return true
}
if rp.supervisor == nil || !rp.supervisor.Implements(hookId) || !env.IsActive(id) {
return true
}
hookStartTime := time.Now()
cont := hookRunnerFunc(rp.supervisor.Hooks(), rp.BundleInfo.Manifest)
if env.metrics != nil {
elapsedTime := float64(time.Since(hookStartTime)) / float64(time.Second)
env.metrics.ObservePluginMultiHookIterationDuration(id, elapsedTime)
}
return cont
})
if env.metrics != nil {
elapsedTime := float64(time.Since(startTime)) / float64(time.Second)
env.metrics.ObservePluginMultiHookDuration(elapsedTime)
}
}
// RunMultiPluginHookWithRPCErr is like RunMultiPluginHook but surfaces RPC transport errors. The
// closure receives a HooksWithRPCErr so it can call any *WithRPCErr variant. Iteration stops on the
// first non-nil error returned by the closure.
func (env *Environment) RunMultiPluginHookWithRPCErr(hookRunnerFunc func(hooks HooksWithRPCErr, manifest *model.Manifest) (bool, error), hookId int) error {
startTime := time.Now()
var retErr error
env.registeredPlugins.Range(func(key, value any) bool {
rp := value.(registeredPlugin)
if rp.supervisor == nil || !rp.supervisor.Implements(hookId) || !env.IsActive(rp.BundleInfo.Manifest.Id) {
return true
}
hookStartTime := time.Now()
cont, err := hookRunnerFunc(rp.supervisor.HooksWithRPCErr(), rp.BundleInfo.Manifest)
if env.metrics != nil {
elapsedTime := float64(time.Since(hookStartTime)) / float64(time.Second)
env.metrics.ObservePluginMultiHookIterationDuration(rp.BundleInfo.Manifest.Id, elapsedTime)
}
if err != nil {
retErr = err
return false
}
return cont
})
if env.metrics != nil {
elapsedTime := float64(time.Since(startTime)) / float64(time.Second)
env.metrics.ObservePluginMultiHookDuration(elapsedTime)
}
return retErr
}
// PerformHealthCheck uses the active plugin's supervisor to verify if the plugin has crashed.
func (env *Environment) PerformHealthCheck(id string) error {
p, ok := env.registeredPlugins.Load(id)
if !ok {
return nil
}
rp := p.(registeredPlugin)
sup := rp.supervisor
if sup == nil {
return nil
}
return sup.PerformHealthCheck()
}
// SetPrepackagedPlugins saves prepackaged plugins in the environment.
func (env *Environment) SetPrepackagedPlugins(plugins, transitionalPlugins []*PrepackagedPlugin) {
env.prepackagedPluginsLock.Lock()
env.prepackagedPlugins = plugins
env.transitionallyPrepackagedPlugins = transitionalPlugins
env.prepackagedPluginsLock.Unlock()
}
func newRegisteredPlugin(bundle *model.BundleInfo) registeredPlugin {
state := model.PluginStateNotRunning
return registeredPlugin{State: state, BundleInfo: bundle}
}
// TogglePluginHealthCheckJob starts a new job if one is not running and is set to enabled, or kills an existing one if set to disabled.
func (env *Environment) TogglePluginHealthCheckJob(enable bool) {
// Config is set to enable. No job exists, start a new job.
if enable && env.pluginHealthCheckJob == nil {
mlog.Debug("Enabling plugin health check job", mlog.Duration("interval_s", HealthCheckInterval))
job := newPluginHealthCheckJob(env)
env.pluginHealthCheckJob = job
go job.run()
}
// Config is set to disable. Job exists, kill existing job.
if !enable && env.pluginHealthCheckJob != nil {
mlog.Debug("Disabling plugin health check job")
env.pluginHealthCheckJob.Cancel()
env.pluginHealthCheckJob = nil
}
}
// GetPluginHealthCheckJob returns the configured PluginHealthCheckJob, if any.
func (env *Environment) GetPluginHealthCheckJob() *PluginHealthCheckJob {
return env.pluginHealthCheckJob
}

View File

@@ -0,0 +1,278 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/utils"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
// TestPluginMarksNotRunningAfterOnDeactivate verifies the state transitions during plugin
// teardown, for both Shutdown and Deactivate. While a plugin's OnDeactivate is still running,
// IsActive must return true so that hook dispatches the plugin makes to itself (e.g. CreatePost)
// are not rejected. Once OnDeactivate completes, IsActive must return false before the RPC
// connection is torn down.
func TestPluginMarksNotRunningAfterOnDeactivate(t *testing.T) {
testCases := []struct {
name string
teardown func(env *Environment, pluginID string)
}{
{
name: "Shutdown",
teardown: func(env *Environment, _ string) {
env.Shutdown()
},
},
{
name: "Deactivate",
teardown: func(env *Environment, pluginID string) {
env.Deactivate(pluginID)
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
pluginDir, err := os.MkdirTemp("", "mm-shutdown-state-plugin")
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(pluginDir) })
webappPluginDir, err := os.MkdirTemp("", "mm-shutdown-state-webapp")
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(webappPluginDir) })
pluginID := "test-shutdown-state-plugin"
require.NoError(t, os.MkdirAll(filepath.Join(pluginDir, pluginID), 0700))
backend := filepath.Join(pluginDir, pluginID, "backend.exe")
// OnDeactivate blocks until the test signals it via MessageWillBePosted.
utils.CompileGo(t, `
package main
import (
"sync"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)
type MyPlugin struct {
plugin.MattermostPlugin
once sync.Once
proceed chan struct{}
}
func (p *MyPlugin) OnActivate() error {
p.proceed = make(chan struct{})
return nil
}
func (p *MyPlugin) OnDeactivate() error {
<-p.proceed
return nil
}
func (p *MyPlugin) MessageWillBePosted(_ *plugin.Context, _ *model.Post) (*model.Post, string) {
p.once.Do(func() { close(p.proceed) })
return nil, ""
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`, backend)
require.NoError(t, os.WriteFile(
filepath.Join(pluginDir, pluginID, "plugin.json"),
[]byte(`{"id":"`+pluginID+`","server":{"executable":"backend.exe"}}`),
0600,
))
logger := mlog.CreateConsoleTestLogger(t)
apiImpl := func(*model.Manifest) API { return nil }
env, err := NewEnvironment(apiImpl, nil, pluginDir, webappPluginDir, logger, nil)
require.NoError(t, err)
_, _, err = env.Activate(pluginID)
require.NoError(t, err)
require.True(t, env.IsActive(pluginID))
teardownDone := make(chan struct{})
go func() {
defer close(teardownDone)
tc.teardown(env, pluginID)
}()
// Plugin is blocked in OnDeactivate — state must still be Running so a plugin
// dispatching hooks back to itself from OnDeactivate isn't rejected.
require.True(t, env.IsActive(pluginID), "IsActive should be true while OnDeactivate is blocking")
// Signal the plugin to complete OnDeactivate.
env.RunMultiPluginHook(func(hooks Hooks, _ *model.Manifest) bool {
hooks.MessageWillBePosted(&Context{}, &model.Post{})
return true
}, MessageWillBePostedID)
select {
case <-teardownDone:
case <-time.After(2 * time.Second):
t.Fatalf("%s did not return", tc.name)
}
require.False(t, env.IsActive(pluginID))
})
}
}
// TestShutdownAfterDeactivateNoOnDeactivateRPC verifies that shutting down the environment after a
// plugin has already been deactivated does not dispatch a second OnDeactivate over the plugin's
// already-closed RPC connection. Previously Shutdown ran deactivateAndTeardown unconditionally for
// every registered plugin, dispatching OnDeactivate to inactive plugins and logging a spurious
// "connection is shut down" error.
func TestShutdownAfterDeactivateNoOnDeactivateRPC(t *testing.T) {
pluginDir, err := os.MkdirTemp("", "mm-shutdown-after-deactivate-plugin")
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(pluginDir) })
webappPluginDir, err := os.MkdirTemp("", "mm-shutdown-after-deactivate-webapp")
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(webappPluginDir) })
pluginID := "test-shutdown-after-deactivate-plugin"
require.NoError(t, os.MkdirAll(filepath.Join(pluginDir, pluginID), 0700))
backend := filepath.Join(pluginDir, pluginID, "backend.exe")
utils.CompileGo(t, `
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) OnDeactivate() error {
return nil
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`, backend)
require.NoError(t, os.WriteFile(
filepath.Join(pluginDir, pluginID, "plugin.json"),
[]byte(`{"id":"`+pluginID+`","server":{"executable":"backend.exe"}}`),
0600,
))
logger := mlog.CreateConsoleTestLogger(t)
var buf mlog.Buffer
require.NoError(t, mlog.AddWriterTarget(logger, &buf, true, mlog.LvlError))
apiImpl := func(*model.Manifest) API { return nil }
env, err := NewEnvironment(apiImpl, nil, pluginDir, webappPluginDir, logger, nil)
require.NoError(t, err)
_, _, err = env.Activate(pluginID)
require.NoError(t, err)
require.True(t, env.IsActive(pluginID))
// Deactivate tears down the RPC connection but leaves the plugin registered.
require.True(t, env.Deactivate(pluginID))
require.False(t, env.IsActive(pluginID))
// Shutdown must not dispatch OnDeactivate again to the now-inactive plugin.
env.Shutdown()
require.NoError(t, logger.Flush())
assert.NotContains(t, buf.String(), "OnDeactivate",
"Shutdown dispatched OnDeactivate to an already-deactivated plugin")
}
// TestDeactivateReconcilesPluginState verifies that deactivation always reconciles the plugin to
// PluginStateNotRunning, clearing any prior error state, and that the health check job's ordering
// (deactivate, then set state) still records PluginStateFailedToStayRunning.
func TestDeactivateReconcilesPluginState(t *testing.T) {
pluginDir, err := os.MkdirTemp("", "mm-deactivate-state-plugin")
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(pluginDir) })
webappPluginDir, err := os.MkdirTemp("", "mm-deactivate-state-webapp")
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(webappPluginDir) })
pluginID := "test-deactivate-state-plugin"
require.NoError(t, os.MkdirAll(filepath.Join(pluginDir, pluginID), 0700))
backend := filepath.Join(pluginDir, pluginID, "backend.exe")
utils.CompileGo(t, `
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) OnDeactivate() error {
return nil
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`, backend)
require.NoError(t, os.WriteFile(
filepath.Join(pluginDir, pluginID, "plugin.json"),
[]byte(`{"id":"`+pluginID+`","server":{"executable":"backend.exe"}}`),
0600,
))
logger := mlog.CreateConsoleTestLogger(t)
apiImpl := func(*model.Manifest) API { return nil }
env, err := NewEnvironment(apiImpl, nil, pluginDir, webappPluginDir, logger, nil)
require.NoError(t, err)
t.Cleanup(env.Shutdown)
t.Run("deactivating an inactive, failed plugin clears the error state", func(t *testing.T) {
_, _, err = env.Activate(pluginID)
require.NoError(t, err)
require.True(t, env.IsActive(pluginID))
// Tear the plugin down, then simulate the health check marking it as failed while it
// remains registered but inactive.
require.True(t, env.Deactivate(pluginID))
require.False(t, env.IsActive(pluginID))
env.setPluginState(pluginID, model.PluginStateFailedToStayRunning)
// Deactivating an already-inactive plugin (e.g. on Shutdown or an explicit disable) must
// reconcile the error state back to not running rather than leaving it as failed.
require.False(t, env.Deactivate(pluginID))
assert.Equal(t, model.PluginStateNotRunning, env.GetPluginState(pluginID))
})
t.Run("health check ordering records FailedToStayRunning", func(t *testing.T) {
_, _, err = env.Activate(pluginID)
require.NoError(t, err)
require.True(t, env.IsActive(pluginID))
// Mirror PluginHealthCheckJob.CheckPlugin: deactivate first, then record the failed state.
require.True(t, env.Deactivate(pluginID))
env.setPluginState(pluginID, model.PluginStateFailedToStayRunning)
assert.Equal(t, model.PluginStateFailedToStayRunning, env.GetPluginState(pluginID))
})
}

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/utils"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func TestAvailablePlugins(t *testing.T) {
dir, err1 := os.MkdirTemp("", "mm-plugin-test")
require.NoError(t, err1)
t.Cleanup(func() {
os.RemoveAll(dir)
})
logger := mlog.CreateConsoleTestLogger(t)
env := Environment{
pluginDir: dir,
logger: logger,
}
t.Run("Should be able to load available plugins", func(t *testing.T) {
bundle1 := model.BundleInfo{
Manifest: &model.Manifest{
Id: "someid",
Version: "1",
},
}
err := os.Mkdir(filepath.Join(dir, "plugin1"), 0700)
require.NoError(t, err)
defer os.RemoveAll(filepath.Join(dir, "plugin1"))
path := filepath.Join(dir, "plugin1", "plugin.json")
manifestJSON, jsonErr := json.Marshal(bundle1.Manifest)
require.NoError(t, jsonErr)
err = os.WriteFile(path, manifestJSON, 0644)
require.NoError(t, err)
bundles, err := env.Available()
require.NoError(t, err)
require.Len(t, bundles, 1)
})
t.Run("Should not be able to load plugins without a valid manifest file", func(t *testing.T) {
err := os.Mkdir(filepath.Join(dir, "plugin2"), 0700)
require.NoError(t, err)
defer os.RemoveAll(filepath.Join(dir, "plugin2"))
path := filepath.Join(dir, "plugin2", "manifest.json")
err = os.WriteFile(path, []byte("{}"), 0644)
require.NoError(t, err)
bundles, err := env.Available()
require.NoError(t, err)
require.Len(t, bundles, 0)
})
t.Run("Should not be able to load plugins without a manifest file", func(t *testing.T) {
err := os.Mkdir(filepath.Join(dir, "plugin3"), 0700)
require.NoError(t, err)
defer os.RemoveAll(filepath.Join(dir, "plugin3"))
bundles, err := env.Available()
require.NoError(t, err)
require.Len(t, bundles, 0)
})
}
func TestHasPluginImplementing(t *testing.T) {
pluginDir, err := os.MkdirTemp("", "mm-haspluginimpl-plugin")
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(pluginDir) })
webappPluginDir, err := os.MkdirTemp("", "mm-haspluginimpl-webapp")
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(webappPluginDir) })
pluginID := "test-has-plugin-implementing"
require.NoError(t, os.MkdirAll(filepath.Join(pluginDir, pluginID), 0700))
backend := filepath.Join(pluginDir, pluginID, "backend.exe")
// This plugin implements MessageHasBeenPosted but not MessageHasBeenUpdated.
utils.CompileGo(t, `
package main
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`, backend)
require.NoError(t, os.WriteFile(
filepath.Join(pluginDir, pluginID, "plugin.json"),
[]byte(`{"id":"`+pluginID+`","server":{"executable":"backend.exe"}}`),
0600,
))
logger := mlog.CreateConsoleTestLogger(t)
apiImpl := func(*model.Manifest) API { return nil }
env, err := NewEnvironment(apiImpl, nil, pluginDir, webappPluginDir, logger, nil)
require.NoError(t, err)
t.Cleanup(env.Shutdown)
t.Run("no plugins registered", func(t *testing.T) {
require.False(t, env.HasPluginImplementing(MessageHasBeenPostedID))
})
_, _, err = env.Activate(pluginID)
require.NoError(t, err)
require.True(t, env.IsActive(pluginID))
t.Run("active plugin implementing the hook", func(t *testing.T) {
require.True(t, env.HasPluginImplementing(MessageHasBeenPostedID))
})
t.Run("active plugin not implementing the hook", func(t *testing.T) {
require.False(t, env.HasPluginImplementing(MessageHasBeenUpdatedID))
})
t.Run("deactivated plugin no longer counts", func(t *testing.T) {
require.True(t, env.Deactivate(pluginID))
require.False(t, env.HasPluginImplementing(MessageHasBeenPostedID))
})
}

View File

@@ -0,0 +1,290 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"errors"
"io"
"net/rpc"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/utils"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func TestRunMultiPluginHookWithRPCErr(t *testing.T) {
pluginDir, err := os.MkdirTemp("", "mm-rpcerr-plugin")
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(pluginDir) })
webappPluginDir, err := os.MkdirTemp("", "mm-rpcerr-webapp")
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(webappPluginDir) })
pluginID1 := "test-rpc-err-plugin"
pluginID2 := "test-rpc-err-plugin-2"
require.NoError(t, os.MkdirAll(filepath.Join(pluginDir, pluginID1), 0700))
require.NoError(t, os.MkdirAll(filepath.Join(pluginDir, pluginID2), 0700))
backend1 := filepath.Join(pluginDir, pluginID1, "backend.exe")
backend2 := filepath.Join(pluginDir, pluginID2, "backend.exe")
utils.CompileGo(t, `
package main
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`, backend1)
copyExecutable(t, backend1, backend2)
require.NoError(t, os.WriteFile(
filepath.Join(pluginDir, pluginID1, "plugin.json"),
[]byte(`{"id":"`+pluginID1+`","server":{"executable":"backend.exe"}}`),
0600,
))
require.NoError(t, os.WriteFile(
filepath.Join(pluginDir, pluginID2, "plugin.json"),
[]byte(`{"id":"`+pluginID2+`","server":{"executable":"backend.exe"}}`),
0600,
))
logger := mlog.CreateConsoleTestLogger(t)
apiImpl := func(*model.Manifest) API { return nil }
env, err := NewEnvironment(apiImpl, nil, pluginDir, webappPluginDir, logger, nil)
require.NoError(t, err)
t.Cleanup(env.Shutdown)
_, _, err = env.Activate(pluginID1)
require.NoError(t, err)
require.True(t, env.IsActive(pluginID1))
_, _, err = env.Activate(pluginID2)
require.NoError(t, err)
require.True(t, env.IsActive(pluginID2))
t.Run("both plugins healthy - closure invoked once per plugin", func(t *testing.T) {
seen := map[string]int{}
runErr := env.RunMultiPluginHookWithRPCErr(func(hooks HooksWithRPCErr, manifest *model.Manifest) (bool, error) {
seen[manifest.Id]++
require.NoError(t, hooks.MessageHasBeenPostedWithRPCErr(&Context{}, &model.Post{}))
return true, nil
}, MessageHasBeenPostedID)
require.NoError(t, runErr)
require.Equal(t, map[string]int{pluginID1: 1, pluginID2: 1}, seen)
})
t.Run("closure error propagates and stops iteration", func(t *testing.T) {
sentinel := errors.New("from closure")
var calls int
runErr := env.RunMultiPluginHookWithRPCErr(func(_ HooksWithRPCErr, _ *model.Manifest) (bool, error) {
calls++
return true, sentinel
}, MessageHasBeenPostedID)
require.ErrorIs(t, runErr, sentinel)
require.Equal(t, 1, calls)
})
t.Run("hook id not implemented - closure never invoked", func(t *testing.T) {
var calls int
runErr := env.RunMultiPluginHookWithRPCErr(func(_ HooksWithRPCErr, _ *model.Manifest) (bool, error) {
calls++
return true, nil
}, MessageHasBeenUpdatedID)
require.NoError(t, runErr)
require.Equal(t, 0, calls)
})
t.Run("rpc transport error surfaces after plugin process dies", func(t *testing.T) {
rp, ok := env.registeredPlugins.Load(pluginID1)
require.True(t, ok)
sup := rp.(registeredPlugin).supervisor
require.NotNil(t, sup)
sup.client.Kill()
// Give the rpc client a moment to notice the dead connection.
require.Eventually(t, func() bool {
var rpcErr error
_ = env.RunMultiPluginHookWithRPCErr(func(hooks HooksWithRPCErr, manifest *model.Manifest) (bool, error) {
if manifest.Id != pluginID1 {
return true, nil
}
rpcErr = hooks.MessageHasBeenPostedWithRPCErr(&Context{}, &model.Post{})
return true, nil
}, MessageHasBeenPostedID)
return rpcErr != nil
}, 2*time.Second, 50*time.Millisecond)
})
}
// TestShutdownNoRPCErrorsDuringConcurrentHookDispatch verifies that concurrent
// RunMultiPluginHookWithRPCErr calls during Shutdown do not observe "connection
// is shut down" errors.
//
// The race: Shutdown closes each plugin's RPC connection (supervisor.Shutdown)
// before removing it from registeredPlugins, leaving a window where the plugin
// still appears active but its transport is dead. The fix sets the plugin state
// to NotRunning before closing the RPC, so concurrent callers skip it cleanly.
func TestShutdownNoRPCErrorsDuringConcurrentHookDispatch(t *testing.T) {
pluginDir, err := os.MkdirTemp("", "mm-shutdown-race-plugin")
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(pluginDir) })
webappPluginDir, err := os.MkdirTemp("", "mm-shutdown-race-webapp")
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(webappPluginDir) })
fastID := "test-shutdown-race-fast"
slowID := "test-shutdown-race-slow"
require.NoError(t, os.MkdirAll(filepath.Join(pluginDir, fastID), 0700))
require.NoError(t, os.MkdirAll(filepath.Join(pluginDir, slowID), 0700))
fastBackend := filepath.Join(pluginDir, fastID, "backend.exe")
slowBackend := filepath.Join(pluginDir, slowID, "backend.exe")
// fast plugin: instant OnDeactivate
utils.CompileGo(t, `
package main
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)
type FastPlugin struct{ plugin.MattermostPlugin }
func (p *FastPlugin) MessageHasBeenPosted(_ *plugin.Context, _ *model.Post) {}
func main() { plugin.ClientMain(&FastPlugin{}) }
`, fastBackend)
// slow plugin: OnDeactivate blocks until the test signals it via MessageWillBePosted,
// keeping Shutdown's wg.Wait alive so the fast plugin's entry lingers in registeredPlugins.
utils.CompileGo(t, `
package main
import (
"sync"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)
type SlowPlugin struct {
plugin.MattermostPlugin
once sync.Once
proceed chan struct{}
}
func (p *SlowPlugin) OnActivate() error {
p.proceed = make(chan struct{})
return nil
}
func (p *SlowPlugin) OnDeactivate() error {
<-p.proceed
return nil
}
func (p *SlowPlugin) MessageWillBePosted(_ *plugin.Context, _ *model.Post) (*model.Post, string) {
p.once.Do(func() { close(p.proceed) })
return nil, ""
}
func (p *SlowPlugin) MessageHasBeenPosted(_ *plugin.Context, _ *model.Post) {}
func main() { plugin.ClientMain(&SlowPlugin{}) }
`, slowBackend)
require.NoError(t, os.WriteFile(
filepath.Join(pluginDir, fastID, "plugin.json"),
[]byte(`{"id":"`+fastID+`","server":{"executable":"backend.exe"}}`),
0600,
))
require.NoError(t, os.WriteFile(
filepath.Join(pluginDir, slowID, "plugin.json"),
[]byte(`{"id":"`+slowID+`","server":{"executable":"backend.exe"}}`),
0600,
))
logger := mlog.CreateConsoleTestLogger(t)
apiImpl := func(*model.Manifest) API { return nil }
env, err := NewEnvironment(apiImpl, nil, pluginDir, webappPluginDir, logger, nil)
require.NoError(t, err)
_, _, err = env.Activate(fastID)
require.NoError(t, err)
_, _, err = env.Activate(slowID)
require.NoError(t, err)
// Race window: Shutdown is running concurrently. After the fast plugin's RPC
// is closed (it finishes OnDeactivate first) but before registeredPlugins is
// cleaned up (gated on the slow plugin finishing), concurrent hook dispatches
// must not observe net/rpc.ErrShutdown.
//
// The slow plugin blocks in OnDeactivate until we call MessageWillBePosted,
// giving us a controlled window to dispatch hooks while the fast plugin's RPC
// is already closed. Each dispatch involves IPC (a Unix socket round-trip),
// which yields to the scheduler and lets the fast plugin's teardown goroutine run.
shutdownDone := make(chan struct{})
go func() {
defer close(shutdownDone)
env.Shutdown()
}()
var rpcErrs []error
for range 200 {
_ = env.RunMultiPluginHookWithRPCErr(func(hooks HooksWithRPCErr, _ *model.Manifest) (bool, error) {
if rpcErr := hooks.MessageHasBeenPostedWithRPCErr(&Context{}, &model.Post{}); rpcErr != nil {
rpcErrs = append(rpcErrs, rpcErr)
}
return true, nil
}, MessageHasBeenPostedID)
}
// Signal the slow plugin to finish OnDeactivate, unblocking Shutdown.
env.RunMultiPluginHook(func(hooks Hooks, _ *model.Manifest) bool {
hooks.MessageWillBePosted(&Context{}, &model.Post{})
return true
}, MessageWillBePostedID)
<-shutdownDone
// Filter to only the canonical shutdown error so the test isn't brittle
// against other transient RPC errors (e.g. EOF on race-y reads).
var shutdownErrs []error
for _, e := range rpcErrs {
if errors.Is(e, rpc.ErrShutdown) {
shutdownErrs = append(shutdownErrs, e)
}
}
assert.Empty(t, shutdownErrs,
"RunMultiPluginHookWithRPCErr dispatched to a plugin whose RPC connection was already closed during Shutdown")
}
func copyExecutable(t *testing.T, src, dst string) {
t.Helper()
in, err := os.Open(src)
require.NoError(t, err)
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0700)
require.NoError(t, err)
defer out.Close()
_, err = io.Copy(out, in)
require.NoError(t, err)
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin_test
import (
"fmt"
"net/http"
"github.com/mattermost/mattermost/server/public/plugin"
)
// HelloWorldPlugin implements the interface expected by the Mattermost server to communicate
// between the server and plugin processes.
type HelloWorldPlugin struct {
plugin.MattermostPlugin
}
// ServeHTTP demonstrates a plugin that handles HTTP requests by greeting the world.
func (p *HelloWorldPlugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "Hello, world!")
}
// This example demonstrates a plugin that handles HTTP requests which respond by greeting the
// world.
func Example_helloWorld() {
plugin.ClientMain(&HelloWorldPlugin{})
}

View File

@@ -0,0 +1,120 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin_test
import (
"strings"
"sync"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)
// configuration represents the configuration for this plugin as exposed via the Mattermost
// server configuration.
type configuration struct {
TeamName string
ChannelName string
// channelID is resolved when the public configuration fields above change
channelID string
}
// HelpPlugin implements the interface expected by the Mattermost server to communicate
// between the server and plugin processes.
type HelpPlugin struct {
plugin.MattermostPlugin
// configurationLock synchronizes access to the configuration.
configurationLock sync.RWMutex
// configuration is the active plugin configuration. Consult getConfiguration and
// setConfiguration for usage.
configuration *configuration
}
// getConfiguration retrieves the active configuration under lock, making it safe to use
// concurrently. The active configuration may change underneath the client of this method, but
// the struct returned by this API call is considered immutable.
func (p *HelpPlugin) getConfiguration() *configuration {
p.configurationLock.RLock()
defer p.configurationLock.RUnlock()
if p.configuration == nil {
return &configuration{}
}
return p.configuration
}
// setConfiguration replaces the active configuration under lock.
//
// Do not call setConfiguration while holding the configurationLock, as sync.Mutex is not
// reentrant.
func (p *HelpPlugin) setConfiguration(configuration *configuration) {
// Replace the active configuration under lock.
p.configurationLock.Lock()
defer p.configurationLock.Unlock()
p.configuration = configuration
}
// OnConfigurationChange updates the active configuration for this plugin under lock.
func (p *HelpPlugin) OnConfigurationChange() error {
var configuration = new(configuration)
// Load the public configuration fields from the Mattermost server configuration.
if err := p.API.LoadPluginConfiguration(configuration); err != nil {
return errors.Wrap(err, "failed to load plugin configuration")
}
team, err := p.API.GetTeamByName(configuration.TeamName)
if err != nil {
return errors.Wrapf(err, "failed to find team %s", configuration.TeamName)
}
channel, err := p.API.GetChannelByName(team.Id, configuration.ChannelName, false)
if err != nil {
return errors.Wrapf(err, "failed to find channel %s", configuration.ChannelName)
}
configuration.channelID = channel.Id
p.setConfiguration(configuration)
return nil
}
// MessageHasBeenPosted automatically replies to posts that plea for help.
func (p *HelpPlugin) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {
configuration := p.getConfiguration()
// Ignore posts not in the configured channel
if post.ChannelId != configuration.channelID {
return
}
// Ignore posts this plugin made.
if sentByPlugin, _ := post.GetProp("sent_by_plugin").(bool); sentByPlugin {
return
}
// Ignore posts without a plea for help.
if !strings.Contains(post.Message, "help") {
return
}
p.API.SendEphemeralPost(post.UserId, &model.Post{
ChannelId: configuration.channelID,
Message: "You asked for help? Checkout https://support.mattermost.com/hc/en-us",
Props: map[string]any{
"sent_by_plugin": true,
},
})
}
func Example_helpPlugin() {
plugin.ClientMain(&HelpPlugin{})
}

View File

@@ -0,0 +1,135 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"fmt"
"io"
"log"
"strings"
"github.com/hashicorp/go-hclog"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
type hclogAdapter struct {
wrappedLogger *mlog.Logger
extrasKey string
}
func (h *hclogAdapter) Log(level hclog.Level, msg string, args ...any) {
switch level {
case hclog.Trace:
h.Trace(msg, args...)
case hclog.Debug:
h.Debug(msg, args...)
case hclog.Info:
h.Info(msg, args...)
case hclog.Warn:
h.Warn(msg, args...)
case hclog.Error:
h.Error(msg, args...)
default:
// For unknown/unexpected log level, treat it as an error so we notice and fix the code.
h.Error(msg, args...)
}
}
func (h *hclogAdapter) Trace(msg string, args ...any) {
extras := strings.TrimSpace(fmt.Sprint(args...))
if extras != "" {
h.wrappedLogger.Debug(msg, mlog.String(h.extrasKey, extras))
} else {
h.wrappedLogger.Debug(msg)
}
}
func (h *hclogAdapter) Debug(msg string, args ...any) {
extras := strings.TrimSpace(fmt.Sprint(args...))
if extras != "" {
h.wrappedLogger.Debug(msg, mlog.String(h.extrasKey, extras))
} else {
h.wrappedLogger.Debug(msg)
}
}
func (h *hclogAdapter) Info(msg string, args ...any) {
extras := strings.TrimSpace(fmt.Sprint(args...))
if extras != "" {
h.wrappedLogger.Info(msg, mlog.String(h.extrasKey, extras))
} else {
h.wrappedLogger.Info(msg)
}
}
func (h *hclogAdapter) Warn(msg string, args ...any) {
extras := strings.TrimSpace(fmt.Sprint(args...))
if extras != "" {
h.wrappedLogger.Warn(msg, mlog.String(h.extrasKey, extras))
} else {
h.wrappedLogger.Warn(msg)
}
}
func (h *hclogAdapter) Error(msg string, args ...any) {
extras := strings.TrimSpace(fmt.Sprint(args...))
if extras != "" {
h.wrappedLogger.Error(msg, mlog.String(h.extrasKey, extras))
} else {
h.wrappedLogger.Error(msg)
}
}
func (h *hclogAdapter) IsTrace() bool {
return false
}
func (h *hclogAdapter) IsDebug() bool {
return true
}
func (h *hclogAdapter) IsInfo() bool {
return true
}
func (h *hclogAdapter) IsWarn() bool {
return true
}
func (h *hclogAdapter) IsError() bool {
return true
}
func (h *hclogAdapter) With(args ...any) hclog.Logger {
return h
}
func (h *hclogAdapter) Named(name string) hclog.Logger {
return h
}
func (h *hclogAdapter) ResetNamed(name string) hclog.Logger {
return h
}
func (h *hclogAdapter) StandardLogger(opts *hclog.StandardLoggerOptions) *log.Logger {
return h.wrappedLogger.StdLogger(mlog.LvlInfo)
}
func (h *hclogAdapter) StandardWriter(opts *hclog.StandardLoggerOptions) io.Writer {
return h.wrappedLogger.StdLogWriter()
}
func (h *hclogAdapter) SetLevel(hclog.Level) {}
func (h *hclogAdapter) GetLevel() hclog.Level { return hclog.NoLevel }
func (h *hclogAdapter) ImpliedArgs() []any {
return []any{}
}
func (h *hclogAdapter) Name() string {
return "MattermostPluginLogger"
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"sync"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
const (
HealthCheckInterval = 30 * time.Second // How often the health check should run
HealthCheckDeactivationWindow = 60 * time.Minute // How long we wait for num fails to occur before deactivating the plugin
HealthCheckPingFailLimit = 3 // How many times we call RPC ping in a row before it is considered a failure
HealthCheckNumRestartsLimit = 3 // How many times we restart a plugin before we deactivate it
)
type PluginHealthCheckJob struct {
cancel chan struct{}
cancelled chan struct{}
cancelOnce sync.Once
env *Environment
failureTimestamps sync.Map
}
// run continuously performs health checks on all active plugins, on a timer.
func (job *PluginHealthCheckJob) run() {
mlog.Debug("Plugin health check job starting.")
defer close(job.cancelled)
ticker := time.NewTicker(HealthCheckInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
activePlugins := job.env.Active()
for _, plugin := range activePlugins {
job.CheckPlugin(plugin.Manifest.Id)
}
case <-job.cancel:
return
}
}
}
// CheckPlugin determines the plugin's health status, then handles the error or success case.
// If the plugin passes the health check, do nothing.
// If the plugin fails the health check, the function either restarts or deactivates the plugin, based on the quantity and frequency of its failures.
func (job *PluginHealthCheckJob) CheckPlugin(id string) {
err := job.env.PerformHealthCheck(id)
if err == nil {
return
}
mlog.Warn("Health check failed for plugin", mlog.String("id", id), mlog.Err(err))
timestamps := job.getStoredTimestamps(id)
timestamps = append(timestamps, time.Now())
if shouldDeactivatePlugin(timestamps) {
// Order matters here, must deactivate first and then set plugin state
mlog.Debug("Deactivating plugin due to multiple crashes", mlog.String("id", id))
job.env.Deactivate(id)
// Reset timestamp state for this plugin
job.failureTimestamps.Delete(id)
job.env.setPluginState(id, model.PluginStateFailedToStayRunning)
} else {
mlog.Debug("Restarting plugin due to failed health check", mlog.String("id", id))
if err := job.env.RestartPlugin(id); err != nil {
mlog.Error("Failed to restart plugin", mlog.String("id", id), mlog.Err(err))
}
// Store this failure so we can continue to monitor the plugin
job.failureTimestamps.Store(id, removeStaleTimestamps(timestamps))
}
}
// getStoredTimestamps returns the stored failure timestamps for a plugin.
func (job *PluginHealthCheckJob) getStoredTimestamps(id string) []time.Time {
timestamps, ok := job.failureTimestamps.Load(id)
if !ok {
timestamps = []time.Time{}
}
return timestamps.([]time.Time)
}
func newPluginHealthCheckJob(env *Environment) *PluginHealthCheckJob {
return &PluginHealthCheckJob{
cancel: make(chan struct{}),
cancelled: make(chan struct{}),
env: env,
}
}
func (job *PluginHealthCheckJob) Cancel() {
job.cancelOnce.Do(func() {
close(job.cancel)
})
<-job.cancelled
}
// shouldDeactivatePlugin determines if a plugin needs to be deactivated after the plugin has failed (HealthCheckNumRestartsLimit) times,
// within the configured time window (HealthCheckDeactivationWindow).
func shouldDeactivatePlugin(failedTimestamps []time.Time) bool {
if len(failedTimestamps) < HealthCheckNumRestartsLimit {
return false
}
index := len(failedTimestamps) - HealthCheckNumRestartsLimit
return time.Since(failedTimestamps[index]) <= HealthCheckDeactivationWindow
}
// removeStaleTimestamps only keeps the last HealthCheckNumRestartsLimit items in timestamps.
func removeStaleTimestamps(timestamps []time.Time) []time.Time {
if len(timestamps) > HealthCheckNumRestartsLimit {
timestamps = timestamps[len(timestamps)-HealthCheckNumRestartsLimit:]
}
return timestamps
}

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/utils"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func TestPluginHealthCheck(t *testing.T) {
for name, f := range map[string]func(*testing.T){
"PluginHealthCheck_Success": testPluginHealthCheckSuccess,
"PluginHealthCheck_Panic": testPluginHealthCheckPanic,
} {
t.Run(name, f)
}
}
func testPluginHealthCheckSuccess(t *testing.T) {
dir, err := os.MkdirTemp("", "")
require.NoError(t, err)
defer os.RemoveAll(dir)
backend := filepath.Join(dir, "backend.exe")
utils.CompileGo(t, `
package main
import (
"github.com/mattermost/mattermost/server/public/plugin"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`, backend)
err = os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"id": "foo", "server": {"executable": "backend.exe"}}`), 0600)
require.NoError(t, err)
bundle := model.BundleInfoForPath(dir)
logger := mlog.CreateConsoleTestLogger(t)
supervisor, err := newSupervisor(bundle, nil, nil, logger, nil, WithExecutableFromManifest(bundle))
require.NoError(t, err)
require.NotNil(t, supervisor)
defer supervisor.Shutdown()
err = supervisor.PerformHealthCheck()
require.NoError(t, err)
}
func testPluginHealthCheckPanic(t *testing.T) {
dir, err := os.MkdirTemp("", "")
require.NoError(t, err)
defer os.RemoveAll(dir)
backend := filepath.Join(dir, "backend.exe")
utils.CompileGo(t, `
package main
import (
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)
type MyPlugin struct {
plugin.MattermostPlugin
}
func (p *MyPlugin) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) {
panic("Uncaught error")
}
func main() {
plugin.ClientMain(&MyPlugin{})
}
`, backend)
err = os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"id": "foo", "server": {"executable": "backend.exe"}}`), 0600)
require.NoError(t, err)
bundle := model.BundleInfoForPath(dir)
logger := mlog.CreateConsoleTestLogger(t)
supervisor, err := newSupervisor(bundle, nil, nil, logger, nil, WithExecutableFromManifest(bundle))
require.NoError(t, err)
require.NotNil(t, supervisor)
defer supervisor.Shutdown()
err = supervisor.PerformHealthCheck()
require.NoError(t, err)
supervisor.hooks.MessageWillBePosted(&Context{}, &model.Post{})
err = supervisor.PerformHealthCheck()
require.Error(t, err)
}
func TestShouldDeactivatePlugin(t *testing.T) {
// No failures, don't restart
ftime := []time.Time{}
result := shouldDeactivatePlugin(ftime)
require.Equal(t, false, result)
now := time.Now()
// Failures are recent enough to restart
ftime = []time.Time{}
ftime = append(ftime, now.Add(-HealthCheckDeactivationWindow/10*2))
ftime = append(ftime, now.Add(-HealthCheckDeactivationWindow/10))
ftime = append(ftime, now)
result = shouldDeactivatePlugin(ftime)
require.Equal(t, true, result)
// Failures are too spaced out to warrant a restart
ftime = []time.Time{}
ftime = append(ftime, now.Add(-HealthCheckDeactivationWindow*2))
ftime = append(ftime, now.Add(-HealthCheckDeactivationWindow*1))
ftime = append(ftime, now)
result = shouldDeactivatePlugin(ftime)
require.Equal(t, false, result)
// Not enough failures are present to warrant a restart
ftime = []time.Time{}
ftime = append(ftime, now.Add(-HealthCheckDeactivationWindow/10))
ftime = append(ftime, now)
result = shouldDeactivatePlugin(ftime)
require.Equal(t, false, result)
}

View File

@@ -0,0 +1,205 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"bufio"
"errors"
"net"
"net/http"
"net/rpc"
"time"
)
const (
hijackedConnReadBufSize = 4096
)
var (
ErrNotHijacked = errors.New("response is not hijacked")
ErrAlreadyHijacked = errors.New("response was already hijacked")
ErrCannotHijack = errors.New("response cannot be hijacked")
)
func (w *httpResponseWriterRPCServer) HjConnRWRead(b []byte, reply *[]byte) error {
if w.hjr == nil {
return ErrNotHijacked
}
data := make([]byte, len(b))
n, err := w.hjr.bufrw.Read(data)
if err != nil {
return err
}
*reply = data[:n]
return nil
}
func (w *httpResponseWriterRPCServer) HjConnRWWrite(b []byte, reply *int) error {
if w.hjr == nil {
return ErrNotHijacked
}
n, err := w.hjr.bufrw.Write(b)
if err != nil {
return err
}
*reply = n
return nil
}
func (w *httpResponseWriterRPCServer) HjConnRead(size int, reply *[]byte) error {
if w.hjr == nil {
return ErrNotHijacked
}
if len(w.hjr.readBuf) < size {
w.hjr.readBuf = make([]byte, size)
}
n, err := w.hjr.conn.Read(w.hjr.readBuf[:size])
if err != nil {
return err
}
*reply = w.hjr.readBuf[:n]
return nil
}
func (w *httpResponseWriterRPCServer) HjConnWrite(b []byte, reply *int) error {
if w.hjr == nil {
return ErrNotHijacked
}
n, err := w.hjr.conn.Write(b)
if err != nil {
return err
}
*reply = n
return nil
}
func (w *httpResponseWriterRPCServer) HjConnClose(args struct{}, reply *struct{}) error {
if w.hjr == nil {
return ErrNotHijacked
}
return w.hjr.conn.Close()
}
func (w *httpResponseWriterRPCServer) HjConnSetDeadline(t time.Time, reply *struct{}) error {
if w.hjr == nil {
return ErrNotHijacked
}
return w.hjr.conn.SetDeadline(t)
}
func (w *httpResponseWriterRPCServer) HjConnSetReadDeadline(t time.Time, reply *struct{}) error {
if w.hjr == nil {
return ErrNotHijacked
}
return w.hjr.conn.SetReadDeadline(t)
}
func (w *httpResponseWriterRPCServer) HjConnSetWriteDeadline(t time.Time, reply *struct{}) error {
if w.hjr == nil {
return ErrNotHijacked
}
return w.hjr.conn.SetWriteDeadline(t)
}
func (w *httpResponseWriterRPCServer) HijackResponse(args struct{}, reply *struct{}) error {
if w.hjr != nil {
return ErrAlreadyHijacked
}
hj, ok := w.w.(http.Hijacker)
if !ok {
return ErrCannotHijack
}
conn, bufrw, err := hj.Hijack()
if err != nil {
return err
}
w.hjr = &hijackedResponse{
conn: conn,
bufrw: bufrw,
readBuf: make([]byte, hijackedConnReadBufSize),
}
return nil
}
type hijackedConn struct {
client *rpc.Client
}
type hijackedConnRW struct {
client *rpc.Client
}
func (w *hijackedConnRW) Read(b []byte) (int, error) {
var data []byte
if err := w.client.Call("Plugin.HjConnRWRead", b, &data); err != nil {
return 0, err
}
copy(b, data)
return len(data), nil
}
func (w *hijackedConnRW) Write(b []byte) (int, error) {
var n int
if err := w.client.Call("Plugin.HjConnRWWrite", b, &n); err != nil {
return 0, err
}
return n, nil
}
func (w *hijackedConn) Read(b []byte) (int, error) {
var data []byte
if err := w.client.Call("Plugin.HjConnRead", len(b), &data); err != nil {
return 0, err
}
copy(b, data)
return len(data), nil
}
func (w *hijackedConn) Write(b []byte) (int, error) {
var n int
if err := w.client.Call("Plugin.HjConnWrite", b, &n); err != nil {
return 0, err
}
return n, nil
}
func (w *hijackedConn) Close() error {
return w.client.Call("Plugin.HjConnClose", struct{}{}, nil)
}
func (w *hijackedConn) LocalAddr() net.Addr {
return nil
}
func (w *hijackedConn) RemoteAddr() net.Addr {
return nil
}
func (w *hijackedConn) SetDeadline(t time.Time) error {
return w.client.Call("Plugin.HjConnSetDeadline", t, nil)
}
func (w *hijackedConn) SetReadDeadline(t time.Time) error {
return w.client.Call("Plugin.HjConnSetReadDeadline", t, nil)
}
func (w *hijackedConn) SetWriteDeadline(t time.Time) error {
return w.client.Call("Plugin.HjConnSetWriteDeadline", t, nil)
}
func (w *httpResponseWriterRPCClient) Hijack() (net.Conn, *bufio.ReadWriter, error) {
c := &hijackedConn{
client: w.client,
}
rw := &hijackedConnRW{
client: w.client,
}
if err := w.client.Call("Plugin.HijackResponse", struct{}{}, nil); err != nil {
return nil, nil, err
}
return c, bufio.NewReadWriter(bufio.NewReader(rw), bufio.NewWriter(rw)), nil
}

View File

@@ -0,0 +1,536 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"io"
"net/http"
saml2 "github.com/mattermost/gosaml2"
"github.com/mattermost/mattermost/server/public/model"
)
// These assignments are part of the wire protocol used to trigger hook events in plugins.
//
// Feel free to add more, but do not change existing assignments. Follow the naming convention of
// <HookName>ID as the autogenerated glue code depends on that.
const (
OnActivateID = 0
OnDeactivateID = 1
ServeHTTPID = 2
OnConfigurationChangeID = 3
ExecuteCommandID = 4
MessageWillBePostedID = 5
MessageWillBeUpdatedID = 6
MessageHasBeenPostedID = 7
MessageHasBeenUpdatedID = 8
UserHasJoinedChannelID = 9
UserHasLeftChannelID = 10
UserHasJoinedTeamID = 11
UserHasLeftTeamID = 12
ChannelHasBeenCreatedID = 13
FileWillBeUploadedID = 14
UserWillLogInID = 15
UserHasLoggedInID = 16
UserHasBeenCreatedID = 17
ReactionHasBeenAddedID = 18
ReactionHasBeenRemovedID = 19
OnPluginClusterEventID = 20
OnWebSocketConnectID = 21
OnWebSocketDisconnectID = 22
WebSocketMessageHasBeenPostedID = 23
RunDataRetentionID = 24
OnInstallID = 25
OnSendDailyTelemetryID = 26
OnCloudLimitsUpdatedID = 27
deprecatedUserHasPermissionToCollectionID = 28
deprecatedGetAllUserIdsForCollectionID = 29
deprecatedGetAllCollectionIDsForUserID = 30
deprecatedGetTopicRedirectID = 31
deprecatedGetCollectionMetadataByIdsID = 32
deprecatedGetTopicMetadataByIdsID = 33
ConfigurationWillBeSavedID = 34
NotificationWillBePushedID = 35
UserHasBeenDeactivatedID = 36
MessageHasBeenDeletedID = 37
MessagesWillBeConsumedID = 38
ServeMetricsID = 39
OnSharedChannelsSyncMsgID = 40
OnSharedChannelsPingID = 41
PreferencesHaveChangedID = 42
OnSharedChannelsAttachmentSyncMsgID = 43
OnSharedChannelsProfileImageSyncMsgID = 44
GenerateSupportDataID = 45
OnSAMLLoginID = 46
EmailNotificationWillBeSentID = 47
FileWillBeDownloadedID = 48
ChannelMemberWillBeAddedID = 49
TeamMemberWillBeAddedID = 50
ChannelWillBeArchivedID = 51
ChannelWillBeUpdatedID = 52
ChannelWillBeRestoredID = 53
ScheduledPostWillBeCreatedID = 54
DraftWillBeUpsertedID = 55
MessagesWillBeConsumedWithContextID = 56
OnLicenseChangedID = 57
TotalHooksID = iota
)
const (
// DismissPostError dismisses a pending post when the error is returned from MessageWillBePosted.
DismissPostError = "plugin.message_will_be_posted.dismiss_post"
)
// Hooks describes the methods a plugin may implement to automatically receive the corresponding
// event.
//
// A plugin only need implement the hooks it cares about. The MattermostPlugin provides some
// default implementations for convenience but may be overridden.
type Hooks interface {
// OnActivate is invoked when the plugin is activated. If an error is returned, the plugin
// will be terminated. The plugin will not receive hooks until after OnActivate returns
// without error. OnConfigurationChange will be called once before OnActivate.
//
// Minimum server version: 5.2
OnActivate() error
// Implemented returns a list of hooks that are implemented by the plugin.
// Plugins do not need to provide an implementation. Any given will be ignored.
//
// Minimum server version: 5.2
Implemented() ([]string, error)
// OnDeactivate is invoked when the plugin is deactivated. This is the plugin's last chance to
// use the API, and the plugin will be terminated shortly after this invocation. The plugin
// will stop receiving hooks just prior to this method being called.
//
// Minimum server version: 5.2
OnDeactivate() error
// OnConfigurationChange is invoked when configuration changes may have been made. Any
// returned error is logged, but does not stop the plugin. You must be prepared to handle
// a configuration failure gracefully. It is called once before OnActivate.
//
// Minimum server version: 5.2
OnConfigurationChange() error
// ServeHTTP allows the plugin to implement the http.Handler interface. Requests destined for
// the /plugins/{id} path will be routed to the plugin.
//
// The Mattermost-User-Id header will be present if (and only if) the request is by an
// authenticated user.
//
// Minimum server version: 5.2
ServeHTTP(c *Context, w http.ResponseWriter, r *http.Request)
// ExecuteCommand executes a command that has been previously registered via the RegisterCommand
// API.
//
// Minimum server version: 5.2
ExecuteCommand(c *Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError)
// UserHasBeenCreated is invoked after a user was created.
//
// Minimum server version: 5.10
UserHasBeenCreated(c *Context, user *model.User)
// UserWillLogIn before the login of the user is returned. Returning a non empty string will reject the login event.
// If you don't need to reject the login event, see UserHasLoggedIn
//
// Minimum server version: 5.2
UserWillLogIn(c *Context, user *model.User) string
// UserHasLoggedIn is invoked after a user has logged in.
//
// Minimum server version: 5.2
UserHasLoggedIn(c *Context, user *model.User)
// MessageWillBePosted is invoked when a message is posted by a user before it is committed
// to the database. If you also want to act on edited posts, see MessageWillBeUpdated.
//
// To reject a post, return an non-empty string describing why the post was rejected.
// To modify the post, return the replacement, non-nil *model.Post and an empty string.
// To allow the post without modification, return a nil *model.Post and an empty string.
// To dismiss the post, return a nil *model.Post and the const DismissPostError string.
//
// If you don't need to modify or reject posts, use MessageHasBeenPosted instead.
//
// Note that this method will be called for posts created by plugins, including the plugin that
// created the post.
//
// Minimum server version: 5.2
MessageWillBePosted(c *Context, post *model.Post) (*model.Post, string)
// MessageWillBeUpdated is invoked when a message is updated by a user before it is committed
// to the database. If you also want to act on new posts, see MessageWillBePosted.
// Return values should be the modified post or nil if rejected and an explanation for the user.
// On rejection, the post will be kept in its previous state.
//
// If you don't need to modify or rejected updated posts, use MessageHasBeenUpdated instead.
//
// Note that this method will be called for posts updated by plugins, including the plugin that
// updated the post.
//
// Minimum server version: 5.2
MessageWillBeUpdated(c *Context, newPost, oldPost *model.Post) (*model.Post, string)
// MessageHasBeenPosted is invoked after the message has been committed to the database.
// If you need to modify or reject the post, see MessageWillBePosted
// Note that this method will be called for posts created by plugins, including the plugin that
// created the post.
//
// Minimum server version: 5.2
MessageHasBeenPosted(c *Context, post *model.Post)
// MessageHasBeenUpdated is invoked after a message is updated and has been updated in the database.
// If you need to modify or reject the post, see MessageWillBeUpdated
// Note that this method will be called for posts created by plugins, including the plugin that
// created the post.
//
// Minimum server version: 5.2
MessageHasBeenUpdated(c *Context, newPost, oldPost *model.Post)
// MessagesWillBeConsumed is invoked when a message is requested by a client before it is returned
// to the client
//
// Note that this method will be called for posts created by plugins, including the plugin that
// created the post.
//
// Minimum server version: 9.3
MessagesWillBeConsumed(posts []*model.Post) []*model.Post
// MessagesWillBeConsumedWithContext is invoked when messages are requested by a client, before
// they are returned to the client. It is the context-aware variant of MessagesWillBeConsumed.
//
// To modify a post, return the replacement post; the returned posts are matched to the originals
// by ID. Posts that should be left unchanged may be omitted from the returned slice.
//
// Note that this method will be called for posts created by plugins, including the plugin that
// created the post.
//
// Minimum server version: 11.9
MessagesWillBeConsumedWithContext(c *Context, posts []*model.Post) []*model.Post
// MessageHasBeenDeleted is invoked after the message has been deleted from the database.
// Note that this method will be called for posts deleted by plugins, including the plugin that
// deleted the post.
//
// Minimum server version: 9.1
MessageHasBeenDeleted(c *Context, post *model.Post)
// ChannelHasBeenCreated is invoked after the channel has been committed to the database.
//
// Minimum server version: 5.2
ChannelHasBeenCreated(c *Context, channel *model.Channel)
// ChannelWillBeArchived is invoked before a channel is archived, allowing plugins to
// reject the archive operation.
//
// To reject the archive, return a non-empty string describing why it was rejected.
// To allow the archive, return an empty string.
//
// Minimum server version: 11.7
ChannelWillBeArchived(c *Context, channel *model.Channel) string
// ChannelMemberWillBeAdded is invoked before a member is added to a channel, allowing
// plugins to modify the channel member or reject the addition.
//
// To reject the addition, return a non-empty string describing why it was rejected.
// To modify the member, return the replacement, non-nil *model.ChannelMember and an empty string.
// To allow the addition without modification, return a nil *model.ChannelMember and an empty string.
//
// Minimum server version: 11.7
ChannelMemberWillBeAdded(c *Context, channelMember *model.ChannelMember) (*model.ChannelMember, string)
// UserHasJoinedChannel is invoked after the membership has been committed to the database.
// If actor is not nil, the user was invited to the channel by the actor.
//
// Minimum server version: 5.2
UserHasJoinedChannel(c *Context, channelMember *model.ChannelMember, actor *model.User)
// UserHasLeftChannel is invoked after the membership has been removed from the database.
// If actor is not nil, the user was removed from the channel by the actor.
//
// Minimum server version: 5.2
UserHasLeftChannel(c *Context, channelMember *model.ChannelMember, actor *model.User)
// TeamMemberWillBeAdded is invoked before a member is added to a team, allowing
// plugins to modify the team member or reject the addition.
//
// To reject the addition, return a non-empty string describing why it was rejected.
// To modify the member, return the replacement, non-nil *model.TeamMember and an empty string.
// To allow the addition without modification, return a nil *model.TeamMember and an empty string.
//
// Minimum server version: 11.7
TeamMemberWillBeAdded(c *Context, teamMember *model.TeamMember) (*model.TeamMember, string)
// UserHasJoinedTeam is invoked after the membership has been committed to the database.
// If actor is not nil, the user was added to the team by the actor.
//
// Minimum server version: 5.2
UserHasJoinedTeam(c *Context, teamMember *model.TeamMember, actor *model.User)
// UserHasLeftTeam is invoked after the membership has been removed from the database.
// If actor is not nil, the user was removed from the team by the actor.
//
// Minimum server version: 5.2
UserHasLeftTeam(c *Context, teamMember *model.TeamMember, actor *model.User)
// FileWillBeUploaded is invoked when a file is uploaded, but before it is committed to backing store.
// Read from file to retrieve the body of the uploaded file.
//
// To reject a file upload, return an non-empty string describing why the file was rejected.
// To modify the file, write to the output and/or return a non-nil *model.FileInfo, as well as an empty string.
// To allow the file without modification, do not write to the output and return a nil *model.FileInfo and an empty string.
//
// Note that this method will be called for files uploaded by plugins, including the plugin that uploaded the post.
// FileInfo.Size will be automatically set properly if you modify the file.
//
// Minimum server version: 5.2
FileWillBeUploaded(c *Context, info *model.FileInfo, file io.Reader, output io.Writer) (*model.FileInfo, string)
// FileWillBeDownloaded is invoked when a file is requested for download, but before it is sent to the client.
//
// To reject a file download, return an non-empty string describing why the file was rejected.
// To allow the download, return an empty string.
//
// The downloadType parameter indicates the type of file access and can be one of:
// - model.FileDownloadTypeFile: Full file download
// - model.FileDownloadTypeThumbnail: Thumbnail request
// - model.FileDownloadTypePreview: Preview image request
// - model.FileDownloadTypePublic: Public link access (userID will be empty string in this case)
//
// Minimum server version: 11.5
FileWillBeDownloaded(c *Context, fileInfo *model.FileInfo, userID string, downloadType model.FileDownloadType) string
// ReactionHasBeenAdded is invoked after the reaction has been committed to the database.
//
// Note that this method will be called for reactions added by plugins, including the plugin that
// added the reaction.
//
// Minimum server version: 5.30
ReactionHasBeenAdded(c *Context, reaction *model.Reaction)
// ReactionHasBeenRemoved is invoked after the removal of the reaction has been committed to the database.
//
// Note that this method will be called for reactions removed by plugins, including the plugin that
// removed the reaction.
//
// Minimum server version: 5.30
ReactionHasBeenRemoved(c *Context, reaction *model.Reaction)
// OnPluginClusterEvent is invoked when an intra-cluster plugin event is received.
//
// This is used to allow communication between multiple instances of the same plugin
// that are running on separate nodes of the same High-Availability cluster.
// This hook receives events sent by a call to PublishPluginClusterEvent.
//
// Minimum server version: 5.36
OnPluginClusterEvent(c *Context, ev model.PluginClusterEvent)
// OnWebSocketConnect is invoked when a new websocket connection is opened.
//
// This is used to track which users have connections opened with the Mattermost
// websocket.
//
// Minimum server version: 6.0
OnWebSocketConnect(webConnID, userID string)
// OnWebSocketDisconnect is invoked when a websocket connection is closed.
//
// This is used to track which users have connections opened with the Mattermost
// websocket.
//
// Minimum server version: 6.0
OnWebSocketDisconnect(webConnID, userID string)
// WebSocketMessageHasBeenPosted is invoked when a websocket message is received.
//
// Minimum server version: 6.0
WebSocketMessageHasBeenPosted(webConnID, userID string, req *model.WebSocketRequest)
// RunDataRetention is invoked during a DataRetentionJob.
//
// Minimum server version: 6.4
RunDataRetention(nowTime, batchSize int64) (int64, error)
// OnInstall is invoked after the installation of a plugin as part of the onboarding.
// It's called on every installation, not only once.
//
// In the future, other plugin installation methods will trigger this hook, e.g. an installation via the Marketplace.
//
// Minimum server version: 6.5
OnInstall(c *Context, event model.OnInstallEvent) error
// OnSendDailyTelemetry is invoked when the server send the daily telemetry data.
//
// Minimum server version: 6.5
OnSendDailyTelemetry()
// OnCloudLimitsUpdated is invoked product limits change, for example when plan tiers change
//
// Minimum server version: 7.0
OnCloudLimitsUpdated(limits *model.ProductLimits)
// OnLicenseChanged is invoked when the server license changes, including
// upload, removal, expiry, and cluster-driven reloads. Either license may
// be nil (unlicensed ↔ licensed transitions).
//
// This hook is not invoked for the initial license load at startup. Plugins
// that need the current license when activated should call API.GetLicense()
// from OnActivate.
//
// Minimum server version: 12.0
OnLicenseChanged(oldLicense, newLicense *model.License)
// ConfigurationWillBeSaved is invoked before saving the configuration to the
// backing store.
// An error can be returned to reject the operation. Additionally, a new
// config object can be returned to be stored in place of the provided one.
// Minimum server version: 8.0
ConfigurationWillBeSaved(newCfg *model.Config) (*model.Config, error)
// EmailNotificationWillBeSent is invoked before an email notification is sent to a user.
// This allows plugins to customize the email notification content including subject,
// title, subtitle, message content, buttons, and other email properties.
//
// To reject an email notification, return an non-empty string describing why the notification was rejected.
// To modify the notification, return the replacement, non-nil *model.EmailNotificationContent and an empty string.
// To allow the notification without modification, return a nil *model.EmailNotificationContent and an empty string.
//
// Note that core identifiers (PostId, ChannelId, TeamId, SenderId, RecipientId, RootId) and
// context fields (ChannelType, IsDirectMessage, etc.) are immutable and changes to them will be ignored.
// Only customizable content fields can be modified.
//
// Minimum server version: 11.00
EmailNotificationWillBeSent(emailNotification *model.EmailNotification) (*model.EmailNotificationContent, string)
// NotificationWillBePushed is invoked before a push notification is sent to the push
// notification server.
//
// To reject a notification, return an non-empty string describing why the notification was rejected.
// To modify the notification, return the replacement, non-nil *model.PushNotification and an empty string.
// To allow the notification without modification, return a nil *model.PushNotification and an empty string.
//
// Note that this method will be called for push notifications created by plugins, including the plugin that
// created the notification.
//
// Minimum server version: 9.0
NotificationWillBePushed(pushNotification *model.PushNotification, userID string) (*model.PushNotification, string)
// UserHasBeenDeactivated is invoked when a user is deactivated.
//
// Minimum server version: 9.1
UserHasBeenDeactivated(c *Context, user *model.User)
// ServeMetrics allows plugins to expose their own metrics endpoint through
// the server's metrics HTTP listener (e.g. "localhost:8067").
// Requests destined to the /plugins/{id}/metrics path will be routed to the plugin.
//
// Minimum server version: 9.2
ServeMetrics(c *Context, w http.ResponseWriter, r *http.Request)
// OnSharedChannelsSyncMsg is invoked for plugins that wish to receive synchronization messages from the
// Shared Channels service for which they have been invited via InviteRemote. Each SyncMsg may contain
// multiple updates (posts, reactions, attachments, users) for a single channel.
//
// The cursor will be advanced based on the SyncResponse returned.
//
// Minimum server version: 9.5
OnSharedChannelsSyncMsg(msg *model.SyncMsg, rc *model.RemoteCluster) (model.SyncResponse, error)
// OnSharedChannelsPing is invoked for plugins to indicate the health of the plugin and the connection
// to the upstream service (e.g. MS Graph APIs).
//
// Return true to indicate all is well.
//
// Return false to indicate there is a problem with the plugin or connection to upstream service.
// Some number of failed pings will result in the plugin being marked offline and it will stop receiving
// OnSharedChannelsSyncMsg calls until it comes back online. The plugin will also appear offline in the status
// report via the `secure-connection status` slash command.
//
// Minimum server version: 9.5
OnSharedChannelsPing(rc *model.RemoteCluster) bool
// PreferencesHaveChanged is invoked after one or more of a user's preferences have changed.
// Note that this method will be called for preferences changed by plugins, including the plugin that changed
// the preferences.
//
// Minimum server version: 9.5
PreferencesHaveChanged(c *Context, preferences []model.Preference)
// OnSharedChannelsAttachmentSyncMsg is invoked for plugins that wish to receive synchronization messages from the
// Shared Channels service for which they have been invited via InviteRemote. Each call represents one file attachment
// to be synchronized.
//
// The cursor will be advanced based on the timestamp returned if no error is returned.
//
// Minimum server version: 9.5
OnSharedChannelsAttachmentSyncMsg(fi *model.FileInfo, post *model.Post, rc *model.RemoteCluster) error
// OnSharedChannelsProfileImageSyncMsg is invoked for plugins that wish to receive synchronization messages from the
// Shared Channels service for which they have been invited via InviteRemote. Each call represents one user profile
// image that should be synchronized. `App.GetProfileImage` can be used to fetch the image bytes.
//
// The cursor will be advanced based on the timestamp returned if no error is returned.
//
// Minimum server version: 9.5
OnSharedChannelsProfileImageSyncMsg(user *model.User, rc *model.RemoteCluster) error
// GenerateSupportData is invoked when a Support Packet gets generated.
// It allows plugins to include their own content in the Support Packet.
//
// Plugins may specififes a "support_packet" field in the manifest props with a custom text.
// By doing so, the plugin will be included in the Support Packet UI and the user will be able to select it.
// This hook will only be called, if the user selects the plugin in the Support Packet UI.
//
// If no "support_packet" is specified, this hook will always be called.
//
// Minimum server version: 9.8
GenerateSupportData(c *Context) ([]*model.FileData, error)
// OnSAMLLogin is invoked after a successful SAML login.
//
// Minimum server version: 10.7
OnSAMLLogin(c *Context, user *model.User, assertion *saml2.AssertionInfo) error
// ChannelWillBeUpdated is invoked before a channel update is committed, allowing plugins to
// modify the channel or reject the update.
//
// To reject the update, return a non-empty string describing why. To modify the channel, return
// the replacement *model.Channel and an empty string. To allow the update without modification,
// return nil and an empty string.
//
// Fires from the app-layer UpdateChannel and PatchChannel paths so REST, local API, plugin API,
// import, and bulk callers all hit it.
//
// Minimum server version: 11.9
ChannelWillBeUpdated(c *Context, newChannel, oldChannel *model.Channel) (*model.Channel, string)
// ChannelWillBeRestored is invoked before an archived channel is un-archived. Fires from
// app.RestoreChannel before the store's Channel().Restore call. Sibling of
// ChannelWillBeArchived for the inverse operation.
//
// To reject, return a non-empty string. Empty string allows the restore.
//
// Minimum server version: 11.9
ChannelWillBeRestored(c *Context, channel *model.Channel) string
// ScheduledPostWillBeCreated is invoked before a scheduled post is committed. Fires from the
// app-layer SaveScheduledPost and UpdateScheduledPost paths.
//
// Return value semantics match MessageWillBePosted.
//
// Minimum server version: 11.9
ScheduledPostWillBeCreated(c *Context, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, string)
// DraftWillBeUpserted is invoked before a draft is committed. Fires from the app-layer
// UpsertDraft path.
//
// Return value semantics match MessageWillBePosted.
//
// Minimum server version: 11.9
DraftWillBeUpserted(c *Context, draft *model.Draft) (*model.Draft, string)
}

View File

@@ -0,0 +1,672 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Code generated by "make pluginapi"
// DO NOT EDIT
package plugin
import (
"io"
"net/http"
timePkg "time"
saml2 "github.com/mattermost/gosaml2"
"github.com/mattermost/mattermost/server/public/model"
)
type hooksTimerLayer struct {
pluginID string
hooksImpl Hooks
hooksWithRPCErrImpl HooksWithRPCErr
metrics metricsInterface
}
func (hooks *hooksTimerLayer) recordTime(startTime timePkg.Time, name string, success bool) {
if hooks.metrics != nil {
elapsedTime := float64(timePkg.Since(startTime)) / float64(timePkg.Second)
hooks.metrics.ObservePluginHookDuration(hooks.pluginID, name, success, elapsedTime)
}
}
func (hooks *hooksTimerLayer) OnActivate() error {
startTime := timePkg.Now()
_returnsA := hooks.hooksImpl.OnActivate()
hooks.recordTime(startTime, "OnActivate", _returnsA == nil)
return _returnsA
}
func (hooks *hooksTimerLayer) Implemented() ([]string, error) {
startTime := timePkg.Now()
_returnsA, _returnsB := hooks.hooksImpl.Implemented()
hooks.recordTime(startTime, "Implemented", _returnsB == nil)
return _returnsA, _returnsB
}
func (hooks *hooksTimerLayer) OnDeactivate() error {
startTime := timePkg.Now()
_returnsA := hooks.hooksImpl.OnDeactivate()
hooks.recordTime(startTime, "OnDeactivate", _returnsA == nil)
return _returnsA
}
func (hooks *hooksTimerLayer) OnConfigurationChange() error {
startTime := timePkg.Now()
_returnsA := hooks.hooksImpl.OnConfigurationChange()
hooks.recordTime(startTime, "OnConfigurationChange", _returnsA == nil)
return _returnsA
}
func (hooks *hooksTimerLayer) ServeHTTP(c *Context, w http.ResponseWriter, r *http.Request) {
startTime := timePkg.Now()
hooks.hooksImpl.ServeHTTP(c, w, r)
hooks.recordTime(startTime, "ServeHTTP", true)
}
func (hooks *hooksTimerLayer) ExecuteCommand(c *Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
startTime := timePkg.Now()
_returnsA, _returnsB := hooks.hooksImpl.ExecuteCommand(c, args)
hooks.recordTime(startTime, "ExecuteCommand", _returnsB == nil)
return _returnsA, _returnsB
}
func (hooks *hooksTimerLayer) UserHasBeenCreated(c *Context, user *model.User) {
startTime := timePkg.Now()
hooks.hooksImpl.UserHasBeenCreated(c, user)
hooks.recordTime(startTime, "UserHasBeenCreated", true)
}
func (hooks *hooksTimerLayer) UserWillLogIn(c *Context, user *model.User) string {
startTime := timePkg.Now()
_returnsA := hooks.hooksImpl.UserWillLogIn(c, user)
hooks.recordTime(startTime, "UserWillLogIn", true)
return _returnsA
}
func (hooks *hooksTimerLayer) UserHasLoggedIn(c *Context, user *model.User) {
startTime := timePkg.Now()
hooks.hooksImpl.UserHasLoggedIn(c, user)
hooks.recordTime(startTime, "UserHasLoggedIn", true)
}
func (hooks *hooksTimerLayer) MessageWillBePosted(c *Context, post *model.Post) (*model.Post, string) {
startTime := timePkg.Now()
_returnsA, _returnsB := hooks.hooksImpl.MessageWillBePosted(c, post)
hooks.recordTime(startTime, "MessageWillBePosted", true)
return _returnsA, _returnsB
}
func (hooks *hooksTimerLayer) MessageWillBeUpdated(c *Context, newPost, oldPost *model.Post) (*model.Post, string) {
startTime := timePkg.Now()
_returnsA, _returnsB := hooks.hooksImpl.MessageWillBeUpdated(c, newPost, oldPost)
hooks.recordTime(startTime, "MessageWillBeUpdated", true)
return _returnsA, _returnsB
}
func (hooks *hooksTimerLayer) MessageHasBeenPosted(c *Context, post *model.Post) {
startTime := timePkg.Now()
hooks.hooksImpl.MessageHasBeenPosted(c, post)
hooks.recordTime(startTime, "MessageHasBeenPosted", true)
}
func (hooks *hooksTimerLayer) MessageHasBeenUpdated(c *Context, newPost, oldPost *model.Post) {
startTime := timePkg.Now()
hooks.hooksImpl.MessageHasBeenUpdated(c, newPost, oldPost)
hooks.recordTime(startTime, "MessageHasBeenUpdated", true)
}
func (hooks *hooksTimerLayer) MessagesWillBeConsumed(posts []*model.Post) []*model.Post {
startTime := timePkg.Now()
_returnsA := hooks.hooksImpl.MessagesWillBeConsumed(posts)
hooks.recordTime(startTime, "MessagesWillBeConsumed", true)
return _returnsA
}
func (hooks *hooksTimerLayer) MessagesWillBeConsumedWithContext(c *Context, posts []*model.Post) []*model.Post {
startTime := timePkg.Now()
_returnsA := hooks.hooksImpl.MessagesWillBeConsumedWithContext(c, posts)
hooks.recordTime(startTime, "MessagesWillBeConsumedWithContext", true)
return _returnsA
}
func (hooks *hooksTimerLayer) MessageHasBeenDeleted(c *Context, post *model.Post) {
startTime := timePkg.Now()
hooks.hooksImpl.MessageHasBeenDeleted(c, post)
hooks.recordTime(startTime, "MessageHasBeenDeleted", true)
}
func (hooks *hooksTimerLayer) ChannelHasBeenCreated(c *Context, channel *model.Channel) {
startTime := timePkg.Now()
hooks.hooksImpl.ChannelHasBeenCreated(c, channel)
hooks.recordTime(startTime, "ChannelHasBeenCreated", true)
}
func (hooks *hooksTimerLayer) ChannelWillBeArchived(c *Context, channel *model.Channel) string {
startTime := timePkg.Now()
_returnsA := hooks.hooksImpl.ChannelWillBeArchived(c, channel)
hooks.recordTime(startTime, "ChannelWillBeArchived", true)
return _returnsA
}
func (hooks *hooksTimerLayer) ChannelMemberWillBeAdded(c *Context, channelMember *model.ChannelMember) (*model.ChannelMember, string) {
startTime := timePkg.Now()
_returnsA, _returnsB := hooks.hooksImpl.ChannelMemberWillBeAdded(c, channelMember)
hooks.recordTime(startTime, "ChannelMemberWillBeAdded", true)
return _returnsA, _returnsB
}
func (hooks *hooksTimerLayer) UserHasJoinedChannel(c *Context, channelMember *model.ChannelMember, actor *model.User) {
startTime := timePkg.Now()
hooks.hooksImpl.UserHasJoinedChannel(c, channelMember, actor)
hooks.recordTime(startTime, "UserHasJoinedChannel", true)
}
func (hooks *hooksTimerLayer) UserHasLeftChannel(c *Context, channelMember *model.ChannelMember, actor *model.User) {
startTime := timePkg.Now()
hooks.hooksImpl.UserHasLeftChannel(c, channelMember, actor)
hooks.recordTime(startTime, "UserHasLeftChannel", true)
}
func (hooks *hooksTimerLayer) TeamMemberWillBeAdded(c *Context, teamMember *model.TeamMember) (*model.TeamMember, string) {
startTime := timePkg.Now()
_returnsA, _returnsB := hooks.hooksImpl.TeamMemberWillBeAdded(c, teamMember)
hooks.recordTime(startTime, "TeamMemberWillBeAdded", true)
return _returnsA, _returnsB
}
func (hooks *hooksTimerLayer) UserHasJoinedTeam(c *Context, teamMember *model.TeamMember, actor *model.User) {
startTime := timePkg.Now()
hooks.hooksImpl.UserHasJoinedTeam(c, teamMember, actor)
hooks.recordTime(startTime, "UserHasJoinedTeam", true)
}
func (hooks *hooksTimerLayer) UserHasLeftTeam(c *Context, teamMember *model.TeamMember, actor *model.User) {
startTime := timePkg.Now()
hooks.hooksImpl.UserHasLeftTeam(c, teamMember, actor)
hooks.recordTime(startTime, "UserHasLeftTeam", true)
}
func (hooks *hooksTimerLayer) FileWillBeUploaded(c *Context, info *model.FileInfo, file io.Reader, output io.Writer) (*model.FileInfo, string) {
startTime := timePkg.Now()
_returnsA, _returnsB := hooks.hooksImpl.FileWillBeUploaded(c, info, file, output)
hooks.recordTime(startTime, "FileWillBeUploaded", true)
return _returnsA, _returnsB
}
func (hooks *hooksTimerLayer) FileWillBeDownloaded(c *Context, fileInfo *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
startTime := timePkg.Now()
_returnsA := hooks.hooksImpl.FileWillBeDownloaded(c, fileInfo, userID, downloadType)
hooks.recordTime(startTime, "FileWillBeDownloaded", true)
return _returnsA
}
func (hooks *hooksTimerLayer) ReactionHasBeenAdded(c *Context, reaction *model.Reaction) {
startTime := timePkg.Now()
hooks.hooksImpl.ReactionHasBeenAdded(c, reaction)
hooks.recordTime(startTime, "ReactionHasBeenAdded", true)
}
func (hooks *hooksTimerLayer) ReactionHasBeenRemoved(c *Context, reaction *model.Reaction) {
startTime := timePkg.Now()
hooks.hooksImpl.ReactionHasBeenRemoved(c, reaction)
hooks.recordTime(startTime, "ReactionHasBeenRemoved", true)
}
func (hooks *hooksTimerLayer) OnPluginClusterEvent(c *Context, ev model.PluginClusterEvent) {
startTime := timePkg.Now()
hooks.hooksImpl.OnPluginClusterEvent(c, ev)
hooks.recordTime(startTime, "OnPluginClusterEvent", true)
}
func (hooks *hooksTimerLayer) OnWebSocketConnect(webConnID, userID string) {
startTime := timePkg.Now()
hooks.hooksImpl.OnWebSocketConnect(webConnID, userID)
hooks.recordTime(startTime, "OnWebSocketConnect", true)
}
func (hooks *hooksTimerLayer) OnWebSocketDisconnect(webConnID, userID string) {
startTime := timePkg.Now()
hooks.hooksImpl.OnWebSocketDisconnect(webConnID, userID)
hooks.recordTime(startTime, "OnWebSocketDisconnect", true)
}
func (hooks *hooksTimerLayer) WebSocketMessageHasBeenPosted(webConnID, userID string, req *model.WebSocketRequest) {
startTime := timePkg.Now()
hooks.hooksImpl.WebSocketMessageHasBeenPosted(webConnID, userID, req)
hooks.recordTime(startTime, "WebSocketMessageHasBeenPosted", true)
}
func (hooks *hooksTimerLayer) RunDataRetention(nowTime, batchSize int64) (int64, error) {
startTime := timePkg.Now()
_returnsA, _returnsB := hooks.hooksImpl.RunDataRetention(nowTime, batchSize)
hooks.recordTime(startTime, "RunDataRetention", _returnsB == nil)
return _returnsA, _returnsB
}
func (hooks *hooksTimerLayer) OnInstall(c *Context, event model.OnInstallEvent) error {
startTime := timePkg.Now()
_returnsA := hooks.hooksImpl.OnInstall(c, event)
hooks.recordTime(startTime, "OnInstall", _returnsA == nil)
return _returnsA
}
func (hooks *hooksTimerLayer) OnSendDailyTelemetry() {
startTime := timePkg.Now()
hooks.hooksImpl.OnSendDailyTelemetry()
hooks.recordTime(startTime, "OnSendDailyTelemetry", true)
}
func (hooks *hooksTimerLayer) OnCloudLimitsUpdated(limits *model.ProductLimits) {
startTime := timePkg.Now()
hooks.hooksImpl.OnCloudLimitsUpdated(limits)
hooks.recordTime(startTime, "OnCloudLimitsUpdated", true)
}
func (hooks *hooksTimerLayer) OnLicenseChanged(oldLicense, newLicense *model.License) {
startTime := timePkg.Now()
hooks.hooksImpl.OnLicenseChanged(oldLicense, newLicense)
hooks.recordTime(startTime, "OnLicenseChanged", true)
}
func (hooks *hooksTimerLayer) ConfigurationWillBeSaved(newCfg *model.Config) (*model.Config, error) {
startTime := timePkg.Now()
_returnsA, _returnsB := hooks.hooksImpl.ConfigurationWillBeSaved(newCfg)
hooks.recordTime(startTime, "ConfigurationWillBeSaved", _returnsB == nil)
return _returnsA, _returnsB
}
func (hooks *hooksTimerLayer) EmailNotificationWillBeSent(emailNotification *model.EmailNotification) (*model.EmailNotificationContent, string) {
startTime := timePkg.Now()
_returnsA, _returnsB := hooks.hooksImpl.EmailNotificationWillBeSent(emailNotification)
hooks.recordTime(startTime, "EmailNotificationWillBeSent", true)
return _returnsA, _returnsB
}
func (hooks *hooksTimerLayer) NotificationWillBePushed(pushNotification *model.PushNotification, userID string) (*model.PushNotification, string) {
startTime := timePkg.Now()
_returnsA, _returnsB := hooks.hooksImpl.NotificationWillBePushed(pushNotification, userID)
hooks.recordTime(startTime, "NotificationWillBePushed", true)
return _returnsA, _returnsB
}
func (hooks *hooksTimerLayer) UserHasBeenDeactivated(c *Context, user *model.User) {
startTime := timePkg.Now()
hooks.hooksImpl.UserHasBeenDeactivated(c, user)
hooks.recordTime(startTime, "UserHasBeenDeactivated", true)
}
func (hooks *hooksTimerLayer) ServeMetrics(c *Context, w http.ResponseWriter, r *http.Request) {
startTime := timePkg.Now()
hooks.hooksImpl.ServeMetrics(c, w, r)
hooks.recordTime(startTime, "ServeMetrics", true)
}
func (hooks *hooksTimerLayer) OnSharedChannelsSyncMsg(msg *model.SyncMsg, rc *model.RemoteCluster) (model.SyncResponse, error) {
startTime := timePkg.Now()
_returnsA, _returnsB := hooks.hooksImpl.OnSharedChannelsSyncMsg(msg, rc)
hooks.recordTime(startTime, "OnSharedChannelsSyncMsg", _returnsB == nil)
return _returnsA, _returnsB
}
func (hooks *hooksTimerLayer) OnSharedChannelsPing(rc *model.RemoteCluster) bool {
startTime := timePkg.Now()
_returnsA := hooks.hooksImpl.OnSharedChannelsPing(rc)
hooks.recordTime(startTime, "OnSharedChannelsPing", true)
return _returnsA
}
func (hooks *hooksTimerLayer) PreferencesHaveChanged(c *Context, preferences []model.Preference) {
startTime := timePkg.Now()
hooks.hooksImpl.PreferencesHaveChanged(c, preferences)
hooks.recordTime(startTime, "PreferencesHaveChanged", true)
}
func (hooks *hooksTimerLayer) OnSharedChannelsAttachmentSyncMsg(fi *model.FileInfo, post *model.Post, rc *model.RemoteCluster) error {
startTime := timePkg.Now()
_returnsA := hooks.hooksImpl.OnSharedChannelsAttachmentSyncMsg(fi, post, rc)
hooks.recordTime(startTime, "OnSharedChannelsAttachmentSyncMsg", _returnsA == nil)
return _returnsA
}
func (hooks *hooksTimerLayer) OnSharedChannelsProfileImageSyncMsg(user *model.User, rc *model.RemoteCluster) error {
startTime := timePkg.Now()
_returnsA := hooks.hooksImpl.OnSharedChannelsProfileImageSyncMsg(user, rc)
hooks.recordTime(startTime, "OnSharedChannelsProfileImageSyncMsg", _returnsA == nil)
return _returnsA
}
func (hooks *hooksTimerLayer) GenerateSupportData(c *Context) ([]*model.FileData, error) {
startTime := timePkg.Now()
_returnsA, _returnsB := hooks.hooksImpl.GenerateSupportData(c)
hooks.recordTime(startTime, "GenerateSupportData", _returnsB == nil)
return _returnsA, _returnsB
}
func (hooks *hooksTimerLayer) OnSAMLLogin(c *Context, user *model.User, assertion *saml2.AssertionInfo) error {
startTime := timePkg.Now()
_returnsA := hooks.hooksImpl.OnSAMLLogin(c, user, assertion)
hooks.recordTime(startTime, "OnSAMLLogin", _returnsA == nil)
return _returnsA
}
func (hooks *hooksTimerLayer) ChannelWillBeUpdated(c *Context, newChannel, oldChannel *model.Channel) (*model.Channel, string) {
startTime := timePkg.Now()
_returnsA, _returnsB := hooks.hooksImpl.ChannelWillBeUpdated(c, newChannel, oldChannel)
hooks.recordTime(startTime, "ChannelWillBeUpdated", true)
return _returnsA, _returnsB
}
func (hooks *hooksTimerLayer) ChannelWillBeRestored(c *Context, channel *model.Channel) string {
startTime := timePkg.Now()
_returnsA := hooks.hooksImpl.ChannelWillBeRestored(c, channel)
hooks.recordTime(startTime, "ChannelWillBeRestored", true)
return _returnsA
}
func (hooks *hooksTimerLayer) ScheduledPostWillBeCreated(c *Context, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, string) {
startTime := timePkg.Now()
_returnsA, _returnsB := hooks.hooksImpl.ScheduledPostWillBeCreated(c, scheduledPost)
hooks.recordTime(startTime, "ScheduledPostWillBeCreated", true)
return _returnsA, _returnsB
}
func (hooks *hooksTimerLayer) DraftWillBeUpserted(c *Context, draft *model.Draft) (*model.Draft, string) {
startTime := timePkg.Now()
_returnsA, _returnsB := hooks.hooksImpl.DraftWillBeUpserted(c, draft)
hooks.recordTime(startTime, "DraftWillBeUpserted", true)
return _returnsA, _returnsB
}
func (hooks *hooksTimerLayer) OnDeactivateWithRPCErr() (error, error) {
startTime := timePkg.Now()
_returnsA, _returnsRPCErr := hooks.hooksWithRPCErrImpl.OnDeactivateWithRPCErr()
hooks.recordTime(startTime, "OnDeactivateWithRPCErr", _returnsRPCErr == nil && _returnsA == nil)
return _returnsA, _returnsRPCErr
}
func (hooks *hooksTimerLayer) OnConfigurationChangeWithRPCErr() (error, error) {
startTime := timePkg.Now()
_returnsA, _returnsRPCErr := hooks.hooksWithRPCErrImpl.OnConfigurationChangeWithRPCErr()
hooks.recordTime(startTime, "OnConfigurationChangeWithRPCErr", _returnsRPCErr == nil && _returnsA == nil)
return _returnsA, _returnsRPCErr
}
func (hooks *hooksTimerLayer) ExecuteCommandWithRPCErr(c *Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError, error) {
startTime := timePkg.Now()
_returnsA, _returnsB, _returnsRPCErr := hooks.hooksWithRPCErrImpl.ExecuteCommandWithRPCErr(c, args)
hooks.recordTime(startTime, "ExecuteCommandWithRPCErr", _returnsRPCErr == nil && _returnsB == nil)
return _returnsA, _returnsB, _returnsRPCErr
}
func (hooks *hooksTimerLayer) UserHasBeenCreatedWithRPCErr(c *Context, user *model.User) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.UserHasBeenCreatedWithRPCErr(c, user)
hooks.recordTime(startTime, "UserHasBeenCreatedWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) UserWillLogInWithRPCErr(c *Context, user *model.User) (string, error) {
startTime := timePkg.Now()
_returnsA, _returnsRPCErr := hooks.hooksWithRPCErrImpl.UserWillLogInWithRPCErr(c, user)
hooks.recordTime(startTime, "UserWillLogInWithRPCErr", _returnsRPCErr == nil)
return _returnsA, _returnsRPCErr
}
func (hooks *hooksTimerLayer) UserHasLoggedInWithRPCErr(c *Context, user *model.User) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.UserHasLoggedInWithRPCErr(c, user)
hooks.recordTime(startTime, "UserHasLoggedInWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) MessageHasBeenPostedWithRPCErr(c *Context, post *model.Post) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.MessageHasBeenPostedWithRPCErr(c, post)
hooks.recordTime(startTime, "MessageHasBeenPostedWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) MessageHasBeenUpdatedWithRPCErr(c *Context, newPost, oldPost *model.Post) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.MessageHasBeenUpdatedWithRPCErr(c, newPost, oldPost)
hooks.recordTime(startTime, "MessageHasBeenUpdatedWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) MessageHasBeenDeletedWithRPCErr(c *Context, post *model.Post) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.MessageHasBeenDeletedWithRPCErr(c, post)
hooks.recordTime(startTime, "MessageHasBeenDeletedWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) ChannelHasBeenCreatedWithRPCErr(c *Context, channel *model.Channel) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.ChannelHasBeenCreatedWithRPCErr(c, channel)
hooks.recordTime(startTime, "ChannelHasBeenCreatedWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) ChannelWillBeArchivedWithRPCErr(c *Context, channel *model.Channel) (string, error) {
startTime := timePkg.Now()
_returnsA, _returnsRPCErr := hooks.hooksWithRPCErrImpl.ChannelWillBeArchivedWithRPCErr(c, channel)
hooks.recordTime(startTime, "ChannelWillBeArchivedWithRPCErr", _returnsRPCErr == nil)
return _returnsA, _returnsRPCErr
}
func (hooks *hooksTimerLayer) UserHasJoinedChannelWithRPCErr(c *Context, channelMember *model.ChannelMember, actor *model.User) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.UserHasJoinedChannelWithRPCErr(c, channelMember, actor)
hooks.recordTime(startTime, "UserHasJoinedChannelWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) UserHasLeftChannelWithRPCErr(c *Context, channelMember *model.ChannelMember, actor *model.User) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.UserHasLeftChannelWithRPCErr(c, channelMember, actor)
hooks.recordTime(startTime, "UserHasLeftChannelWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) UserHasJoinedTeamWithRPCErr(c *Context, teamMember *model.TeamMember, actor *model.User) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.UserHasJoinedTeamWithRPCErr(c, teamMember, actor)
hooks.recordTime(startTime, "UserHasJoinedTeamWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) UserHasLeftTeamWithRPCErr(c *Context, teamMember *model.TeamMember, actor *model.User) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.UserHasLeftTeamWithRPCErr(c, teamMember, actor)
hooks.recordTime(startTime, "UserHasLeftTeamWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) FileWillBeDownloadedWithRPCErr(c *Context, fileInfo *model.FileInfo, userID string, downloadType model.FileDownloadType) (string, error) {
startTime := timePkg.Now()
_returnsA, _returnsRPCErr := hooks.hooksWithRPCErrImpl.FileWillBeDownloadedWithRPCErr(c, fileInfo, userID, downloadType)
hooks.recordTime(startTime, "FileWillBeDownloadedWithRPCErr", _returnsRPCErr == nil)
return _returnsA, _returnsRPCErr
}
func (hooks *hooksTimerLayer) ReactionHasBeenAddedWithRPCErr(c *Context, reaction *model.Reaction) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.ReactionHasBeenAddedWithRPCErr(c, reaction)
hooks.recordTime(startTime, "ReactionHasBeenAddedWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) ReactionHasBeenRemovedWithRPCErr(c *Context, reaction *model.Reaction) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.ReactionHasBeenRemovedWithRPCErr(c, reaction)
hooks.recordTime(startTime, "ReactionHasBeenRemovedWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) OnPluginClusterEventWithRPCErr(c *Context, ev model.PluginClusterEvent) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.OnPluginClusterEventWithRPCErr(c, ev)
hooks.recordTime(startTime, "OnPluginClusterEventWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) OnWebSocketConnectWithRPCErr(webConnID, userID string) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.OnWebSocketConnectWithRPCErr(webConnID, userID)
hooks.recordTime(startTime, "OnWebSocketConnectWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) OnWebSocketDisconnectWithRPCErr(webConnID, userID string) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.OnWebSocketDisconnectWithRPCErr(webConnID, userID)
hooks.recordTime(startTime, "OnWebSocketDisconnectWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) WebSocketMessageHasBeenPostedWithRPCErr(webConnID, userID string, req *model.WebSocketRequest) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.WebSocketMessageHasBeenPostedWithRPCErr(webConnID, userID, req)
hooks.recordTime(startTime, "WebSocketMessageHasBeenPostedWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) RunDataRetentionWithRPCErr(nowTime, batchSize int64) (int64, error, error) {
startTime := timePkg.Now()
_returnsA, _returnsB, _returnsRPCErr := hooks.hooksWithRPCErrImpl.RunDataRetentionWithRPCErr(nowTime, batchSize)
hooks.recordTime(startTime, "RunDataRetentionWithRPCErr", _returnsRPCErr == nil && _returnsB == nil)
return _returnsA, _returnsB, _returnsRPCErr
}
func (hooks *hooksTimerLayer) OnInstallWithRPCErr(c *Context, event model.OnInstallEvent) (error, error) {
startTime := timePkg.Now()
_returnsA, _returnsRPCErr := hooks.hooksWithRPCErrImpl.OnInstallWithRPCErr(c, event)
hooks.recordTime(startTime, "OnInstallWithRPCErr", _returnsRPCErr == nil && _returnsA == nil)
return _returnsA, _returnsRPCErr
}
func (hooks *hooksTimerLayer) OnSendDailyTelemetryWithRPCErr() error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.OnSendDailyTelemetryWithRPCErr()
hooks.recordTime(startTime, "OnSendDailyTelemetryWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) OnCloudLimitsUpdatedWithRPCErr(limits *model.ProductLimits) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.OnCloudLimitsUpdatedWithRPCErr(limits)
hooks.recordTime(startTime, "OnCloudLimitsUpdatedWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) OnLicenseChangedWithRPCErr(oldLicense, newLicense *model.License) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.OnLicenseChangedWithRPCErr(oldLicense, newLicense)
hooks.recordTime(startTime, "OnLicenseChangedWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) ConfigurationWillBeSavedWithRPCErr(newCfg *model.Config) (*model.Config, error, error) {
startTime := timePkg.Now()
_returnsA, _returnsB, _returnsRPCErr := hooks.hooksWithRPCErrImpl.ConfigurationWillBeSavedWithRPCErr(newCfg)
hooks.recordTime(startTime, "ConfigurationWillBeSavedWithRPCErr", _returnsRPCErr == nil && _returnsB == nil)
return _returnsA, _returnsB, _returnsRPCErr
}
func (hooks *hooksTimerLayer) EmailNotificationWillBeSentWithRPCErr(emailNotification *model.EmailNotification) (*model.EmailNotificationContent, string, error) {
startTime := timePkg.Now()
_returnsA, _returnsB, _returnsRPCErr := hooks.hooksWithRPCErrImpl.EmailNotificationWillBeSentWithRPCErr(emailNotification)
hooks.recordTime(startTime, "EmailNotificationWillBeSentWithRPCErr", _returnsRPCErr == nil)
return _returnsA, _returnsB, _returnsRPCErr
}
func (hooks *hooksTimerLayer) NotificationWillBePushedWithRPCErr(pushNotification *model.PushNotification, userID string) (*model.PushNotification, string, error) {
startTime := timePkg.Now()
_returnsA, _returnsB, _returnsRPCErr := hooks.hooksWithRPCErrImpl.NotificationWillBePushedWithRPCErr(pushNotification, userID)
hooks.recordTime(startTime, "NotificationWillBePushedWithRPCErr", _returnsRPCErr == nil)
return _returnsA, _returnsB, _returnsRPCErr
}
func (hooks *hooksTimerLayer) UserHasBeenDeactivatedWithRPCErr(c *Context, user *model.User) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.UserHasBeenDeactivatedWithRPCErr(c, user)
hooks.recordTime(startTime, "UserHasBeenDeactivatedWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) OnSharedChannelsSyncMsgWithRPCErr(msg *model.SyncMsg, rc *model.RemoteCluster) (model.SyncResponse, error, error) {
startTime := timePkg.Now()
_returnsA, _returnsB, _returnsRPCErr := hooks.hooksWithRPCErrImpl.OnSharedChannelsSyncMsgWithRPCErr(msg, rc)
hooks.recordTime(startTime, "OnSharedChannelsSyncMsgWithRPCErr", _returnsRPCErr == nil && _returnsB == nil)
return _returnsA, _returnsB, _returnsRPCErr
}
func (hooks *hooksTimerLayer) OnSharedChannelsPingWithRPCErr(rc *model.RemoteCluster) (bool, error) {
startTime := timePkg.Now()
_returnsA, _returnsRPCErr := hooks.hooksWithRPCErrImpl.OnSharedChannelsPingWithRPCErr(rc)
hooks.recordTime(startTime, "OnSharedChannelsPingWithRPCErr", _returnsRPCErr == nil)
return _returnsA, _returnsRPCErr
}
func (hooks *hooksTimerLayer) PreferencesHaveChangedWithRPCErr(c *Context, preferences []model.Preference) error {
startTime := timePkg.Now()
_returnsRPCErr := hooks.hooksWithRPCErrImpl.PreferencesHaveChangedWithRPCErr(c, preferences)
hooks.recordTime(startTime, "PreferencesHaveChangedWithRPCErr", _returnsRPCErr == nil)
return _returnsRPCErr
}
func (hooks *hooksTimerLayer) OnSharedChannelsAttachmentSyncMsgWithRPCErr(fi *model.FileInfo, post *model.Post, rc *model.RemoteCluster) (error, error) {
startTime := timePkg.Now()
_returnsA, _returnsRPCErr := hooks.hooksWithRPCErrImpl.OnSharedChannelsAttachmentSyncMsgWithRPCErr(fi, post, rc)
hooks.recordTime(startTime, "OnSharedChannelsAttachmentSyncMsgWithRPCErr", _returnsRPCErr == nil && _returnsA == nil)
return _returnsA, _returnsRPCErr
}
func (hooks *hooksTimerLayer) OnSharedChannelsProfileImageSyncMsgWithRPCErr(user *model.User, rc *model.RemoteCluster) (error, error) {
startTime := timePkg.Now()
_returnsA, _returnsRPCErr := hooks.hooksWithRPCErrImpl.OnSharedChannelsProfileImageSyncMsgWithRPCErr(user, rc)
hooks.recordTime(startTime, "OnSharedChannelsProfileImageSyncMsgWithRPCErr", _returnsRPCErr == nil && _returnsA == nil)
return _returnsA, _returnsRPCErr
}
func (hooks *hooksTimerLayer) GenerateSupportDataWithRPCErr(c *Context) ([]*model.FileData, error, error) {
startTime := timePkg.Now()
_returnsA, _returnsB, _returnsRPCErr := hooks.hooksWithRPCErrImpl.GenerateSupportDataWithRPCErr(c)
hooks.recordTime(startTime, "GenerateSupportDataWithRPCErr", _returnsRPCErr == nil && _returnsB == nil)
return _returnsA, _returnsB, _returnsRPCErr
}
func (hooks *hooksTimerLayer) OnSAMLLoginWithRPCErr(c *Context, user *model.User, assertion *saml2.AssertionInfo) (error, error) {
startTime := timePkg.Now()
_returnsA, _returnsRPCErr := hooks.hooksWithRPCErrImpl.OnSAMLLoginWithRPCErr(c, user, assertion)
hooks.recordTime(startTime, "OnSAMLLoginWithRPCErr", _returnsRPCErr == nil && _returnsA == nil)
return _returnsA, _returnsRPCErr
}
func (hooks *hooksTimerLayer) ChannelWillBeUpdatedWithRPCErr(c *Context, newChannel, oldChannel *model.Channel) (*model.Channel, string, error) {
startTime := timePkg.Now()
_returnsA, _returnsB, _returnsRPCErr := hooks.hooksWithRPCErrImpl.ChannelWillBeUpdatedWithRPCErr(c, newChannel, oldChannel)
hooks.recordTime(startTime, "ChannelWillBeUpdatedWithRPCErr", _returnsRPCErr == nil)
return _returnsA, _returnsB, _returnsRPCErr
}
func (hooks *hooksTimerLayer) ChannelWillBeRestoredWithRPCErr(c *Context, channel *model.Channel) (string, error) {
startTime := timePkg.Now()
_returnsA, _returnsRPCErr := hooks.hooksWithRPCErrImpl.ChannelWillBeRestoredWithRPCErr(c, channel)
hooks.recordTime(startTime, "ChannelWillBeRestoredWithRPCErr", _returnsRPCErr == nil)
return _returnsA, _returnsRPCErr
}
func (hooks *hooksTimerLayer) ScheduledPostWillBeCreatedWithRPCErr(c *Context, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, string, error) {
startTime := timePkg.Now()
_returnsA, _returnsB, _returnsRPCErr := hooks.hooksWithRPCErrImpl.ScheduledPostWillBeCreatedWithRPCErr(c, scheduledPost)
hooks.recordTime(startTime, "ScheduledPostWillBeCreatedWithRPCErr", _returnsRPCErr == nil)
return _returnsA, _returnsB, _returnsRPCErr
}
func (hooks *hooksTimerLayer) DraftWillBeUpsertedWithRPCErr(c *Context, draft *model.Draft) (*model.Draft, string, error) {
startTime := timePkg.Now()
_returnsA, _returnsB, _returnsRPCErr := hooks.hooksWithRPCErrImpl.DraftWillBeUpsertedWithRPCErr(c, draft)
hooks.recordTime(startTime, "DraftWillBeUpsertedWithRPCErr", _returnsRPCErr == nil)
return _returnsA, _returnsB, _returnsRPCErr
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Hand-written timer-layer wrappers for the three hooks excluded from the code generator.
// The auto-generated hooks_timer_layer_generated.go ranges over HooksMethodsRPCErr which
// omits excluded hooks; these three fill that gap so hooksTimerLayer satisfies HooksWithRPCErr.
package plugin
import (
timePkg "time"
"github.com/mattermost/mattermost/server/public/model"
)
// MessageWillBePostedWithRPCErr wraps the underlying implementation's MessageWillBePostedWithRPCErr
// and records timing metrics.
func (hooks *hooksTimerLayer) MessageWillBePostedWithRPCErr(c *Context, post *model.Post) (*model.Post, string, error) {
startTime := timePkg.Now()
_returnsA, _returnsB, _returnsRPCErr := hooks.hooksWithRPCErrImpl.MessageWillBePostedWithRPCErr(c, post)
hooks.recordTime(startTime, "MessageWillBePostedWithRPCErr", _returnsRPCErr == nil)
return _returnsA, _returnsB, _returnsRPCErr
}
// MessageWillBeUpdatedWithRPCErr wraps the underlying implementation's MessageWillBeUpdatedWithRPCErr
// and records timing metrics.
func (hooks *hooksTimerLayer) MessageWillBeUpdatedWithRPCErr(c *Context, newPost, oldPost *model.Post) (*model.Post, string, error) {
startTime := timePkg.Now()
_returnsA, _returnsB, _returnsRPCErr := hooks.hooksWithRPCErrImpl.MessageWillBeUpdatedWithRPCErr(c, newPost, oldPost)
hooks.recordTime(startTime, "MessageWillBeUpdatedWithRPCErr", _returnsRPCErr == nil)
return _returnsA, _returnsB, _returnsRPCErr
}
// ChannelMemberWillBeAddedWithRPCErr wraps the underlying implementation's ChannelMemberWillBeAddedWithRPCErr
// and records timing metrics.
func (hooks *hooksTimerLayer) ChannelMemberWillBeAddedWithRPCErr(c *Context, channelMember *model.ChannelMember) (*model.ChannelMember, string, error) {
startTime := timePkg.Now()
_returnsA, _returnsB, _returnsRPCErr := hooks.hooksWithRPCErrImpl.ChannelMemberWillBeAddedWithRPCErr(c, channelMember)
hooks.recordTime(startTime, "ChannelMemberWillBeAddedWithRPCErr", _returnsRPCErr == nil)
return _returnsA, _returnsB, _returnsRPCErr
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"bufio"
"errors"
"fmt"
"io"
"maps"
"net"
"net/http"
"net/rpc"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
type hijackedResponse struct {
conn net.Conn
bufrw *bufio.ReadWriter
readBuf []byte
}
type httpResponseWriterRPCServer struct {
w http.ResponseWriter
log *mlog.Logger
hjr *hijackedResponse
}
func (w *httpResponseWriterRPCServer) Header(args struct{}, reply *http.Header) error {
*reply = w.w.Header()
return nil
}
func (w *httpResponseWriterRPCServer) Write(args []byte, reply *struct{}) error {
_, err := w.w.Write(args)
return err
}
func (w *httpResponseWriterRPCServer) WriteHeader(args int, reply *struct{}) error {
// Check if args is a valid http status code. This prevents plugins from crashing the server with a panic.
// This is a copy of the checkWriteHeaderCode function in net/http/server.go in the go source.
if args < 100 || args > 999 {
w.log.Error(fmt.Sprintf("Plugin tried to write an invalid http status code: %v. Did not write the invalid header.", args))
return errors.New("invalid http status code")
}
w.w.WriteHeader(args)
return nil
}
func (w *httpResponseWriterRPCServer) SyncHeader(args http.Header, reply *struct{}) error {
dest := w.w.Header()
for k := range dest {
if _, ok := args[k]; !ok {
delete(dest, k)
}
}
maps.Copy(dest, args)
return nil
}
func (w *httpResponseWriterRPCServer) Flush(args struct{}, reply *struct{}) error {
// Type assert to http.Flusher and flush if supported
if flusher, ok := w.w.(http.Flusher); ok {
flusher.Flush()
}
// If the underlying writer doesn't support Flusher, silently ignore
// This matches the HTTP spec's "best effort" semantics
return nil
}
type httpResponseWriterRPCClient struct {
client *rpc.Client
header http.Header
}
var _ http.ResponseWriter = (*httpResponseWriterRPCClient)(nil)
var _ http.Flusher = (*httpResponseWriterRPCClient)(nil)
func (w *httpResponseWriterRPCClient) Header() http.Header {
if w.header == nil {
w.client.Call("Plugin.Header", struct{}{}, &w.header)
}
return w.header
}
func (w *httpResponseWriterRPCClient) Write(b []byte) (int, error) {
if err := w.client.Call("Plugin.SyncHeader", w.header, nil); err != nil {
return 0, err
}
if err := w.client.Call("Plugin.Write", b, nil); err != nil {
return 0, err
}
return len(b), nil
}
func (w *httpResponseWriterRPCClient) WriteHeader(statusCode int) {
if err := w.client.Call("Plugin.SyncHeader", w.header, nil); err != nil {
return
}
w.client.Call("Plugin.WriteHeader", statusCode, nil)
}
// Flush implements http.Flusher interface
func (w *httpResponseWriterRPCClient) Flush() {
// Best-effort flush - ignore errors per HTTP spec
w.client.Call("Plugin.Flush", struct{}{}, nil)
}
func (w *httpResponseWriterRPCClient) Close() error {
return w.client.Close()
}
func connectHTTPResponseWriter(conn io.ReadWriteCloser) *httpResponseWriterRPCClient {
return &httpResponseWriterRPCClient{
client: rpc.NewClient(conn),
}
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestFlushGracefulDegradation verifies that the Flush RPC handler gracefully handles
// underlying writers that don't support http.Flusher interface
func TestFlushGracefulDegradation(t *testing.T) {
// Create a mock writer that does NOT implement http.Flusher
type basicWriter struct {
http.ResponseWriter
}
mockWriter := &basicWriter{
ResponseWriter: httptest.NewRecorder(),
}
// Verify it doesn't implement Flusher
_, ok := any(mockWriter).(http.Flusher)
require.False(t, ok, "basicWriter should not implement http.Flusher")
// Create server with non-Flusher writer
server := &httpResponseWriterRPCServer{
w: mockWriter,
}
// Test that Flush doesn't panic even when underlying writer doesn't support it
assert.NotPanics(t, func() {
err := server.Flush(struct{}{}, &struct{}{})
assert.NoError(t, err)
})
}

View File

@@ -0,0 +1,756 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"bytes"
"fmt"
"go/ast"
"go/parser"
"go/printer"
"go/token"
"log"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"text/template"
"github.com/pkg/errors"
"golang.org/x/tools/imports"
)
var excludedPluginHooks = []string{
"ChannelMemberWillBeAdded",
"FileWillBeUploaded",
"TeamMemberWillBeAdded",
"Implemented",
"LoadPluginConfiguration",
"InstallPlugin",
"LogAuditRec",
"LogAuditRecWithLevel",
"LogDebug",
"LogError",
"LogInfo",
"LogWarn",
"MessageWillBePosted",
"MessageWillBeUpdated",
"MessagesWillBeConsumed",
"MessagesWillBeConsumedWithContext",
"OnActivate",
"PluginHTTP",
"ServeHTTP",
"UploadData",
"ReceiveSharedChannelAttachmentSyncMsg",
"ServeMetrics",
}
type IHookEntry struct {
FuncName string
Args *ast.FieldList
Results *ast.FieldList
}
type PluginInterfaceInfo struct {
Hooks []IHookEntry
API []IHookEntry
FileSet *token.FileSet
}
func FieldListToFuncList(fieldList *ast.FieldList, fileset *token.FileSet) string {
result := []string{}
if fieldList == nil || len(fieldList.List) == 0 {
return "()"
}
for _, field := range fieldList.List {
typeNameBuffer := &bytes.Buffer{}
err := printer.Fprint(typeNameBuffer, fileset, field.Type)
if err != nil {
panic(err)
}
typeName := typeNameBuffer.String()
names := []string{}
for _, name := range field.Names {
names = append(names, name.Name)
}
result = append(result, strings.Join(names, ", ")+" "+typeName)
}
return "(" + strings.Join(result, ", ") + ")"
}
// FieldListToFuncListAppendErr renders the return signature with an extra trailing
// `error` (the RPC transport error). Used to emit *WithRPCErr companion signatures.
func FieldListToFuncListAppendErr(fieldList *ast.FieldList, fileset *token.FileSet) string {
if fieldList == nil || len(fieldList.List) == 0 {
return "error"
}
base := FieldListToFuncList(fieldList, fileset)
return "(" + base[1:len(base)-1] + ", error)"
}
func FieldListToNames(fieldList *ast.FieldList, variadicForm bool) string {
result := []string{}
if fieldList == nil || len(fieldList.List) == 0 {
return ""
}
for _, field := range fieldList.List {
for _, name := range field.Names {
paramName := name.Name
if _, ok := field.Type.(*ast.Ellipsis); ok && variadicForm {
paramName = fmt.Sprintf("%s...", paramName)
}
result = append(result, paramName)
}
}
return strings.Join(result, ", ")
}
func FieldListToEncodedErrors(structPrefix string, fieldList *ast.FieldList, fileset *token.FileSet) string {
result := []string{}
if fieldList == nil {
return ""
}
nextLetter := 'A'
for _, field := range fieldList.List {
typeNameBuffer := &bytes.Buffer{}
err := printer.Fprint(typeNameBuffer, fileset, field.Type)
if err != nil {
panic(err)
}
if typeNameBuffer.String() != "error" {
nextLetter++
continue
}
name := ""
if len(field.Names) == 0 {
name = string(nextLetter)
nextLetter++
} else {
for range field.Names {
name += string(nextLetter)
nextLetter++
}
}
result = append(result, structPrefix+name+" = encodableError("+structPrefix+name+")")
}
return strings.Join(result, "\n")
}
func FieldListDestruct(structPrefix string, fieldList *ast.FieldList, fileset *token.FileSet) string {
result := []string{}
if fieldList == nil || len(fieldList.List) == 0 {
return ""
}
nextLetter := 'A'
for _, field := range fieldList.List {
typeNameBuffer := &bytes.Buffer{}
err := printer.Fprint(typeNameBuffer, fileset, field.Type)
if err != nil {
panic(err)
}
typeName := typeNameBuffer.String()
suffix := ""
if strings.HasPrefix(typeName, "...") {
suffix = "..."
}
if len(field.Names) == 0 {
result = append(result, structPrefix+string(nextLetter)+suffix)
nextLetter++
} else {
for range field.Names {
result = append(result, structPrefix+string(nextLetter)+suffix)
nextLetter++
}
}
}
return strings.Join(result, ", ")
}
// FieldListDestructAppendErr is FieldListDestruct with an extra trailing `<prefix>RPCErr`
// variable name appended — used to bind the transport error in *WithRPCErr templates.
func FieldListDestructAppendErr(structPrefix string, fieldList *ast.FieldList, fileset *token.FileSet) string {
base := FieldListDestruct(structPrefix, fieldList, fileset)
rpcErr := structPrefix + "RPCErr"
if base == "" {
return rpcErr
}
return base + ", " + rpcErr
}
func FieldListToRecordSuccess(structPrefix string, fieldList *ast.FieldList) string {
if fieldList == nil || len(fieldList.List) == 0 {
return "true"
}
result := ""
nextLetter := 'A'
for _, field := range fieldList.List {
typeName := baseTypeName(field.Type)
if typeName == "error" || typeName == "AppError" {
result = structPrefix + string(nextLetter)
break
}
nextLetter++
}
if result == "" {
return "true"
}
return fmt.Sprintf("%s == nil", result)
}
// FieldListToRecordSuccessWithRPCErr renders the success-flag expression for a *WithRPCErr
// timer-layer call. It combines the RPC transport check with the base hook's app-error check
// (if any), so the metric counts a call as successful only when both the transport and the
// plugin's application-level return signal success.
func FieldListToRecordSuccessWithRPCErr(structPrefix string, fieldList *ast.FieldList) string {
rpcErr := structPrefix + "RPCErr == nil"
base := FieldListToRecordSuccess(structPrefix, fieldList)
if base == "true" {
return rpcErr
}
return rpcErr + " && " + base
}
func FieldListToStructList(fieldList *ast.FieldList, fileset *token.FileSet) string {
result := []string{}
if fieldList == nil || len(fieldList.List) == 0 {
return ""
}
nextLetter := 'A'
for _, field := range fieldList.List {
typeNameBuffer := &bytes.Buffer{}
err := printer.Fprint(typeNameBuffer, fileset, field.Type)
if err != nil {
panic(err)
}
typeName := typeNameBuffer.String()
if strings.HasPrefix(typeName, "...") {
typeName = strings.Replace(typeName, "...", "[]", 1)
}
if len(field.Names) == 0 {
result = append(result, string(nextLetter)+" "+typeName)
nextLetter++
} else {
for range field.Names {
result = append(result, string(nextLetter)+" "+typeName)
nextLetter++
}
}
}
return strings.Join(result, "\n\t")
}
func baseTypeName(x ast.Expr) string {
switch t := x.(type) {
case *ast.Ident:
return t.Name
case *ast.SelectorExpr:
if _, ok := t.X.(*ast.Ident); ok {
// only possible for qualified type names;
// assume type is imported
return t.Sel.Name
}
case *ast.ParenExpr:
return baseTypeName(t.X)
case *ast.StarExpr:
return baseTypeName(t.X)
}
return ""
}
func goList(dir string) ([]string, error) {
cmd := exec.Command("go", "list", "-f", "{{.Dir}}", dir)
bytes, err := cmd.Output()
if err != nil {
return nil, errors.Wrap(err, "Can't list packages")
}
return strings.Fields(string(bytes)), nil
}
func (info *PluginInterfaceInfo) addHookMethod(method *ast.Field) {
info.Hooks = append(info.Hooks, IHookEntry{
FuncName: method.Names[0].Name,
Args: method.Type.(*ast.FuncType).Params,
Results: method.Type.(*ast.FuncType).Results,
})
}
func (info *PluginInterfaceInfo) addAPIMethod(method *ast.Field) {
info.API = append(info.API, IHookEntry{
FuncName: method.Names[0].Name,
Args: method.Type.(*ast.FuncType).Params,
Results: method.Type.(*ast.FuncType).Results,
})
}
func (info *PluginInterfaceInfo) makeHookInspector() func(node ast.Node) bool {
return func(node ast.Node) bool {
if typeSpec, ok := node.(*ast.TypeSpec); ok {
if typeSpec.Name.Name == "Hooks" {
for _, method := range typeSpec.Type.(*ast.InterfaceType).Methods.List {
info.addHookMethod(method)
}
return false
} else if typeSpec.Name.Name == "API" {
for _, method := range typeSpec.Type.(*ast.InterfaceType).Methods.List {
info.addAPIMethod(method)
}
return false
}
}
return true
}
}
func getPluginInfo(dir string) (*PluginInterfaceInfo, error) {
pluginInfo := &PluginInterfaceInfo{
Hooks: make([]IHookEntry, 0),
FileSet: token.NewFileSet(),
}
packages, err := parser.ParseDir(pluginInfo.FileSet, dir, nil, parser.ParseComments)
if err != nil {
log.Println("Parser error in dir "+dir+": ", err)
return nil, err
}
for _, pkg := range packages {
if pkg.Name != "plugin" {
continue
}
for _, file := range pkg.Files {
ast.Inspect(file, pluginInfo.makeHookInspector())
}
}
return pluginInfo, nil
}
var hooksTemplate = `// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Code generated by "make pluginapi"
// DO NOT EDIT
package plugin
import (
"fmt"
"log"
saml2 "github.com/mattermost/gosaml2"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
{{range .HooksMethods}}
func init() {
hookNameToId["{{.Name}}"] = {{.Name}}ID
}
type {{.Name | obscure}}Args struct {
{{structStyle .Params}}
}
type {{.Name | obscure}}Returns struct {
{{structStyle .Return}}
}
func (g *hooksRPCClient) {{.Name}}{{funcStyle .Params}} {{funcStyle .Return}} {
_args := &{{.Name | obscure}}Args{ {{valuesOnly .Params}} }
_returns := &{{.Name | obscure}}Returns{}
if g.implemented[{{.Name}}ID] {
if err := g.client.Call("Plugin.{{.Name}}", _args, _returns); err != nil {
g.log.Error("RPC call {{.Name}} to plugin failed.", mlog.Err(err))
}
}
{{ if .Return }} return {{destruct "_returns." .Return}} {{ end }}
}
// {{.Name}}WithRPCErr returns the same values as {{.Name}}, with an additional trailing error
// for the RPC transport — always the LAST return slot.
func (g *hooksRPCClient) {{.Name}}WithRPCErr{{funcStyle .Params}} {{funcStyleAppendErr .Return}} {
_args := &{{.Name | obscure}}Args{ {{valuesOnly .Params}} }
_returns := &{{.Name | obscure}}Returns{}
var _err error
if g.implemented[{{.Name}}ID] {
_err = g.client.Call("Plugin.{{.Name}}", _args, _returns)
if _err != nil {
// Reset _returns so partial gob decoding can't leak non-zero
// values past a transport failure (HooksWithRPCErrGenerated contract).
_returns = &{{.Name | obscure}}Returns{}
g.log.Debug("RPC call {{.Name}} to plugin failed.", mlog.Err(_err))
}
}
{{ if .Return }} return {{destruct "_returns." .Return}}, _err {{ else }} return _err {{ end }}
}
func (s *hooksRPCServer) {{.Name}}(args *{{.Name | obscure}}Args, returns *{{.Name | obscure}}Returns) error {
if hook, ok := s.impl.(interface {
{{.Name}}{{funcStyle .Params}} {{funcStyle .Return}}
}); ok {
{{if .Return}}{{destruct "returns." .Return}} = {{end}}hook.{{.Name}}({{destruct "args." .Params}})
{{if .Return}}{{encodeErrors "returns." .Return}}{{end -}}
} else {
return encodableError(fmt.Errorf("Hook {{.Name}} called but not implemented."))
}
return nil
}
{{end}}
// HooksWithRPCErrGenerated provides a WithRPCErr variant for every generated hook. The last error return
// is always the RPC transport error — if non-nil, the plugin's other return values are zero. For
// hooks whose base signature already returns error, the tuple is (originalReturns..., rpcErr)
// where the final slot is always transport.
//
// If the plugin does not implement the hook, the companion returns zero values and a nil error —
// indistinguishable from a successful invocation that returned zeros. Callers MUST gate on
// supervisor.Implements(<HookID>) (or use Environment.RunMultiPluginHookWithRPCErr, which gates
// by the iteration's hook ID — note that any *WithRPCErr method called on the closure's
// HooksWithRPCErrGenerated is independently subject to its own implemented-gate).
type HooksWithRPCErrGenerated interface {
{{range .HooksMethods}}
{{.Name}}WithRPCErr{{funcStyle .Params}} {{funcStyleAppendErr .Return}}
{{end}}
}
{{range .APIMethods}}
type {{.Name | obscure}}Args struct {
{{structStyle .Params}}
}
type {{.Name | obscure}}Returns struct {
{{structStyle .Return}}
}
func (g *apiRPCClient) {{.Name}}{{funcStyle .Params}} {{funcStyle .Return}} {
_args := &{{.Name | obscure}}Args{ {{valuesOnly .Params}} }
_returns := &{{.Name | obscure}}Returns{}
if err := g.client.Call("Plugin.{{.Name}}", _args, _returns); err != nil {
log.Printf("RPC call to {{.Name}} API failed: %s", err.Error())
}
{{ if .Return }} return {{destruct "_returns." .Return}} {{ end }}
}
func (s *apiRPCServer) {{.Name}}(args *{{.Name | obscure}}Args, returns *{{.Name | obscure}}Returns) error {
if hook, ok := s.impl.(interface {
{{.Name}}{{funcStyle .Params}} {{funcStyle .Return}}
}); ok {
{{if .Return}}{{destruct "returns." .Return}} = {{end}}hook.{{.Name}}({{destruct "args." .Params}})
{{if .Return}}{{encodeErrors "returns." .Return}}{{end -}}
} else {
return encodableError(fmt.Errorf("API {{.Name}} called but not implemented."))
}
return nil
}
{{end}}
`
var apiTimerLayerTemplate = `// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Code generated by "make pluginapi"
// DO NOT EDIT
package plugin
import (
"io"
"net/http"
timePkg "time"
"github.com/mattermost/mattermost/server/public/model"
)
type apiTimerLayer struct {
pluginID string
apiImpl API
metrics metricsInterface
}
func (api *apiTimerLayer) recordTime(startTime timePkg.Time, name string, success bool) {
if api.metrics != nil {
elapsedTime := float64(timePkg.Since(startTime)) / float64(timePkg.Second)
api.metrics.ObservePluginAPIDuration(api.pluginID, name, success, elapsedTime)
}
}
{{range .APIMethods}}
func (api *apiTimerLayer) {{.Name}}{{funcStyle .Params}} {{funcStyle .Return}} {
startTime := timePkg.Now()
{{ if .Return }} {{destruct "_returns" .Return}} := {{ end }} api.apiImpl.{{.Name}}({{valuesOnly .Params}})
api.recordTime(startTime, "{{.Name}}", {{ shouldRecordSuccess "_returns" .Return }})
{{ if .Return }} return {{destruct "_returns" .Return}} {{ end -}}
}
{{end}}
`
var hooksTimerLayerTemplate = `// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Code generated by "make pluginapi"
// DO NOT EDIT
package plugin
import (
"io"
"net/http"
timePkg "time"
saml2 "github.com/mattermost/gosaml2"
"github.com/mattermost/mattermost/server/public/model"
)
type hooksTimerLayer struct {
pluginID string
hooksImpl Hooks
hooksWithRPCErrImpl HooksWithRPCErr
metrics metricsInterface
}
func (hooks *hooksTimerLayer) recordTime(startTime timePkg.Time, name string, success bool) {
if hooks.metrics != nil {
elapsedTime := float64(timePkg.Since(startTime)) / float64(timePkg.Second)
hooks.metrics.ObservePluginHookDuration(hooks.pluginID, name, success, elapsedTime)
}
}
{{range .HooksMethods}}
func (hooks *hooksTimerLayer) {{.Name}}{{funcStyle .Params}} {{funcStyle .Return}} {
startTime := timePkg.Now()
{{ if .Return }} {{destruct "_returns" .Return}} := {{ end }} hooks.hooksImpl.{{.Name}}({{valuesOnly .Params}})
hooks.recordTime(startTime, "{{.Name}}", {{ shouldRecordSuccess "_returns" .Return }})
{{ if .Return }} return {{destruct "_returns" .Return}} {{end -}}
}
{{end}}
{{range .HooksMethodsRPCErr}}
func (hooks *hooksTimerLayer) {{.Name}}WithRPCErr{{funcStyle .Params}} {{funcStyleAppendErr .Return}} {
startTime := timePkg.Now()
{{destructAppendErr "_returns" .Return}} := hooks.hooksWithRPCErrImpl.{{.Name}}WithRPCErr({{valuesOnly .Params}})
hooks.recordTime(startTime, "{{.Name}}WithRPCErr", {{ shouldRecordSuccessWithRPCErr "_returns" .Return }})
return {{destructAppendErr "_returns" .Return}}
}
{{end}}
`
type MethodParams struct {
Name string
Params *ast.FieldList
Return *ast.FieldList
}
type HooksTemplateParams struct {
HooksMethods []MethodParams
HooksMethodsRPCErr []MethodParams
APIMethods []MethodParams
}
func generateHooksGlue(info *PluginInterfaceInfo) {
templateFunctions := map[string]any{
"funcStyle": func(fields *ast.FieldList) string { return FieldListToFuncList(fields, info.FileSet) },
"funcStyleAppendErr": func(fields *ast.FieldList) string { return FieldListToFuncListAppendErr(fields, info.FileSet) },
"structStyle": func(fields *ast.FieldList) string { return FieldListToStructList(fields, info.FileSet) },
"valuesOnly": func(fields *ast.FieldList) string { return FieldListToNames(fields, false) },
"encodeErrors": func(structPrefix string, fields *ast.FieldList) string {
return FieldListToEncodedErrors(structPrefix, fields, info.FileSet)
},
"destruct": func(structPrefix string, fields *ast.FieldList) string {
return FieldListDestruct(structPrefix, fields, info.FileSet)
},
"shouldRecordSuccess": func(structPrefix string, fields *ast.FieldList) string {
return FieldListToRecordSuccess(structPrefix, fields)
},
"obscure": func(name string) string {
return "Z_" + name
},
}
hooksTemplate, err := template.New("hooks").Funcs(templateFunctions).Parse(hooksTemplate)
if err != nil {
panic(err)
}
templateParams := HooksTemplateParams{}
for _, hook := range info.Hooks {
templateParams.HooksMethods = append(templateParams.HooksMethods, MethodParams{
Name: hook.FuncName,
Params: hook.Args,
Return: hook.Results,
})
}
for _, api := range info.API {
templateParams.APIMethods = append(templateParams.APIMethods, MethodParams{
Name: api.FuncName,
Params: api.Args,
Return: api.Results,
})
}
templateResult := &bytes.Buffer{}
err = hooksTemplate.Execute(templateResult, &templateParams)
if err != nil {
panic(err)
}
formatted, err := imports.Process("", templateResult.Bytes(), nil)
if err != nil {
panic(err)
}
if err := os.WriteFile(filepath.Join(getPluginPackageDir(), "client_rpc_generated.go"), formatted, 0664); err != nil {
panic(err)
}
}
func generatePluginTimerLayer(info *PluginInterfaceInfo) {
templateFunctions := map[string]any{
"funcStyle": func(fields *ast.FieldList) string { return FieldListToFuncList(fields, info.FileSet) },
"funcStyleAppendErr": func(fields *ast.FieldList) string { return FieldListToFuncListAppendErr(fields, info.FileSet) },
"structStyle": func(fields *ast.FieldList) string { return FieldListToStructList(fields, info.FileSet) },
"valuesOnly": func(fields *ast.FieldList) string { return FieldListToNames(fields, true) },
"destruct": func(structPrefix string, fields *ast.FieldList) string {
return FieldListDestruct(structPrefix, fields, info.FileSet)
},
"destructAppendErr": func(structPrefix string, fields *ast.FieldList) string {
return FieldListDestructAppendErr(structPrefix, fields, info.FileSet)
},
"shouldRecordSuccess": func(structPrefix string, fields *ast.FieldList) string {
return FieldListToRecordSuccess(structPrefix, fields)
},
"shouldRecordSuccessWithRPCErr": func(structPrefix string, fields *ast.FieldList) string {
return FieldListToRecordSuccessWithRPCErr(structPrefix, fields)
},
}
// Prepare template params. The timer layer wraps the full Hooks interface, so
// HooksMethods includes excluded hooks too. *WithRPCErr companions only exist
// for non-excluded hooks (see HooksWithRPCErrGenerated in client_rpc_generated.go), so the
// excluded subset is filtered into HooksMethodsRPCErr for that loop.
excluded := func(name string) bool { return slices.Contains(excludedPluginHooks, name) }
templateParams := HooksTemplateParams{}
for _, hook := range info.Hooks {
mp := MethodParams{
Name: hook.FuncName,
Params: hook.Args,
Return: hook.Results,
}
templateParams.HooksMethods = append(templateParams.HooksMethods, mp)
if !excluded(hook.FuncName) {
templateParams.HooksMethodsRPCErr = append(templateParams.HooksMethodsRPCErr, mp)
}
}
for _, api := range info.API {
templateParams.APIMethods = append(templateParams.APIMethods, MethodParams{
Name: api.FuncName,
Params: api.Args,
Return: api.Results,
})
}
pluginTemplates := map[string]string{
"api_timer_layer_generated.go": apiTimerLayerTemplate,
"hooks_timer_layer_generated.go": hooksTimerLayerTemplate,
}
for fileName, presetTemplate := range pluginTemplates {
parsedTemplate, err := template.New("hooks").Funcs(templateFunctions).Parse(presetTemplate)
if err != nil {
panic(err)
}
templateResult := &bytes.Buffer{}
err = parsedTemplate.Execute(templateResult, &templateParams)
if err != nil {
panic(err)
}
formatted, err := imports.Process("", templateResult.Bytes(), nil)
if err != nil {
panic(err)
}
if err := os.WriteFile(filepath.Join(getPluginPackageDir(), fileName), formatted, 0664); err != nil {
panic(err)
}
}
}
func getPluginPackageDir() string {
dirs, err := goList("github.com/mattermost/mattermost/server/public/plugin")
if err != nil {
panic(err)
} else if len(dirs) != 1 {
panic("More than one package dir, or no dirs!")
}
return dirs[0]
}
func removeExcluded(info *PluginInterfaceInfo, excluded []string) *PluginInterfaceInfo {
newIface := &PluginInterfaceInfo{
FileSet: info.FileSet,
}
toBeExcluded := func(item string) bool {
return slices.Contains(excluded, item)
}
hooksResult := make([]IHookEntry, 0, len(info.Hooks))
for _, hook := range info.Hooks {
if !toBeExcluded(hook.FuncName) {
hooksResult = append(hooksResult, hook)
}
}
newIface.Hooks = hooksResult
apiResult := make([]IHookEntry, 0, len(info.API))
for _, api := range info.API {
if !toBeExcluded(api.FuncName) {
apiResult = append(apiResult, api)
}
}
newIface.API = apiResult
return newIface
}
func main() {
pluginPackageDir := getPluginPackageDir()
forRPC, err := getPluginInfo(pluginPackageDir)
if err != nil {
fmt.Println("Unable to get plugin info: " + err.Error())
}
log.Println("Generating plugin hooks glue")
generateHooksGlue(removeExcluded(forRPC, excludedPluginHooks))
// Generate plugin timer layers
log.Println("Generating plugin timer glue")
forPlugins, err := getPluginInfo(pluginPackageDir)
if err != nil {
fmt.Println("Unable to get plugin info: " + err.Error())
}
generatePluginTimerLayer(forPlugins)
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"bufio"
"encoding/binary"
"io"
)
type remoteIOReader struct {
conn io.ReadWriteCloser
}
func (r *remoteIOReader) Read(b []byte) (int, error) {
var buf [10]byte
n := binary.PutVarint(buf[:], int64(len(b)))
if _, err := r.conn.Write(buf[:n]); err != nil {
return 0, err
}
return r.conn.Read(b)
}
func (r *remoteIOReader) Close() error {
return r.conn.Close()
}
func connectIOReader(conn io.ReadWriteCloser) io.ReadCloser {
return &remoteIOReader{conn}
}
func serveIOReader(r io.Reader, conn io.ReadWriteCloser) {
cr := bufio.NewReader(conn)
defer conn.Close()
buf := make([]byte, 32*1024)
for {
n, err := binary.ReadVarint(cr)
if err != nil {
break
}
if written, err := io.CopyBuffer(conn, io.LimitReader(r, n), buf); err != nil || written < n {
break
}
}
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
type metricsInterface interface {
ObservePluginHookDuration(pluginID, hookName string, success bool, elapsed float64)
ObservePluginMultiHookIterationDuration(pluginID string, elapsed float64)
ObservePluginMultiHookDuration(elapsed float64)
ObservePluginAPIDuration(pluginID, apiName string, success bool, elapsed float64)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// The plugintest package provides mocks that can be used to test plugins.
//
// The mocks are created using testify's mock package:
// https://godoc.org/github.com/stretchr/testify/mock
//
// If you need to import the mock package, you can import it with
// "github.com/mattermost/mattermost/server/public/plugin/plugintest/mock".
package plugintest

View File

@@ -0,0 +1,480 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Regenerate this file using `make plugin-mocks`.
package plugintest
import (
driver "database/sql/driver"
plugin "github.com/mattermost/mattermost/server/public/plugin"
mock "github.com/stretchr/testify/mock"
)
// Driver is an autogenerated mock type for the Driver type
type Driver struct {
mock.Mock
}
// Conn provides a mock function with given fields: isMaster
func (_m *Driver) Conn(isMaster bool) (string, error) {
ret := _m.Called(isMaster)
if len(ret) == 0 {
panic("no return value specified for Conn")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func(bool) (string, error)); ok {
return rf(isMaster)
}
if rf, ok := ret.Get(0).(func(bool) string); ok {
r0 = rf(isMaster)
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func(bool) error); ok {
r1 = rf(isMaster)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// ConnClose provides a mock function with given fields: connID
func (_m *Driver) ConnClose(connID string) error {
ret := _m.Called(connID)
if len(ret) == 0 {
panic("no return value specified for ConnClose")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(connID)
} else {
r0 = ret.Error(0)
}
return r0
}
// ConnExec provides a mock function with given fields: connID, q, args
func (_m *Driver) ConnExec(connID string, q string, args []driver.NamedValue) (plugin.ResultContainer, error) {
ret := _m.Called(connID, q, args)
if len(ret) == 0 {
panic("no return value specified for ConnExec")
}
var r0 plugin.ResultContainer
var r1 error
if rf, ok := ret.Get(0).(func(string, string, []driver.NamedValue) (plugin.ResultContainer, error)); ok {
return rf(connID, q, args)
}
if rf, ok := ret.Get(0).(func(string, string, []driver.NamedValue) plugin.ResultContainer); ok {
r0 = rf(connID, q, args)
} else {
r0 = ret.Get(0).(plugin.ResultContainer)
}
if rf, ok := ret.Get(1).(func(string, string, []driver.NamedValue) error); ok {
r1 = rf(connID, q, args)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// ConnPing provides a mock function with given fields: connID
func (_m *Driver) ConnPing(connID string) error {
ret := _m.Called(connID)
if len(ret) == 0 {
panic("no return value specified for ConnPing")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(connID)
} else {
r0 = ret.Error(0)
}
return r0
}
// ConnQuery provides a mock function with given fields: connID, q, args
func (_m *Driver) ConnQuery(connID string, q string, args []driver.NamedValue) (string, error) {
ret := _m.Called(connID, q, args)
if len(ret) == 0 {
panic("no return value specified for ConnQuery")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func(string, string, []driver.NamedValue) (string, error)); ok {
return rf(connID, q, args)
}
if rf, ok := ret.Get(0).(func(string, string, []driver.NamedValue) string); ok {
r0 = rf(connID, q, args)
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func(string, string, []driver.NamedValue) error); ok {
r1 = rf(connID, q, args)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// RowsClose provides a mock function with given fields: rowsID
func (_m *Driver) RowsClose(rowsID string) error {
ret := _m.Called(rowsID)
if len(ret) == 0 {
panic("no return value specified for RowsClose")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(rowsID)
} else {
r0 = ret.Error(0)
}
return r0
}
// RowsColumnTypeDatabaseTypeName provides a mock function with given fields: rowsID, index
func (_m *Driver) RowsColumnTypeDatabaseTypeName(rowsID string, index int) string {
ret := _m.Called(rowsID, index)
if len(ret) == 0 {
panic("no return value specified for RowsColumnTypeDatabaseTypeName")
}
var r0 string
if rf, ok := ret.Get(0).(func(string, int) string); ok {
r0 = rf(rowsID, index)
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// RowsColumnTypePrecisionScale provides a mock function with given fields: rowsID, index
func (_m *Driver) RowsColumnTypePrecisionScale(rowsID string, index int) (int64, int64, bool) {
ret := _m.Called(rowsID, index)
if len(ret) == 0 {
panic("no return value specified for RowsColumnTypePrecisionScale")
}
var r0 int64
var r1 int64
var r2 bool
if rf, ok := ret.Get(0).(func(string, int) (int64, int64, bool)); ok {
return rf(rowsID, index)
}
if rf, ok := ret.Get(0).(func(string, int) int64); ok {
r0 = rf(rowsID, index)
} else {
r0 = ret.Get(0).(int64)
}
if rf, ok := ret.Get(1).(func(string, int) int64); ok {
r1 = rf(rowsID, index)
} else {
r1 = ret.Get(1).(int64)
}
if rf, ok := ret.Get(2).(func(string, int) bool); ok {
r2 = rf(rowsID, index)
} else {
r2 = ret.Get(2).(bool)
}
return r0, r1, r2
}
// RowsColumns provides a mock function with given fields: rowsID
func (_m *Driver) RowsColumns(rowsID string) []string {
ret := _m.Called(rowsID)
if len(ret) == 0 {
panic("no return value specified for RowsColumns")
}
var r0 []string
if rf, ok := ret.Get(0).(func(string) []string); ok {
r0 = rf(rowsID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
return r0
}
// RowsHasNextResultSet provides a mock function with given fields: rowsID
func (_m *Driver) RowsHasNextResultSet(rowsID string) bool {
ret := _m.Called(rowsID)
if len(ret) == 0 {
panic("no return value specified for RowsHasNextResultSet")
}
var r0 bool
if rf, ok := ret.Get(0).(func(string) bool); ok {
r0 = rf(rowsID)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// RowsNext provides a mock function with given fields: rowsID, dest
func (_m *Driver) RowsNext(rowsID string, dest []driver.Value) error {
ret := _m.Called(rowsID, dest)
if len(ret) == 0 {
panic("no return value specified for RowsNext")
}
var r0 error
if rf, ok := ret.Get(0).(func(string, []driver.Value) error); ok {
r0 = rf(rowsID, dest)
} else {
r0 = ret.Error(0)
}
return r0
}
// RowsNextResultSet provides a mock function with given fields: rowsID
func (_m *Driver) RowsNextResultSet(rowsID string) error {
ret := _m.Called(rowsID)
if len(ret) == 0 {
panic("no return value specified for RowsNextResultSet")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(rowsID)
} else {
r0 = ret.Error(0)
}
return r0
}
// Stmt provides a mock function with given fields: connID, q
func (_m *Driver) Stmt(connID string, q string) (string, error) {
ret := _m.Called(connID, q)
if len(ret) == 0 {
panic("no return value specified for Stmt")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func(string, string) (string, error)); ok {
return rf(connID, q)
}
if rf, ok := ret.Get(0).(func(string, string) string); ok {
r0 = rf(connID, q)
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(connID, q)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// StmtClose provides a mock function with given fields: stID
func (_m *Driver) StmtClose(stID string) error {
ret := _m.Called(stID)
if len(ret) == 0 {
panic("no return value specified for StmtClose")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(stID)
} else {
r0 = ret.Error(0)
}
return r0
}
// StmtExec provides a mock function with given fields: stID, args
func (_m *Driver) StmtExec(stID string, args []driver.NamedValue) (plugin.ResultContainer, error) {
ret := _m.Called(stID, args)
if len(ret) == 0 {
panic("no return value specified for StmtExec")
}
var r0 plugin.ResultContainer
var r1 error
if rf, ok := ret.Get(0).(func(string, []driver.NamedValue) (plugin.ResultContainer, error)); ok {
return rf(stID, args)
}
if rf, ok := ret.Get(0).(func(string, []driver.NamedValue) plugin.ResultContainer); ok {
r0 = rf(stID, args)
} else {
r0 = ret.Get(0).(plugin.ResultContainer)
}
if rf, ok := ret.Get(1).(func(string, []driver.NamedValue) error); ok {
r1 = rf(stID, args)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// StmtNumInput provides a mock function with given fields: stID
func (_m *Driver) StmtNumInput(stID string) int {
ret := _m.Called(stID)
if len(ret) == 0 {
panic("no return value specified for StmtNumInput")
}
var r0 int
if rf, ok := ret.Get(0).(func(string) int); ok {
r0 = rf(stID)
} else {
r0 = ret.Get(0).(int)
}
return r0
}
// StmtQuery provides a mock function with given fields: stID, args
func (_m *Driver) StmtQuery(stID string, args []driver.NamedValue) (string, error) {
ret := _m.Called(stID, args)
if len(ret) == 0 {
panic("no return value specified for StmtQuery")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func(string, []driver.NamedValue) (string, error)); ok {
return rf(stID, args)
}
if rf, ok := ret.Get(0).(func(string, []driver.NamedValue) string); ok {
r0 = rf(stID, args)
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func(string, []driver.NamedValue) error); ok {
r1 = rf(stID, args)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Tx provides a mock function with given fields: connID, opts
func (_m *Driver) Tx(connID string, opts driver.TxOptions) (string, error) {
ret := _m.Called(connID, opts)
if len(ret) == 0 {
panic("no return value specified for Tx")
}
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func(string, driver.TxOptions) (string, error)); ok {
return rf(connID, opts)
}
if rf, ok := ret.Get(0).(func(string, driver.TxOptions) string); ok {
r0 = rf(connID, opts)
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func(string, driver.TxOptions) error); ok {
r1 = rf(connID, opts)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// TxCommit provides a mock function with given fields: txID
func (_m *Driver) TxCommit(txID string) error {
ret := _m.Called(txID)
if len(ret) == 0 {
panic("no return value specified for TxCommit")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(txID)
} else {
r0 = ret.Error(0)
}
return r0
}
// TxRollback provides a mock function with given fields: txID
func (_m *Driver) TxRollback(txID string) error {
ret := _m.Called(txID)
if len(ret) == 0 {
panic("no return value specified for TxRollback")
}
var r0 error
if rf, ok := ret.Get(0).(func(string) error); ok {
r0 = rf(txID)
} else {
r0 = ret.Error(0)
}
return r0
}
// NewDriver creates a new instance of Driver. 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 NewDriver(t interface {
mock.TestingT
Cleanup(func())
}) *Driver {
mock := &Driver{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugintest_test
import (
"fmt"
io "io"
"net/http"
"net/http/httptest"
"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"
"github.com/mattermost/mattermost/server/public/plugin/plugintest"
)
type HelloUserPlugin struct {
plugin.MattermostPlugin
}
func (p *HelloUserPlugin) ServeHTTP(context *plugin.Context, w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("Mattermost-User-Id")
user, err := p.API.GetUser(userID)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
p.API.LogError(err.Error())
return
}
fmt.Fprintf(w, "Welcome back, %s!", user.Username)
}
func Example() {
t := &testing.T{}
user := &model.User{
Id: model.NewId(),
Username: "billybob",
}
api := &plugintest.API{}
api.On("GetUser", user.Id).Return(user, nil)
defer api.AssertExpectations(t)
p := &HelloUserPlugin{}
p.SetAPI(api)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
r.Header.Add("Mattermost-User-Id", user.Id)
p.ServeHTTP(&plugin.Context{}, w, r)
body, err := io.ReadAll(w.Result().Body)
require.NoError(t, err)
assert.Equal(t, "Welcome back, billybob!", string(body))
}

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugintest_test
import (
"context"
"os"
"testing"
"time"
"github.com/stretchr/testify/require"
goPlugin "github.com/hashicorp/go-plugin"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin"
)
type UnitTestedPlugin struct {
plugin.MattermostPlugin
}
// This example demonstrates a plugin that's launched during a unit test and reattached to an
// existing server instance to obtain a real PluginAPI.
func Example_unitTestingPlugins() {
t := &testing.T{}
// The manifest is usually generated dynamically.
manifest := &model.Manifest{
Id: "reattach-plugin-test",
}
// ctx, and specifically cancel, gives us control over the plugin lifecycle
ctx, cancel := context.WithCancel(context.Background())
// reattachConfigCh is the means by which we get the Unix socket information to relay back
// to the server and finish the reattachment.
reattachConfigCh := make(chan *goPlugin.ReattachConfig)
// closeCh tells us when the plugin exits and allows for cleanup.
closeCh := make(chan struct{})
// plugin.ClientMain with options allows for reattachment.
go plugin.ClientMain(
&UnitTestedPlugin{},
plugin.WithTestContext(ctx),
plugin.WithTestReattachConfigCh(reattachConfigCh),
plugin.WithTestCloseCh(closeCh),
)
// Make sure the plugin shuts down normally with the test
t.Cleanup(func() {
cancel()
select {
case <-closeCh:
case <-time.After(5 * time.Second):
panic("plugin failed to close after 5 seconds")
}
})
// Wait for the plugin to start and then reattach to the server.
var reattachConfig *goPlugin.ReattachConfig
select {
case reattachConfig = <-reattachConfigCh:
case <-time.After(5 * time.Second):
t.Fatal("failed to get reattach config")
}
// Reattaching requires a local mode client.
socketPath := os.Getenv("MM_LOCALSOCKETPATH")
if socketPath == "" {
socketPath = model.LocalModeSocketPath
}
clientLocal := model.NewAPIv4SocketClient(socketPath)
_, err := clientLocal.ReattachPlugin(ctx, &model.PluginReattachRequest{
Manifest: manifest,
PluginReattachConfig: model.NewPluginReattachConfig(reattachConfig),
})
require.NoError(t, err)
// At this point, the plugin is ready for unit testing and will be cleaned up automatically
// with the testing.T instance.
}

View File

@@ -0,0 +1,883 @@
// Code generated by mockery v2.53.4. DO NOT EDIT.
// Regenerate this file using `make plugin-mocks`.
package plugintest
import (
io "io"
http "net/http"
saml2 "github.com/mattermost/gosaml2"
model "github.com/mattermost/mattermost/server/public/model"
plugin "github.com/mattermost/mattermost/server/public/plugin"
mock "github.com/stretchr/testify/mock"
)
// Hooks is an autogenerated mock type for the Hooks type
type Hooks struct {
mock.Mock
}
// ChannelHasBeenCreated provides a mock function with given fields: c, channel
func (_m *Hooks) ChannelHasBeenCreated(c *plugin.Context, channel *model.Channel) {
_m.Called(c, channel)
}
// ChannelMemberWillBeAdded provides a mock function with given fields: c, channelMember
func (_m *Hooks) ChannelMemberWillBeAdded(c *plugin.Context, channelMember *model.ChannelMember) (*model.ChannelMember, string) {
ret := _m.Called(c, channelMember)
if len(ret) == 0 {
panic("no return value specified for ChannelMemberWillBeAdded")
}
var r0 *model.ChannelMember
var r1 string
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.ChannelMember) (*model.ChannelMember, string)); ok {
return rf(c, channelMember)
}
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.ChannelMember) *model.ChannelMember); ok {
r0 = rf(c, channelMember)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.ChannelMember)
}
}
if rf, ok := ret.Get(1).(func(*plugin.Context, *model.ChannelMember) string); ok {
r1 = rf(c, channelMember)
} else {
r1 = ret.Get(1).(string)
}
return r0, r1
}
// ChannelWillBeArchived provides a mock function with given fields: c, channel
func (_m *Hooks) ChannelWillBeArchived(c *plugin.Context, channel *model.Channel) string {
ret := _m.Called(c, channel)
if len(ret) == 0 {
panic("no return value specified for ChannelWillBeArchived")
}
var r0 string
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Channel) string); ok {
r0 = rf(c, channel)
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// ChannelWillBeRestored provides a mock function with given fields: c, channel
func (_m *Hooks) ChannelWillBeRestored(c *plugin.Context, channel *model.Channel) string {
ret := _m.Called(c, channel)
if len(ret) == 0 {
panic("no return value specified for ChannelWillBeRestored")
}
var r0 string
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Channel) string); ok {
r0 = rf(c, channel)
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// ChannelWillBeUpdated provides a mock function with given fields: c, newChannel, oldChannel
func (_m *Hooks) ChannelWillBeUpdated(c *plugin.Context, newChannel *model.Channel, oldChannel *model.Channel) (*model.Channel, string) {
ret := _m.Called(c, newChannel, oldChannel)
if len(ret) == 0 {
panic("no return value specified for ChannelWillBeUpdated")
}
var r0 *model.Channel
var r1 string
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Channel, *model.Channel) (*model.Channel, string)); ok {
return rf(c, newChannel, oldChannel)
}
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Channel, *model.Channel) *model.Channel); ok {
r0 = rf(c, newChannel, oldChannel)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Channel)
}
}
if rf, ok := ret.Get(1).(func(*plugin.Context, *model.Channel, *model.Channel) string); ok {
r1 = rf(c, newChannel, oldChannel)
} else {
r1 = ret.Get(1).(string)
}
return r0, r1
}
// ConfigurationWillBeSaved provides a mock function with given fields: newCfg
func (_m *Hooks) ConfigurationWillBeSaved(newCfg *model.Config) (*model.Config, error) {
ret := _m.Called(newCfg)
if len(ret) == 0 {
panic("no return value specified for ConfigurationWillBeSaved")
}
var r0 *model.Config
var r1 error
if rf, ok := ret.Get(0).(func(*model.Config) (*model.Config, error)); ok {
return rf(newCfg)
}
if rf, ok := ret.Get(0).(func(*model.Config) *model.Config); ok {
r0 = rf(newCfg)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Config)
}
}
if rf, ok := ret.Get(1).(func(*model.Config) error); ok {
r1 = rf(newCfg)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// DraftWillBeUpserted provides a mock function with given fields: c, draft
func (_m *Hooks) DraftWillBeUpserted(c *plugin.Context, draft *model.Draft) (*model.Draft, string) {
ret := _m.Called(c, draft)
if len(ret) == 0 {
panic("no return value specified for DraftWillBeUpserted")
}
var r0 *model.Draft
var r1 string
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Draft) (*model.Draft, string)); ok {
return rf(c, draft)
}
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Draft) *model.Draft); ok {
r0 = rf(c, draft)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Draft)
}
}
if rf, ok := ret.Get(1).(func(*plugin.Context, *model.Draft) string); ok {
r1 = rf(c, draft)
} else {
r1 = ret.Get(1).(string)
}
return r0, r1
}
// EmailNotificationWillBeSent provides a mock function with given fields: emailNotification
func (_m *Hooks) EmailNotificationWillBeSent(emailNotification *model.EmailNotification) (*model.EmailNotificationContent, string) {
ret := _m.Called(emailNotification)
if len(ret) == 0 {
panic("no return value specified for EmailNotificationWillBeSent")
}
var r0 *model.EmailNotificationContent
var r1 string
if rf, ok := ret.Get(0).(func(*model.EmailNotification) (*model.EmailNotificationContent, string)); ok {
return rf(emailNotification)
}
if rf, ok := ret.Get(0).(func(*model.EmailNotification) *model.EmailNotificationContent); ok {
r0 = rf(emailNotification)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.EmailNotificationContent)
}
}
if rf, ok := ret.Get(1).(func(*model.EmailNotification) string); ok {
r1 = rf(emailNotification)
} else {
r1 = ret.Get(1).(string)
}
return r0, r1
}
// ExecuteCommand provides a mock function with given fields: c, args
func (_m *Hooks) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
ret := _m.Called(c, args)
if len(ret) == 0 {
panic("no return value specified for ExecuteCommand")
}
var r0 *model.CommandResponse
var r1 *model.AppError
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.CommandArgs) (*model.CommandResponse, *model.AppError)); ok {
return rf(c, args)
}
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.CommandArgs) *model.CommandResponse); ok {
r0 = rf(c, args)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.CommandResponse)
}
}
if rf, ok := ret.Get(1).(func(*plugin.Context, *model.CommandArgs) *model.AppError); ok {
r1 = rf(c, args)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*model.AppError)
}
}
return r0, r1
}
// FileWillBeDownloaded provides a mock function with given fields: c, fileInfo, userID, downloadType
func (_m *Hooks) FileWillBeDownloaded(c *plugin.Context, fileInfo *model.FileInfo, userID string, downloadType model.FileDownloadType) string {
ret := _m.Called(c, fileInfo, userID, downloadType)
if len(ret) == 0 {
panic("no return value specified for FileWillBeDownloaded")
}
var r0 string
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.FileInfo, string, model.FileDownloadType) string); ok {
r0 = rf(c, fileInfo, userID, downloadType)
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// FileWillBeUploaded provides a mock function with given fields: c, info, file, output
func (_m *Hooks) FileWillBeUploaded(c *plugin.Context, info *model.FileInfo, file io.Reader, output io.Writer) (*model.FileInfo, string) {
ret := _m.Called(c, info, file, output)
if len(ret) == 0 {
panic("no return value specified for FileWillBeUploaded")
}
var r0 *model.FileInfo
var r1 string
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.FileInfo, io.Reader, io.Writer) (*model.FileInfo, string)); ok {
return rf(c, info, file, output)
}
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.FileInfo, io.Reader, io.Writer) *model.FileInfo); ok {
r0 = rf(c, info, file, output)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.FileInfo)
}
}
if rf, ok := ret.Get(1).(func(*plugin.Context, *model.FileInfo, io.Reader, io.Writer) string); ok {
r1 = rf(c, info, file, output)
} else {
r1 = ret.Get(1).(string)
}
return r0, r1
}
// GenerateSupportData provides a mock function with given fields: c
func (_m *Hooks) GenerateSupportData(c *plugin.Context) ([]*model.FileData, error) {
ret := _m.Called(c)
if len(ret) == 0 {
panic("no return value specified for GenerateSupportData")
}
var r0 []*model.FileData
var r1 error
if rf, ok := ret.Get(0).(func(*plugin.Context) ([]*model.FileData, error)); ok {
return rf(c)
}
if rf, ok := ret.Get(0).(func(*plugin.Context) []*model.FileData); ok {
r0 = rf(c)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.FileData)
}
}
if rf, ok := ret.Get(1).(func(*plugin.Context) error); ok {
r1 = rf(c)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Implemented provides a mock function with no fields
func (_m *Hooks) Implemented() ([]string, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Implemented")
}
var r0 []string
var r1 error
if rf, ok := ret.Get(0).(func() ([]string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() []string); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MessageHasBeenDeleted provides a mock function with given fields: c, post
func (_m *Hooks) MessageHasBeenDeleted(c *plugin.Context, post *model.Post) {
_m.Called(c, post)
}
// MessageHasBeenPosted provides a mock function with given fields: c, post
func (_m *Hooks) MessageHasBeenPosted(c *plugin.Context, post *model.Post) {
_m.Called(c, post)
}
// MessageHasBeenUpdated provides a mock function with given fields: c, newPost, oldPost
func (_m *Hooks) MessageHasBeenUpdated(c *plugin.Context, newPost *model.Post, oldPost *model.Post) {
_m.Called(c, newPost, oldPost)
}
// MessageWillBePosted provides a mock function with given fields: c, post
func (_m *Hooks) MessageWillBePosted(c *plugin.Context, post *model.Post) (*model.Post, string) {
ret := _m.Called(c, post)
if len(ret) == 0 {
panic("no return value specified for MessageWillBePosted")
}
var r0 *model.Post
var r1 string
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Post) (*model.Post, string)); ok {
return rf(c, post)
}
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Post) *model.Post); ok {
r0 = rf(c, post)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Post)
}
}
if rf, ok := ret.Get(1).(func(*plugin.Context, *model.Post) string); ok {
r1 = rf(c, post)
} else {
r1 = ret.Get(1).(string)
}
return r0, r1
}
// MessageWillBeUpdated provides a mock function with given fields: c, newPost, oldPost
func (_m *Hooks) MessageWillBeUpdated(c *plugin.Context, newPost *model.Post, oldPost *model.Post) (*model.Post, string) {
ret := _m.Called(c, newPost, oldPost)
if len(ret) == 0 {
panic("no return value specified for MessageWillBeUpdated")
}
var r0 *model.Post
var r1 string
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Post, *model.Post) (*model.Post, string)); ok {
return rf(c, newPost, oldPost)
}
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Post, *model.Post) *model.Post); ok {
r0 = rf(c, newPost, oldPost)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Post)
}
}
if rf, ok := ret.Get(1).(func(*plugin.Context, *model.Post, *model.Post) string); ok {
r1 = rf(c, newPost, oldPost)
} else {
r1 = ret.Get(1).(string)
}
return r0, r1
}
// MessagesWillBeConsumed provides a mock function with given fields: posts
func (_m *Hooks) MessagesWillBeConsumed(posts []*model.Post) []*model.Post {
ret := _m.Called(posts)
if len(ret) == 0 {
panic("no return value specified for MessagesWillBeConsumed")
}
var r0 []*model.Post
if rf, ok := ret.Get(0).(func([]*model.Post) []*model.Post); ok {
r0 = rf(posts)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.Post)
}
}
return r0
}
// MessagesWillBeConsumedWithContext provides a mock function with given fields: c, posts
func (_m *Hooks) MessagesWillBeConsumedWithContext(c *plugin.Context, posts []*model.Post) []*model.Post {
ret := _m.Called(c, posts)
if len(ret) == 0 {
panic("no return value specified for MessagesWillBeConsumedWithContext")
}
var r0 []*model.Post
if rf, ok := ret.Get(0).(func(*plugin.Context, []*model.Post) []*model.Post); ok {
r0 = rf(c, posts)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.Post)
}
}
return r0
}
// NotificationWillBePushed provides a mock function with given fields: pushNotification, userID
func (_m *Hooks) NotificationWillBePushed(pushNotification *model.PushNotification, userID string) (*model.PushNotification, string) {
ret := _m.Called(pushNotification, userID)
if len(ret) == 0 {
panic("no return value specified for NotificationWillBePushed")
}
var r0 *model.PushNotification
var r1 string
if rf, ok := ret.Get(0).(func(*model.PushNotification, string) (*model.PushNotification, string)); ok {
return rf(pushNotification, userID)
}
if rf, ok := ret.Get(0).(func(*model.PushNotification, string) *model.PushNotification); ok {
r0 = rf(pushNotification, userID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.PushNotification)
}
}
if rf, ok := ret.Get(1).(func(*model.PushNotification, string) string); ok {
r1 = rf(pushNotification, userID)
} else {
r1 = ret.Get(1).(string)
}
return r0, r1
}
// OnActivate provides a mock function with no fields
func (_m *Hooks) OnActivate() error {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for OnActivate")
}
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// OnCloudLimitsUpdated provides a mock function with given fields: limits
func (_m *Hooks) OnCloudLimitsUpdated(limits *model.ProductLimits) {
_m.Called(limits)
}
// OnConfigurationChange provides a mock function with no fields
func (_m *Hooks) OnConfigurationChange() error {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for OnConfigurationChange")
}
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// OnDeactivate provides a mock function with no fields
func (_m *Hooks) OnDeactivate() error {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for OnDeactivate")
}
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// OnInstall provides a mock function with given fields: c, event
func (_m *Hooks) OnInstall(c *plugin.Context, event model.OnInstallEvent) error {
ret := _m.Called(c, event)
if len(ret) == 0 {
panic("no return value specified for OnInstall")
}
var r0 error
if rf, ok := ret.Get(0).(func(*plugin.Context, model.OnInstallEvent) error); ok {
r0 = rf(c, event)
} else {
r0 = ret.Error(0)
}
return r0
}
// OnLicenseChanged provides a mock function with given fields: oldLicense, newLicense
func (_m *Hooks) OnLicenseChanged(oldLicense *model.License, newLicense *model.License) {
_m.Called(oldLicense, newLicense)
}
// OnPluginClusterEvent provides a mock function with given fields: c, ev
func (_m *Hooks) OnPluginClusterEvent(c *plugin.Context, ev model.PluginClusterEvent) {
_m.Called(c, ev)
}
// OnSAMLLogin provides a mock function with given fields: c, user, assertion
func (_m *Hooks) OnSAMLLogin(c *plugin.Context, user *model.User, assertion *saml2.AssertionInfo) error {
ret := _m.Called(c, user, assertion)
if len(ret) == 0 {
panic("no return value specified for OnSAMLLogin")
}
var r0 error
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.User, *saml2.AssertionInfo) error); ok {
r0 = rf(c, user, assertion)
} else {
r0 = ret.Error(0)
}
return r0
}
// OnSendDailyTelemetry provides a mock function with no fields
func (_m *Hooks) OnSendDailyTelemetry() {
_m.Called()
}
// OnSharedChannelsAttachmentSyncMsg provides a mock function with given fields: fi, post, rc
func (_m *Hooks) OnSharedChannelsAttachmentSyncMsg(fi *model.FileInfo, post *model.Post, rc *model.RemoteCluster) error {
ret := _m.Called(fi, post, rc)
if len(ret) == 0 {
panic("no return value specified for OnSharedChannelsAttachmentSyncMsg")
}
var r0 error
if rf, ok := ret.Get(0).(func(*model.FileInfo, *model.Post, *model.RemoteCluster) error); ok {
r0 = rf(fi, post, rc)
} else {
r0 = ret.Error(0)
}
return r0
}
// OnSharedChannelsPing provides a mock function with given fields: rc
func (_m *Hooks) OnSharedChannelsPing(rc *model.RemoteCluster) bool {
ret := _m.Called(rc)
if len(ret) == 0 {
panic("no return value specified for OnSharedChannelsPing")
}
var r0 bool
if rf, ok := ret.Get(0).(func(*model.RemoteCluster) bool); ok {
r0 = rf(rc)
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// OnSharedChannelsProfileImageSyncMsg provides a mock function with given fields: user, rc
func (_m *Hooks) OnSharedChannelsProfileImageSyncMsg(user *model.User, rc *model.RemoteCluster) error {
ret := _m.Called(user, rc)
if len(ret) == 0 {
panic("no return value specified for OnSharedChannelsProfileImageSyncMsg")
}
var r0 error
if rf, ok := ret.Get(0).(func(*model.User, *model.RemoteCluster) error); ok {
r0 = rf(user, rc)
} else {
r0 = ret.Error(0)
}
return r0
}
// OnSharedChannelsSyncMsg provides a mock function with given fields: msg, rc
func (_m *Hooks) OnSharedChannelsSyncMsg(msg *model.SyncMsg, rc *model.RemoteCluster) (model.SyncResponse, error) {
ret := _m.Called(msg, rc)
if len(ret) == 0 {
panic("no return value specified for OnSharedChannelsSyncMsg")
}
var r0 model.SyncResponse
var r1 error
if rf, ok := ret.Get(0).(func(*model.SyncMsg, *model.RemoteCluster) (model.SyncResponse, error)); ok {
return rf(msg, rc)
}
if rf, ok := ret.Get(0).(func(*model.SyncMsg, *model.RemoteCluster) model.SyncResponse); ok {
r0 = rf(msg, rc)
} else {
r0 = ret.Get(0).(model.SyncResponse)
}
if rf, ok := ret.Get(1).(func(*model.SyncMsg, *model.RemoteCluster) error); ok {
r1 = rf(msg, rc)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// OnWebSocketConnect provides a mock function with given fields: webConnID, userID
func (_m *Hooks) OnWebSocketConnect(webConnID string, userID string) {
_m.Called(webConnID, userID)
}
// OnWebSocketDisconnect provides a mock function with given fields: webConnID, userID
func (_m *Hooks) OnWebSocketDisconnect(webConnID string, userID string) {
_m.Called(webConnID, userID)
}
// PreferencesHaveChanged provides a mock function with given fields: c, preferences
func (_m *Hooks) PreferencesHaveChanged(c *plugin.Context, preferences []model.Preference) {
_m.Called(c, preferences)
}
// ReactionHasBeenAdded provides a mock function with given fields: c, reaction
func (_m *Hooks) ReactionHasBeenAdded(c *plugin.Context, reaction *model.Reaction) {
_m.Called(c, reaction)
}
// ReactionHasBeenRemoved provides a mock function with given fields: c, reaction
func (_m *Hooks) ReactionHasBeenRemoved(c *plugin.Context, reaction *model.Reaction) {
_m.Called(c, reaction)
}
// RunDataRetention provides a mock function with given fields: nowTime, batchSize
func (_m *Hooks) RunDataRetention(nowTime int64, batchSize int64) (int64, error) {
ret := _m.Called(nowTime, batchSize)
if len(ret) == 0 {
panic("no return value specified for RunDataRetention")
}
var r0 int64
var r1 error
if rf, ok := ret.Get(0).(func(int64, int64) (int64, error)); ok {
return rf(nowTime, batchSize)
}
if rf, ok := ret.Get(0).(func(int64, int64) int64); ok {
r0 = rf(nowTime, batchSize)
} else {
r0 = ret.Get(0).(int64)
}
if rf, ok := ret.Get(1).(func(int64, int64) error); ok {
r1 = rf(nowTime, batchSize)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// ScheduledPostWillBeCreated provides a mock function with given fields: c, scheduledPost
func (_m *Hooks) ScheduledPostWillBeCreated(c *plugin.Context, scheduledPost *model.ScheduledPost) (*model.ScheduledPost, string) {
ret := _m.Called(c, scheduledPost)
if len(ret) == 0 {
panic("no return value specified for ScheduledPostWillBeCreated")
}
var r0 *model.ScheduledPost
var r1 string
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.ScheduledPost) (*model.ScheduledPost, string)); ok {
return rf(c, scheduledPost)
}
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.ScheduledPost) *model.ScheduledPost); ok {
r0 = rf(c, scheduledPost)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.ScheduledPost)
}
}
if rf, ok := ret.Get(1).(func(*plugin.Context, *model.ScheduledPost) string); ok {
r1 = rf(c, scheduledPost)
} else {
r1 = ret.Get(1).(string)
}
return r0, r1
}
// ServeHTTP provides a mock function with given fields: c, w, r
func (_m *Hooks) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) {
_m.Called(c, w, r)
}
// ServeMetrics provides a mock function with given fields: c, w, r
func (_m *Hooks) ServeMetrics(c *plugin.Context, w http.ResponseWriter, r *http.Request) {
_m.Called(c, w, r)
}
// TeamMemberWillBeAdded provides a mock function with given fields: c, teamMember
func (_m *Hooks) TeamMemberWillBeAdded(c *plugin.Context, teamMember *model.TeamMember) (*model.TeamMember, string) {
ret := _m.Called(c, teamMember)
if len(ret) == 0 {
panic("no return value specified for TeamMemberWillBeAdded")
}
var r0 *model.TeamMember
var r1 string
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.TeamMember) (*model.TeamMember, string)); ok {
return rf(c, teamMember)
}
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.TeamMember) *model.TeamMember); ok {
r0 = rf(c, teamMember)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.TeamMember)
}
}
if rf, ok := ret.Get(1).(func(*plugin.Context, *model.TeamMember) string); ok {
r1 = rf(c, teamMember)
} else {
r1 = ret.Get(1).(string)
}
return r0, r1
}
// UserHasBeenCreated provides a mock function with given fields: c, user
func (_m *Hooks) UserHasBeenCreated(c *plugin.Context, user *model.User) {
_m.Called(c, user)
}
// UserHasBeenDeactivated provides a mock function with given fields: c, user
func (_m *Hooks) UserHasBeenDeactivated(c *plugin.Context, user *model.User) {
_m.Called(c, user)
}
// UserHasJoinedChannel provides a mock function with given fields: c, channelMember, actor
func (_m *Hooks) UserHasJoinedChannel(c *plugin.Context, channelMember *model.ChannelMember, actor *model.User) {
_m.Called(c, channelMember, actor)
}
// UserHasJoinedTeam provides a mock function with given fields: c, teamMember, actor
func (_m *Hooks) UserHasJoinedTeam(c *plugin.Context, teamMember *model.TeamMember, actor *model.User) {
_m.Called(c, teamMember, actor)
}
// UserHasLeftChannel provides a mock function with given fields: c, channelMember, actor
func (_m *Hooks) UserHasLeftChannel(c *plugin.Context, channelMember *model.ChannelMember, actor *model.User) {
_m.Called(c, channelMember, actor)
}
// UserHasLeftTeam provides a mock function with given fields: c, teamMember, actor
func (_m *Hooks) UserHasLeftTeam(c *plugin.Context, teamMember *model.TeamMember, actor *model.User) {
_m.Called(c, teamMember, actor)
}
// UserHasLoggedIn provides a mock function with given fields: c, user
func (_m *Hooks) UserHasLoggedIn(c *plugin.Context, user *model.User) {
_m.Called(c, user)
}
// UserWillLogIn provides a mock function with given fields: c, user
func (_m *Hooks) UserWillLogIn(c *plugin.Context, user *model.User) string {
ret := _m.Called(c, user)
if len(ret) == 0 {
panic("no return value specified for UserWillLogIn")
}
var r0 string
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.User) string); ok {
r0 = rf(c, user)
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// WebSocketMessageHasBeenPosted provides a mock function with given fields: webConnID, userID, req
func (_m *Hooks) WebSocketMessageHasBeenPosted(webConnID string, userID string, req *model.WebSocketRequest) {
_m.Called(webConnID, userID, req)
}
// NewHooks creates a new instance of Hooks. 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 NewHooks(t interface {
mock.TestingT
Cleanup(func())
}) *Hooks {
mock := &Hooks{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,114 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Hand-written *WithRPCErr mock methods for the three hooks excluded from the code generator.
// The auto-generated hooks.go (regenerated by `make plugin-mocks`) covers the base Hooks interface;
// this file adds the extra *WithRPCErr companions so *Hooks satisfies plugin.HooksWithRPCErr.
// This file is not overwritten by `make plugin-mocks` because mockery writes only hooks.go for the
// Hooks interface (filename: "{{.InterfaceNameLower}}.go" in .mockery.yaml).
package plugintest
import (
model "github.com/mattermost/mattermost/server/public/model"
plugin "github.com/mattermost/mattermost/server/public/plugin"
)
// MessageWillBePostedWithRPCErr provides a mock function with given fields: c, post
func (_m *Hooks) MessageWillBePostedWithRPCErr(c *plugin.Context, post *model.Post) (*model.Post, string, error) {
ret := _m.Called(c, post)
var r0 *model.Post
var r1 string
var r2 error
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Post) (*model.Post, string, error)); ok {
return rf(c, post)
}
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Post) *model.Post); ok {
r0 = rf(c, post)
} else if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Post)
}
if rf, ok := ret.Get(1).(func(*plugin.Context, *model.Post) string); ok {
r1 = rf(c, post)
} else {
r1 = ret.Get(1).(string)
}
if rf, ok := ret.Get(2).(func(*plugin.Context, *model.Post) error); ok {
r2 = rf(c, post)
} else {
r2 = ret.Error(2)
}
return r0, r1, r2
}
// MessageWillBeUpdatedWithRPCErr provides a mock function with given fields: c, newPost, oldPost
func (_m *Hooks) MessageWillBeUpdatedWithRPCErr(c *plugin.Context, newPost *model.Post, oldPost *model.Post) (*model.Post, string, error) {
ret := _m.Called(c, newPost, oldPost)
var r0 *model.Post
var r1 string
var r2 error
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Post, *model.Post) (*model.Post, string, error)); ok {
return rf(c, newPost, oldPost)
}
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.Post, *model.Post) *model.Post); ok {
r0 = rf(c, newPost, oldPost)
} else if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Post)
}
if rf, ok := ret.Get(1).(func(*plugin.Context, *model.Post, *model.Post) string); ok {
r1 = rf(c, newPost, oldPost)
} else {
r1 = ret.Get(1).(string)
}
if rf, ok := ret.Get(2).(func(*plugin.Context, *model.Post, *model.Post) error); ok {
r2 = rf(c, newPost, oldPost)
} else {
r2 = ret.Error(2)
}
return r0, r1, r2
}
// ChannelMemberWillBeAddedWithRPCErr provides a mock function with given fields: c, channelMember
func (_m *Hooks) ChannelMemberWillBeAddedWithRPCErr(c *plugin.Context, channelMember *model.ChannelMember) (*model.ChannelMember, string, error) {
ret := _m.Called(c, channelMember)
var r0 *model.ChannelMember
var r1 string
var r2 error
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.ChannelMember) (*model.ChannelMember, string, error)); ok {
return rf(c, channelMember)
}
if rf, ok := ret.Get(0).(func(*plugin.Context, *model.ChannelMember) *model.ChannelMember); ok {
r0 = rf(c, channelMember)
} else if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.ChannelMember)
}
if rf, ok := ret.Get(1).(func(*plugin.Context, *model.ChannelMember) string); ok {
r1 = rf(c, channelMember)
} else {
r1 = ret.Get(1).(string)
}
if rf, ok := ret.Get(2).(func(*plugin.Context, *model.ChannelMember) error); ok {
r2 = rf(c, channelMember)
} else {
r2 = ret.Error(2)
}
return r0, r1, r2
}
// Note: plugintest.Hooks is a mock for the base Hooks interface only. The auto-generated
// hooks.go does not include *WithRPCErr methods, so *Hooks cannot satisfy HooksWithRPCErr
// in full. The production compile-time assertions for HooksWithRPCErr live in client_rpc.go
// (for *hooksRPCClient and *hooksTimerLayer). Tests that need a HooksWithRPCErr double
// should embed *Hooks and add the needed *WithRPCErr stubs directly.

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// This package provides aliases for the contents of "github.com/stretchr/testify/mock". Because
// external packages can't import our vendored dependencies, this is necessary for them to be able
// to fully utilize the plugintest package.
package mock
import (
"github.com/stretchr/testify/mock"
)
const (
Anything = mock.Anything
)
type Arguments = mock.Arguments
type AnythingOfTypeArgument = mock.AnythingOfTypeArgument
type Call = mock.Call
type Mock = mock.Mock
type TestingT = mock.TestingT
func AnythingOfType(t string) AnythingOfTypeArgument {
return mock.AnythingOfType(t)
}
func AssertExpectationsForObjects(t TestingT, testObjects ...any) bool {
return mock.AssertExpectationsForObjects(t, testObjects...)
}
func MatchedBy(fn any) any {
return mock.MatchedBy(fn)
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"fmt"
)
func stringify(objects []any) []string {
stringified := make([]string, len(objects))
for i, object := range objects {
stringified[i] = fmt.Sprintf("%+v", object)
}
return stringified
}
func toObjects(strings []string) []any {
if strings == nil {
return nil
}
objects := make([]any, len(strings))
for i, string := range strings {
objects[i] = string
}
return objects
}
func stringifyToObjects(objects []any) []any {
return toObjects(stringify(objects))
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"testing"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
)
func TestStringify(t *testing.T) {
t.Run("NilShouldReturnEmpty", func(t *testing.T) {
strings := stringify(nil)
assert.Empty(t, strings)
})
t.Run("EmptyShouldReturnEmpty", func(t *testing.T) {
strings := stringify(make([]any, 0))
assert.Empty(t, strings)
})
t.Run("PrimitivesAndCompositesShouldReturnCorrectValues", func(t *testing.T) {
strings := stringify([]any{
1234,
3.14159265358979323846264338327950288419716939937510,
true,
"foo",
nil,
[]string{"foo", "bar"},
map[string]int{"one": 1, "two": 2},
&WithString{},
&WithoutString{},
&WithStringAndError{},
})
assert.Equal(t, []string{
"1234",
"3.141592653589793",
"true",
"foo",
"<nil>",
"[foo bar]",
"map[one:1 two:2]",
"string",
"&{}",
"error",
}, strings)
})
t.Run("ErrorShouldReturnFormattedStack", func(t *testing.T) {
strings := stringify([]any{
errors.New("error"),
errors.WithStack(errors.New("error")),
})
stackRegexp := "error\n.*plugin.TestStringify.func\\d+\n\t.*plugin/stringifier_test.go:\\d+\ntesting.tRunner\n\t.*testing.go:\\d+.*"
assert.Len(t, strings, 2)
assert.Regexp(t, stackRegexp, strings[0])
assert.Regexp(t, stackRegexp, strings[1])
})
}
type WithString struct {
}
func (*WithString) String() string {
return "string"
}
type WithoutString struct {
}
type WithStringAndError struct {
}
func (*WithStringAndError) String() string {
return "string"
}
func (*WithStringAndError) Error() string {
return "error"
}
func TestToObjects(t *testing.T) {
t.Run("NilShouldReturnNil", func(t *testing.T) {
objects := toObjects(nil)
assert.Nil(t, objects)
})
t.Run("EmptyShouldReturnEmpty", func(t *testing.T) {
objects := toObjects(make([]string, 0))
assert.Empty(t, objects)
})
t.Run("ShouldReturnSliceOfObjects", func(t *testing.T) {
objects := toObjects([]string{"foo", "bar"})
assert.Equal(t, []any{"foo", "bar"}, objects)
})
}

View File

@@ -0,0 +1,259 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"crypto/sha256"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
plugin "github.com/hashicorp/go-plugin"
"github.com/pkg/errors"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
type supervisor struct {
lock sync.RWMutex
pluginID string
appDriver AppDriver
client *plugin.Client
hooks *hooksTimerLayer
implemented [TotalHooksID]bool
hooksClient *hooksRPCClient
isReattached bool
}
type driverForPlugin struct {
AppDriver
pluginID string
}
func (d *driverForPlugin) Conn(isMaster bool) (string, error) {
return d.AppDriver.ConnWithPluginID(isMaster, d.pluginID)
}
func WithExecutableFromManifest(pluginInfo *model.BundleInfo) func(*supervisor, *plugin.ClientConfig) error {
return func(_ *supervisor, clientConfig *plugin.ClientConfig) error {
executable := pluginInfo.Manifest.GetExecutableForRuntime(runtime.GOOS, runtime.GOARCH)
if executable == "" {
return fmt.Errorf("backend executable not found for environment: %s/%s", runtime.GOOS, runtime.GOARCH)
}
executable = filepath.Clean(filepath.Join(".", executable))
if strings.HasPrefix(executable, "..") {
return fmt.Errorf("invalid backend executable: %s", executable)
}
executable = filepath.Join(pluginInfo.Path, executable)
cmd := exec.Command(executable)
// This doesn't add more security than before
// but removes the SecureConfig is nil warning.
// https://mattermost.atlassian.net/browse/MM-49167
pluginChecksum, err := getPluginExecutableChecksum(executable)
if err != nil {
return errors.Wrapf(err, "unable to generate plugin checksum")
}
clientConfig.Cmd = cmd
clientConfig.SecureConfig = &plugin.SecureConfig{
Checksum: pluginChecksum,
Hash: sha256.New(),
}
return nil
}
}
func WithReattachConfig(pluginReattachConfig *model.PluginReattachConfig) func(*supervisor, *plugin.ClientConfig) error {
return func(sup *supervisor, clientConfig *plugin.ClientConfig) error {
clientConfig.Reattach = pluginReattachConfig.ToHashicorpPluginReattachmentConfig()
sup.isReattached = true
return nil
}
}
func newSupervisor(pluginInfo *model.BundleInfo, apiImpl API, driver AppDriver, parentLogger *mlog.Logger, metrics metricsInterface, opts ...func(*supervisor, *plugin.ClientConfig) error) (retSupervisor *supervisor, retErr error) {
sup := supervisor{
pluginID: pluginInfo.Manifest.Id,
}
if driver != nil {
sup.appDriver = &driverForPlugin{AppDriver: driver, pluginID: pluginInfo.Manifest.Id}
}
defer func() {
if retErr != nil {
sup.Shutdown()
}
}()
wrappedLogger := pluginInfo.WrapLogger(parentLogger)
hclogAdaptedLogger := &hclogAdapter{
wrappedLogger: wrappedLogger,
extrasKey: "wrapped_extras",
}
pluginMap := map[string]plugin.Plugin{
"hooks": &hooksPlugin{
log: wrappedLogger,
driverImpl: sup.appDriver,
apiImpl: &apiTimerLayer{pluginInfo.Manifest.Id, apiImpl, metrics},
},
}
clientConfig := &plugin.ClientConfig{
HandshakeConfig: handshake,
Plugins: pluginMap,
SyncStdout: wrappedLogger.With(mlog.String("source", "plugin_stdout")).StdLogWriter(),
SyncStderr: wrappedLogger.With(mlog.String("source", "plugin_stderr")).StdLogWriter(),
Logger: hclogAdaptedLogger,
StartTimeout: time.Second * 3,
}
for _, opt := range opts {
err := opt(&sup, clientConfig)
if err != nil {
return nil, errors.Wrap(err, "failed to apply option")
}
}
sup.client = plugin.NewClient(clientConfig)
rpcClient, err := sup.client.Client()
if err != nil {
return nil, err
}
raw, err := rpcClient.Dispense("hooks")
if err != nil {
return nil, err
}
c, ok := raw.(*hooksRPCClient)
if ok {
sup.hooksClient = c
}
sup.hooks = &hooksTimerLayer{pluginInfo.Manifest.Id, raw.(Hooks), raw.(HooksWithRPCErr), metrics}
impl, err := sup.hooks.Implemented()
if err != nil {
return nil, err
}
for _, hookName := range impl {
if hookId, ok := hookNameToId[hookName]; ok {
sup.implemented[hookId] = true
}
}
return &sup, nil
}
func (sup *supervisor) Shutdown() {
sup.lock.RLock()
defer sup.lock.RUnlock()
if sup.client != nil {
// For reattached plugins, Kill() is mostly a no-op, so manually clean up the
// underlying rpcClient. This might be something to upstream unless we're doing
// something else wrong.
if sup.isReattached {
rpcClient, err := sup.client.Client()
if err != nil {
mlog.Warn("Failed to obtain rpcClient on Shutdown")
} else {
if err = rpcClient.Close(); err != nil {
mlog.Warn("Failed to close rpcClient on Shutdown")
}
}
}
sup.client.Kill()
}
// Wait for API RPC server and DB RPC server to exit.
// And then shutdown conns.
if sup.hooksClient != nil {
sup.hooksClient.doneWg.Wait()
if sup.appDriver != nil {
sup.appDriver.ShutdownConns(sup.pluginID)
}
}
}
func (sup *supervisor) Hooks() Hooks {
sup.lock.RLock()
defer sup.lock.RUnlock()
return sup.hooks
}
func (sup *supervisor) HooksWithRPCErr() HooksWithRPCErr {
sup.lock.RLock()
defer sup.lock.RUnlock()
return sup.hooks
}
// PerformHealthCheck checks the plugin through an RPC ping.
func (sup *supervisor) PerformHealthCheck() error {
// No need for a lock here because Ping is read-locked.
if pingErr := sup.Ping(); pingErr != nil {
for pingFails := 1; pingFails < HealthCheckPingFailLimit; pingFails++ {
pingErr = sup.Ping()
if pingErr == nil {
break
}
}
if pingErr != nil {
return fmt.Errorf("plugin RPC connection is not responding")
}
}
return nil
}
// Ping checks that the RPC connection with the plugin is alive and healthy.
func (sup *supervisor) Ping() error {
sup.lock.RLock()
defer sup.lock.RUnlock()
client, err := sup.client.Client()
if err != nil {
return err
}
return client.Ping()
}
func (sup *supervisor) Implements(hookId int) bool {
sup.lock.RLock()
defer sup.lock.RUnlock()
return sup.implemented[hookId]
}
func getPluginExecutableChecksum(executablePath string) ([]byte, error) {
pathHash := sha256.New()
file, err := os.Open(executablePath)
if err != nil {
return nil, err
}
defer file.Close()
_, err = io.Copy(pathHash, file)
if err != nil {
return nil, err
}
return pathHash.Sum(nil), nil
}

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"os"
"path/filepath"
"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/utils"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func TestSupervisor(t *testing.T) {
for name, f := range map[string]func(*testing.T){
"Supervisor_InvalidExecutablePath": testSupervisorInvalidExecutablePath,
"Supervisor_NonExistentExecutablePath": testSupervisorNonExistentExecutablePath,
"Supervisor_StartTimeout": testSupervisorStartTimeout,
} {
t.Run(name, f)
}
}
func testSupervisorInvalidExecutablePath(t *testing.T) {
dir, err := os.MkdirTemp("", "")
require.NoError(t, err)
defer os.RemoveAll(dir)
os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"id": "foo", "server": {"executable": "/foo/../../backend.exe"}}`), 0600)
bundle := model.BundleInfoForPath(dir)
logger := mlog.CreateConsoleTestLogger(t)
supervisor, err := newSupervisor(bundle, nil, nil, logger, nil)
assert.Nil(t, supervisor)
assert.Error(t, err)
}
func testSupervisorNonExistentExecutablePath(t *testing.T) {
dir, err := os.MkdirTemp("", "")
require.NoError(t, err)
defer os.RemoveAll(dir)
os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"id": "foo", "server": {"executable": "thisfileshouldnotexist"}}`), 0600)
bundle := model.BundleInfoForPath(dir)
logger := mlog.CreateConsoleTestLogger(t)
supervisor, err := newSupervisor(bundle, nil, nil, logger, nil)
require.Error(t, err)
require.Nil(t, supervisor)
}
// If plugin development goes really wrong, let's make sure plugin activation won't block forever.
func testSupervisorStartTimeout(t *testing.T) {
dir, err := os.MkdirTemp("", "")
require.NoError(t, err)
defer os.RemoveAll(dir)
backend := filepath.Join(dir, "backend.exe")
utils.CompileGo(t, `
package main
func main() {
for {
}
}
`, backend)
os.WriteFile(filepath.Join(dir, "plugin.json"), []byte(`{"id": "foo", "server": {"executable": "backend.exe"}}`), 0600)
bundle := model.BundleInfoForPath(dir)
logger := mlog.CreateConsoleTestLogger(t)
supervisor, err := newSupervisor(bundle, nil, nil, logger, nil)
require.Error(t, err)
require.Nil(t, supervisor)
}

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package utils
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/require"
)
func CompileGo(t *testing.T, sourceCode, outputPath string) {
compileGo(t, "go", sourceCode, outputPath)
}
func CompileGoVersion(t *testing.T, goVersion, sourceCode, outputPath string) {
var goBin string
if goVersion != "" {
goBin = os.Getenv("GOBIN")
}
compileGo(t, filepath.Join(goBin, "go"+goVersion), sourceCode, outputPath)
}
func compileGo(t *testing.T, goBin, sourceCode, outputPath string) {
dir, err := os.MkdirTemp(".", "")
require.NoError(t, err)
defer os.RemoveAll(dir)
dir, err = filepath.Abs(dir)
require.NoError(t, err)
// Write out main.go given the source code.
main := filepath.Join(dir, "main.go")
err = os.WriteFile(main, []byte(sourceCode), 0600)
require.NoError(t, err)
_, sourceFile, _, ok := runtime.Caller(0)
require.True(t, ok)
serverPath := filepath.Dir(filepath.Dir(sourceFile))
out := &bytes.Buffer{}
cmd := exec.Command(goBin, "build", "-o", outputPath, main)
cmd.Dir = serverPath
cmd.Stdout = out
cmd.Stderr = out
err = cmd.Run()
if err != nil {
t.Log("Go compile errors:\n", out.String())
}
require.NoError(t, err, "failed to compile go")
}
func CompileGoTest(t *testing.T, sourceCode, outputPath string) {
dir, err := os.MkdirTemp(".", "")
require.NoError(t, err)
defer os.RemoveAll(dir)
dir, err = filepath.Abs(dir)
require.NoError(t, err)
// Write out main.go given the source code.
main := filepath.Join(dir, "main_test.go")
err = os.WriteFile(main, []byte(sourceCode), 0600)
require.NoError(t, err)
_, sourceFile, _, ok := runtime.Caller(0)
require.True(t, ok)
serverPath := filepath.Dir(filepath.Dir(sourceFile))
out := &bytes.Buffer{}
cmd := exec.Command("go", "test", "-c", "-o", outputPath, main)
cmd.Dir = serverPath
cmd.Stdout = out
cmd.Stderr = out
err = cmd.Run()
if err != nil {
t.Log("Go compile errors:\n", out.String())
}
require.NoError(t, err, "failed to compile go")
}