Files
ce5644223a [MM-68400] Four plugin hooks and ChannelGuard enforcement (#36152)
* allow workflow_dispatch trigger for Server CI (for plugins CI)

* [MM-68402] MBE Phase 2: declare four generic plugin hooks (#36291)

* new hooks-only phase 2

* remove ChannelWillBeMoved

* remove RecapWillBeProcessed and MessageWillBeRewrittenByAI

Drop the AI/recap hooks from the new-hook surface; AI-LLM paths
remain uncovered in tech preview and are documented as residuals.

* [MM-68403] MBE Phase 3: ChannelGuards primitive (storage + cache + plugin API) (#36365)

* phase 3

* phase 3: register ChannelGuard mock in test setup helper

NewChannels' startup-time call to reloadGuardCache invokes
s.ChannelGuard().GetAll(); without an expectation on the mock store,
every test that sets up the server with GetMockStoreForSetupFunctions
panics during init.

* phase 3: register ChannelGuard mock in retrylayer test

retrylayer.New walks every store getter to wrap it; without the mock
expectation on ChannelGuard, TestRetry panics during layer construction.

* use rctx properly in the store methods

* phase 3: match rctx arg in testlib ChannelGuard mock

GetAll now takes request.CTX, so the testify expectation must include
mock.Anything; otherwise the call panics under the mocked store.

* phase 3: set api.ctx in TestChannelGuardLowercaseNormalization

The test constructs PluginAPI directly without a ctx, which used to
work when App.RegisterChannelGuard built its own EmptyContext. Now
that the App methods take rctx from the caller, the nil ctx panics
inside RequestContextWithMaster.

* [MM-68404] MBE Phase 4: App-layer plugin hook wiring (#36407)

* phase 4

* Fix nil rctx in TestChannelGuardLowercaseNormalization

The PluginAPI struct literal was missing ctx: rctx after a refactor
moved the rctx declaration below the struct construction, leaving
api.ctx as nil. This caused a nil pointer dereference in reloadGuardCache
when RegisterChannelGuard called store.RequestContextWithMaster(nil).

* Remove ChannelWillBeMoved hook call from MoveChannel (phase 4)

The hook and its ID were removed from mbe-phase-2 but the call site in
MoveChannel and its i18n string were not cleaned up during the rebase.

* remove channel will be moved test

* Remove RecapWillBeProcessed and MessageWillBeRewrittenByAI hook calls (phase 4)

The hooks and their IDs were removed from mbe-phase-2 but the call sites
in ProcessRecapChannel and RewriteMessage, their i18n strings, and their
tests were not cleaned up during the rebase.

* Revert channel_id plumbing on rewrite endpoint (phase 4)

The channel_id field on RewriteRequest was added in phase 4 to feed the
synthetic post passed to MessageWillBeRewrittenByAI. With that hook
removed from mbe-phase-2, channel_id has no consumer; revert the field,
the api4 validation, the app.RewriteMessage parameter, and the
corresponding webapp client + hook plumbing.

* [MM-68555] MBE Phase 5: Channel-guard enforcement + two-phase dispatch (#36473)

* phase 5

* Bake plugin counter-file paths into source instead of env vars

t.Setenv panics when an ancestor test calls t.Parallel, so the two
channel-guard tests broke under ENABLE_FULLY_PARALLEL_TESTS in CI.
Build each plugin source per-subtest with its temp file path embedded
as a Go literal — same pattern as TestPluginUploadsAPI.

* Remove guarded helpers and tests for dropped hooks (phase 5)

The runGuardedRecapWillBeProcessed and runGuardedMessageWillBeRewrittenByAI
helpers were never wired (their app-layer call sites were already removed
in the phase-4 cleanup), and the corresponding sub-tests across panic /
allow / reject / partial plugins reference hooks that no longer exist.

* [MM-68405] MBE Phase 6: fire MessagesWillBeConsumed on the edit path (#36475)

---------

---------

---------

---------

* rebase onto master

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-05-21 18:16:05 +00:00

278 lines
7.5 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"bytes"
"fmt"
"go/ast"
"go/format"
"go/parser"
"go/token"
"io"
"log"
"os"
"path"
"slices"
"strings"
"text/template"
)
const (
ErrorType = "error"
)
func isError(typeName string) bool {
return strings.Contains(typeName, ErrorType)
}
func main() {
if err := buildTimerLayer(); err != nil {
log.Fatal(err)
}
if err := buildRetryLayer(); err != nil {
log.Fatal(err)
}
}
func buildRetryLayer() error {
code, err := generateLayer("RetryLayer", "retry_layer.go.tmpl")
if err != nil {
return err
}
formatedCode, err := format.Source(code)
if err != nil {
return err
}
return os.WriteFile(path.Join("retrylayer/retrylayer.go"), formatedCode, 0644)
}
func buildTimerLayer() error {
code, err := generateLayer("TimerLayer", "timer_layer.go.tmpl")
if err != nil {
return err
}
formatedCode, err := format.Source(code)
if err != nil {
return err
}
return os.WriteFile(path.Join("timerlayer", "timerlayer.go"), formatedCode, 0644)
}
type methodParam struct {
Name string
Type string
}
type methodData struct {
Params []methodParam
Results []string
}
type subStore struct {
Methods map[string]methodData
}
type storeMetadata struct {
Name string
SubStores map[string]subStore
Methods map[string]methodData
}
func extractMethodMetadata(method *ast.Field, src []byte) methodData {
params := []methodParam{}
results := []string{}
ast.Inspect(method.Type, func(expr ast.Node) bool {
switch e := expr.(type) {
case *ast.FuncType:
if e.Params != nil {
for _, param := range e.Params.List {
for _, paramName := range param.Names {
params = append(params, methodParam{Name: paramName.Name, Type: string(src[param.Type.Pos()-1 : param.Type.End()-1])})
}
}
}
if e.Results != nil {
for _, result := range e.Results.List {
results = append(results, string(src[result.Type.Pos()-1:result.Type.End()-1]))
}
}
}
return true
})
return methodData{Params: params, Results: results}
}
func extractStoreMetadata() (*storeMetadata, error) {
// Create the AST by parsing src.
fset := token.NewFileSet() // positions are relative to fset
file, err := os.Open("store.go")
if err != nil {
return nil, fmt.Errorf("unable to open store/store.go file: %w", err)
}
src, err := io.ReadAll(file)
if err != nil {
return nil, err
}
file.Close()
f, err := parser.ParseFile(fset, "", src, parser.AllErrors|parser.ParseComments)
if err != nil {
return nil, err
}
topLevelFunctions := map[string]bool{
"MarkSystemRanUnitTests": false,
"Close": false,
"LockToMaster": false,
"UnlockFromMaster": false,
"DropAllTables": false,
"TotalMasterDbConnections": true,
"TotalReadDbConnections": true,
"SetContext": true,
"TotalSearchDbConnections": true,
"GetCurrentSchemaVersion": true,
}
metadata := storeMetadata{Methods: map[string]methodData{}, SubStores: map[string]subStore{}}
ast.Inspect(f, func(n ast.Node) bool {
switch x := n.(type) {
case *ast.TypeSpec:
if x.Name.Name == "Store" {
for _, method := range x.Type.(*ast.InterfaceType).Methods.List {
methodName := method.Names[0].Name
if _, ok := topLevelFunctions[methodName]; ok {
metadata.Methods[methodName] = extractMethodMetadata(method, src)
}
}
} else if subStoreName, ok := strings.CutSuffix(x.Name.Name, "Store"); ok {
metadata.SubStores[subStoreName] = subStore{Methods: map[string]methodData{}}
for _, method := range x.Type.(*ast.InterfaceType).Methods.List {
methodName := method.Names[0].Name
metadata.SubStores[subStoreName].Methods[methodName] = extractMethodMetadata(method, src)
}
}
}
return true
})
return &metadata, nil
}
func generateLayer(name, templateFile string) ([]byte, error) {
out := bytes.NewBufferString("")
metadata, err := extractStoreMetadata()
if err != nil {
return nil, err
}
metadata.Name = name
myFuncs := template.FuncMap{
"joinResults": func(results []string) string {
return strings.Join(results, ", ")
},
"joinResultsForSignature": func(results []string) string {
if len(results) == 0 {
return ""
}
returns := []string{}
for _, result := range results {
switch result {
case "*PostReminderMetadata":
returns = append(returns, fmt.Sprintf("*store.%s", strings.TrimPrefix(result, "*")))
case "[]*ChannelGuard":
returns = append(returns, fmt.Sprintf("[]*store.%s", strings.TrimPrefix(result, "[]*")))
default:
returns = append(returns, result)
}
}
if len(returns) == 1 {
return strings.Join(returns, ", ")
}
return fmt.Sprintf("(%s)", strings.Join(returns, ", "))
},
"genResultsVars": func(results []string, withNilError bool) string {
vars := []string{}
for i, typeName := range results {
if isError(typeName) {
if withNilError {
vars = append(vars, "nil")
} else {
vars = append(vars, "err")
}
} else if i == 0 {
vars = append(vars, "result")
} else {
vars = append(vars, fmt.Sprintf("resultVar%d", i))
}
}
return strings.Join(vars, ", ")
},
"errorToBoolean": func(results []string) string {
if slices.ContainsFunc(results, isError) {
return "err == nil"
}
return "true"
},
"errorPresent": func(results []string) bool {
return slices.ContainsFunc(results, isError)
},
"errorVar": func(results []string) string {
if slices.ContainsFunc(results, isError) {
return "err"
}
return ""
},
"joinParams": func(params []methodParam) string {
paramsNames := make([]string, 0, len(params))
for _, param := range params {
tParams := ""
if strings.HasPrefix(param.Type, "...") {
tParams = "..."
}
paramsNames = append(paramsNames, param.Name+tParams)
}
return strings.Join(paramsNames, ", ")
},
"joinParamsWithType": func(params []methodParam) string {
paramsWithType := []string{}
for _, param := range params {
switch param.Type {
case "ChannelSearchOpts", "UserGetByIdsOpts", "ThreadMembershipOpts", "GetPolicyOptions":
paramsWithType = append(paramsWithType, fmt.Sprintf("%s store.%s", param.Name, param.Type))
case "*UserGetByIdsOpts", "*SidebarCategorySearchOpts", "*ChannelGuard":
paramsWithType = append(paramsWithType, fmt.Sprintf("%s *store.%s", param.Name, strings.TrimPrefix(param.Type, "*")))
default:
paramsWithType = append(paramsWithType, fmt.Sprintf("%s %s", param.Name, param.Type))
}
}
return strings.Join(paramsWithType, ", ")
},
"joinParamsWithTypeOutsideStore": func(params []methodParam) string {
paramsWithType := []string{}
for _, param := range params {
switch param.Type {
case "ChannelSearchOpts", "UserGetByIdsOpts", "ThreadMembershipOpts", "GetPolicyOptions":
paramsWithType = append(paramsWithType, fmt.Sprintf("%s store.%s", param.Name, param.Type))
case "*UserGetByIdsOpts", "*SidebarCategorySearchOpts", "*ChannelGuard":
paramsWithType = append(paramsWithType, fmt.Sprintf("%s *store.%s", param.Name, strings.TrimPrefix(param.Type, "*")))
default:
paramsWithType = append(paramsWithType, fmt.Sprintf("%s %s", param.Name, param.Type))
}
}
return strings.Join(paramsWithType, ", ")
},
}
t := template.Must(template.New(templateFile).Funcs(myFuncs).ParseFiles("layer_generators/" + templateFile))
if err = t.Execute(out, metadata); err != nil {
return nil, err
}
return out.Bytes(), nil
}