snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
13
server/public/plugin/.mockery.yaml
Normal file
13
server/public/plugin/.mockery.yaml
Normal 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
1794
server/public/plugin/api.go
Normal file
File diff suppressed because it is too large
Load Diff
1834
server/public/plugin/api_timer_layer_generated.go
Normal file
1834
server/public/plugin/api_timer_layer_generated.go
Normal file
File diff suppressed because it is too large
Load Diff
70
server/public/plugin/audit.go
Normal file
70
server/public/plugin/audit.go
Normal 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
|
||||
}
|
||||
50
server/public/plugin/checker/check_api.go
Normal file
50
server/public/plugin/checker/check_api.go
Normal 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
|
||||
}
|
||||
58
server/public/plugin/checker/check_api_test.go
Normal file
58
server/public/plugin/checker/check_api_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
127
server/public/plugin/checker/internal/asthelpers/helpers.go
Normal file
127
server/public/plugin/checker/internal/asthelpers/helpers.go
Normal 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 ""
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
44
server/public/plugin/checker/internal/test/valid/valid.go
Normal file
44
server/public/plugin/checker/internal/test/valid/valid.go
Normal 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()
|
||||
}
|
||||
22
server/public/plugin/checker/internal/version/comments.go
Normal file
22
server/public/plugin/checker/internal/version/comments.go
Normal 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 ""
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
74
server/public/plugin/checker/internal/version/version.go
Normal file
74
server/public/plugin/checker/internal/version/version.go
Normal 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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
71
server/public/plugin/checker/main.go
Normal file
71
server/public/plugin/checker/main.go
Normal 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
|
||||
}
|
||||
29
server/public/plugin/checker/render.go
Normal file
29
server/public/plugin/checker/render.go
Normal 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)
|
||||
}
|
||||
106
server/public/plugin/client.go
Normal file
106
server/public/plugin/client.go
Normal 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
|
||||
}
|
||||
1846
server/public/plugin/client_rpc.go
Normal file
1846
server/public/plugin/client_rpc.go
Normal file
File diff suppressed because it is too large
Load Diff
46
server/public/plugin/client_rpc_test.go
Normal file
46
server/public/plugin/client_rpc_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
16
server/public/plugin/context.go
Normal file
16
server/public/plugin/context.go
Normal 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
|
||||
}
|
||||
223
server/public/plugin/context_rpc.go
Normal file
223
server/public/plugin/context_rpc.go
Normal 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()
|
||||
}
|
||||
}
|
||||
604
server/public/plugin/context_rpc_test.go
Normal file
604
server/public/plugin/context_rpc_test.go
Normal 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())
|
||||
}
|
||||
459
server/public/plugin/db_rpc.go
Normal file
459
server/public/plugin/db_rpc.go
Normal 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
|
||||
}
|
||||
9
server/public/plugin/doc.go
Normal file
9
server/public/plugin/doc.go
Normal 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
|
||||
73
server/public/plugin/driver.go
Normal file
73
server/public/plugin/driver.go
Normal 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)
|
||||
}
|
||||
785
server/public/plugin/environment.go
Normal file
785
server/public/plugin/environment.go
Normal 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
|
||||
}
|
||||
278
server/public/plugin/environment_shutdown_state_test.go
Normal file
278
server/public/plugin/environment_shutdown_state_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
143
server/public/plugin/environment_test.go
Normal file
143
server/public/plugin/environment_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
290
server/public/plugin/environment_with_rpcerr_test.go
Normal file
290
server/public/plugin/environment_with_rpcerr_test.go
Normal 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)
|
||||
}
|
||||
28
server/public/plugin/example_hello_world_test.go
Normal file
28
server/public/plugin/example_hello_world_test.go
Normal 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{})
|
||||
}
|
||||
120
server/public/plugin/example_help_test.go
Normal file
120
server/public/plugin/example_help_test.go
Normal 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{})
|
||||
}
|
||||
135
server/public/plugin/hclog_adapter.go
Normal file
135
server/public/plugin/hclog_adapter.go
Normal 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"
|
||||
}
|
||||
124
server/public/plugin/health_check.go
Normal file
124
server/public/plugin/health_check.go
Normal 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
|
||||
}
|
||||
143
server/public/plugin/health_check_test.go
Normal file
143
server/public/plugin/health_check_test.go
Normal 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)
|
||||
}
|
||||
205
server/public/plugin/hijack.go
Normal file
205
server/public/plugin/hijack.go
Normal 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
|
||||
}
|
||||
536
server/public/plugin/hooks.go
Normal file
536
server/public/plugin/hooks.go
Normal 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)
|
||||
}
|
||||
672
server/public/plugin/hooks_timer_layer_generated.go
Normal file
672
server/public/plugin/hooks_timer_layer_generated.go
Normal 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
|
||||
}
|
||||
41
server/public/plugin/hooks_timer_layer_manual.go
Normal file
41
server/public/plugin/hooks_timer_layer_manual.go
Normal 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
|
||||
}
|
||||
119
server/public/plugin/http.go
Normal file
119
server/public/plugin/http.go
Normal 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),
|
||||
}
|
||||
}
|
||||
40
server/public/plugin/http_test.go
Normal file
40
server/public/plugin/http_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
756
server/public/plugin/interface_generator/main.go
Normal file
756
server/public/plugin/interface_generator/main.go
Normal 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)
|
||||
}
|
||||
46
server/public/plugin/io_rpc.go
Normal file
46
server/public/plugin/io_rpc.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
11
server/public/plugin/metrics.go
Normal file
11
server/public/plugin/metrics.go
Normal 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)
|
||||
}
|
||||
6996
server/public/plugin/plugintest/api.go
Normal file
6996
server/public/plugin/plugintest/api.go
Normal file
File diff suppressed because it is too large
Load Diff
11
server/public/plugin/plugintest/doc.go
Normal file
11
server/public/plugin/plugintest/doc.go
Normal 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
|
||||
480
server/public/plugin/plugintest/driver.go
Normal file
480
server/public/plugin/plugintest/driver.go
Normal 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
|
||||
}
|
||||
58
server/public/plugin/plugintest/example_hello_user_test.go
Normal file
58
server/public/plugin/plugintest/example_hello_user_test.go
Normal 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))
|
||||
}
|
||||
85
server/public/plugin/plugintest/example_reattach_test.go
Normal file
85
server/public/plugin/plugintest/example_reattach_test.go
Normal 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.
|
||||
}
|
||||
883
server/public/plugin/plugintest/hooks.go
Normal file
883
server/public/plugin/plugintest/hooks.go
Normal 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
|
||||
}
|
||||
114
server/public/plugin/plugintest/hooks_with_rpcerr.go
Normal file
114
server/public/plugin/plugintest/hooks_with_rpcerr.go
Normal 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.
|
||||
33
server/public/plugin/plugintest/mock/mock.go
Normal file
33
server/public/plugin/plugintest/mock/mock.go
Normal 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)
|
||||
}
|
||||
31
server/public/plugin/stringifier.go
Normal file
31
server/public/plugin/stringifier.go
Normal 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))
|
||||
}
|
||||
94
server/public/plugin/stringifier_test.go
Normal file
94
server/public/plugin/stringifier_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
259
server/public/plugin/supervisor.go
Normal file
259
server/public/plugin/supervisor.go
Normal 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
|
||||
}
|
||||
80
server/public/plugin/supervisor_test.go
Normal file
80
server/public/plugin/supervisor_test.go
Normal 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)
|
||||
}
|
||||
85
server/public/plugin/utils/test_files_compiler.go
Normal file
85
server/public/plugin/utils/test_files_compiler.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user