mattermost/server/public/plugin/python_supervisor_test.go
Nick Misasi d486fb74f6 test(12-01): add tests for Python API callback server
- Add TestPythonAPIServerStartup: verify server starts and registrar called
- Add TestPythonAPIServerLifecycle: verify cleanup stops server properly
- Add TestPythonCommandWithAPIServer: verify env var not set when apiImpl is nil

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 09:58:28 -05:00

1428 lines
43 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"context"
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
plugin "github.com/hashicorp/go-plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/utils"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func TestIsPythonPlugin(t *testing.T) {
tests := []struct {
name string
manifest *model.Manifest
expected bool
}{
{
name: "nil manifest",
manifest: nil,
expected: false,
},
{
name: "Go plugin with executable",
manifest: &model.Manifest{
Id: "go-plugin",
Server: &model.ManifestServer{
Executable: "plugin",
},
},
expected: false,
},
{
name: "Python plugin with .py extension",
manifest: &model.Manifest{
Id: "python-plugin",
Server: &model.ManifestServer{
Executable: "plugin.py",
},
},
expected: true,
},
{
name: "python runtime from manifest field",
manifest: &model.Manifest{
Id: "python-runtime-manifest",
Server: &model.ManifestServer{
Runtime: "python",
Executable: "server/plugin", // No .py extension
},
},
expected: true,
},
{
name: "go runtime explicit",
manifest: &model.Manifest{
Id: "go-runtime-explicit",
Server: &model.ManifestServer{
Runtime: "go",
Executable: "server/plugin",
},
},
expected: false,
},
{
name: "python with full config",
manifest: &model.Manifest{
Id: "python-full-config",
Server: &model.ManifestServer{
Runtime: "python",
PythonVersion: "3.11",
Executable: "server/main.py",
Python: &model.ManifestPython{
DependencyMode: "venv",
VenvPath: "server/venv",
},
},
},
expected: true,
},
{
name: "Plugin with platform-specific executables (Python)",
manifest: &model.Manifest{
Id: "python-multi-platform",
Server: &model.ManifestServer{
Executables: map[string]string{
"linux-amd64": "plugin.py",
"darwin-amd64": "plugin.py",
},
},
},
expected: true,
},
{
name: "Manifest without server",
manifest: &model.Manifest{
Id: "webapp-only",
},
expected: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := isPythonPlugin(tc.manifest)
assert.Equal(t, tc.expected, result)
})
}
}
func TestFindPythonInterpreter(t *testing.T) {
t.Run("finds venv python when present", func(t *testing.T) {
// Create temp plugin directory with fake venv
dir, err := os.MkdirTemp("", "plugin-venv-test")
require.NoError(t, err)
defer os.RemoveAll(dir)
// Create venv structure based on OS
var venvPythonPath string
if runtime.GOOS == "windows" {
venvPythonPath = filepath.Join(dir, "venv", "Scripts", "python.exe")
} else {
venvPythonPath = filepath.Join(dir, "venv", "bin", "python")
}
// Create directory structure and fake interpreter file
require.NoError(t, os.MkdirAll(filepath.Dir(venvPythonPath), 0755))
require.NoError(t, os.WriteFile(venvPythonPath, []byte("#!/usr/bin/env python"), 0755))
// Find interpreter
found, err := findPythonInterpreter(dir)
require.NoError(t, err)
assert.Equal(t, venvPythonPath, found)
})
t.Run("finds .venv python when present", func(t *testing.T) {
dir, err := os.MkdirTemp("", "plugin-dotvenv-test")
require.NoError(t, err)
defer os.RemoveAll(dir)
var venvPythonPath string
if runtime.GOOS == "windows" {
venvPythonPath = filepath.Join(dir, ".venv", "Scripts", "python.exe")
} else {
venvPythonPath = filepath.Join(dir, ".venv", "bin", "python")
}
require.NoError(t, os.MkdirAll(filepath.Dir(venvPythonPath), 0755))
require.NoError(t, os.WriteFile(venvPythonPath, []byte("#!/usr/bin/env python"), 0755))
found, err := findPythonInterpreter(dir)
require.NoError(t, err)
assert.Equal(t, venvPythonPath, found)
})
t.Run("prefers venv over .venv", func(t *testing.T) {
dir, err := os.MkdirTemp("", "plugin-both-venvs-test")
require.NoError(t, err)
defer os.RemoveAll(dir)
var venvPath, dotVenvPath string
if runtime.GOOS == "windows" {
venvPath = filepath.Join(dir, "venv", "Scripts", "python.exe")
dotVenvPath = filepath.Join(dir, ".venv", "Scripts", "python.exe")
} else {
venvPath = filepath.Join(dir, "venv", "bin", "python")
dotVenvPath = filepath.Join(dir, ".venv", "bin", "python")
}
require.NoError(t, os.MkdirAll(filepath.Dir(venvPath), 0755))
require.NoError(t, os.WriteFile(venvPath, []byte("venv python"), 0755))
require.NoError(t, os.MkdirAll(filepath.Dir(dotVenvPath), 0755))
require.NoError(t, os.WriteFile(dotVenvPath, []byte(".venv python"), 0755))
found, err := findPythonInterpreter(dir)
require.NoError(t, err)
assert.Equal(t, venvPath, found)
})
t.Run("falls back to system python when no venv", func(t *testing.T) {
dir, err := os.MkdirTemp("", "plugin-no-venv-test")
require.NoError(t, err)
defer os.RemoveAll(dir)
// This test requires python3 or python to be available on the system
found, err := findPythonInterpreter(dir)
// Skip if no system python is available
if err != nil {
t.Skip("No system Python available for fallback test")
}
assert.NotEmpty(t, found)
})
}
func TestSanitizePythonScriptPath(t *testing.T) {
tests := []struct {
name string
pluginDir string
executable string
expectError bool
errorMsg string
}{
{
name: "simple script name",
pluginDir: "/plugins/myplugin",
executable: "plugin.py",
expectError: false,
},
{
name: "script in subdirectory",
pluginDir: "/plugins/myplugin",
executable: "server/plugin.py",
expectError: false,
},
{
name: "path traversal attempt",
pluginDir: "/plugins/myplugin",
executable: "../../../etc/passwd",
expectError: true,
errorMsg: "path traversal",
},
{
name: "path traversal with subdirectory",
pluginDir: "/plugins/myplugin",
executable: "server/../../../etc/passwd",
expectError: true,
errorMsg: "path traversal",
},
{
name: "absolute path rejected via clean",
pluginDir: "/plugins/myplugin",
executable: "/etc/passwd",
expectError: false, // filepath.Join handles this by making it relative
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result, err := sanitizePythonScriptPath(tc.pluginDir, tc.executable)
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.errorMsg)
} else {
require.NoError(t, err)
assert.NotEmpty(t, result)
// Ensure result is within plugin directory
assert.True(t, filepath.HasPrefix(result, tc.pluginDir))
}
})
}
}
func TestBuildPythonCommand(t *testing.T) {
pythonPath := "/usr/bin/python3"
scriptPath := "/plugins/myplugin/plugin.py"
pluginDir := "/plugins/myplugin"
cmd := buildPythonCommand(pythonPath, scriptPath, pluginDir)
assert.Equal(t, pythonPath, cmd.Path)
assert.Equal(t, []string{pythonPath, scriptPath}, cmd.Args)
assert.Equal(t, pluginDir, cmd.Dir)
assert.NotZero(t, cmd.WaitDelay)
}
func TestPythonCommandFromManifest(t *testing.T) {
t.Run("configures Python plugin correctly", func(t *testing.T) {
// Create temp plugin directory
dir, err := os.MkdirTemp("", "python-plugin-test")
require.NoError(t, err)
defer os.RemoveAll(dir)
// Create fake venv and script
var venvPythonPath string
if runtime.GOOS == "windows" {
venvPythonPath = filepath.Join(dir, "venv", "Scripts", "python.exe")
} else {
venvPythonPath = filepath.Join(dir, "venv", "bin", "python")
}
require.NoError(t, os.MkdirAll(filepath.Dir(venvPythonPath), 0755))
require.NoError(t, os.WriteFile(venvPythonPath, []byte("#!/usr/bin/env python"), 0755))
// Create plugin script
require.NoError(t, os.WriteFile(filepath.Join(dir, "plugin.py"), []byte("# Python plugin"), 0644))
// Create manifest
manifest := &model.Manifest{
Id: "python-test-plugin",
Server: &model.ManifestServer{
Executable: "plugin.py",
},
}
bundleInfo := &model.BundleInfo{
Path: dir,
Manifest: manifest,
}
clientConfig := &plugin.ClientConfig{}
sup := &supervisor{}
err = WithCommandFromManifest(bundleInfo, nil, nil)(sup, clientConfig)
require.NoError(t, err)
// Verify command was configured
require.NotNil(t, clientConfig.Cmd)
assert.Equal(t, venvPythonPath, clientConfig.Cmd.Path)
// Args contains the executable name (relative to cmd.Dir), not the full path
assert.Contains(t, clientConfig.Cmd.Args, "plugin.py")
assert.Equal(t, dir, clientConfig.Cmd.Dir)
// SecureConfig should be nil for Python plugins
assert.Nil(t, clientConfig.SecureConfig)
})
t.Run("rejects path traversal in Python plugin", func(t *testing.T) {
dir, err := os.MkdirTemp("", "python-plugin-traversal-test")
require.NoError(t, err)
defer os.RemoveAll(dir)
// Create venv
var venvPythonPath string
if runtime.GOOS == "windows" {
venvPythonPath = filepath.Join(dir, "venv", "Scripts", "python.exe")
} else {
venvPythonPath = filepath.Join(dir, "venv", "bin", "python")
}
require.NoError(t, os.MkdirAll(filepath.Dir(venvPythonPath), 0755))
require.NoError(t, os.WriteFile(venvPythonPath, []byte("#!/usr/bin/env python"), 0755))
// Use .py extension to trigger Python detection, with path traversal
manifest := &model.Manifest{
Id: "malicious-plugin",
Server: &model.ManifestServer{
Executable: "../../../etc/passwd.py",
},
}
bundleInfo := &model.BundleInfo{
Path: dir,
Manifest: manifest,
}
clientConfig := &plugin.ClientConfig{}
sup := &supervisor{}
err = WithCommandFromManifest(bundleInfo, nil, nil)(sup, clientConfig)
require.Error(t, err)
assert.Contains(t, err.Error(), "path traversal")
})
t.Run("fails when Python script does not exist", func(t *testing.T) {
dir, err := os.MkdirTemp("", "python-plugin-missing-script")
require.NoError(t, err)
defer os.RemoveAll(dir)
// Create venv but no script
var venvPythonPath string
if runtime.GOOS == "windows" {
venvPythonPath = filepath.Join(dir, "venv", "Scripts", "python.exe")
} else {
venvPythonPath = filepath.Join(dir, "venv", "bin", "python")
}
require.NoError(t, os.MkdirAll(filepath.Dir(venvPythonPath), 0755))
require.NoError(t, os.WriteFile(venvPythonPath, []byte("#!/usr/bin/env python"), 0755))
manifest := &model.Manifest{
Id: "missing-script-plugin",
Server: &model.ManifestServer{
Executable: "nonexistent.py",
},
}
bundleInfo := &model.BundleInfo{
Path: dir,
Manifest: manifest,
}
clientConfig := &plugin.ClientConfig{}
sup := &supervisor{}
err = WithCommandFromManifest(bundleInfo, nil, nil)(sup, clientConfig)
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
})
}
// TestPythonSupervisor_HealthCheckSuccess tests the end-to-end spawning and
// health checking of a Python-style plugin using a fake Python interpreter.
// The fake interpreter is a compiled Go binary that:
// 1. Starts a gRPC server
// 2. Registers gRPC health service with "plugin" status SERVING
// 3. Registers PluginHooks service with basic Implemented() support
// 4. Prints the go-plugin handshake line to stdout
// 5. Blocks until killed
func TestPythonSupervisor_HealthCheckSuccess(t *testing.T) {
// Create temp plugin directory
pluginDir, err := os.MkdirTemp("", "python-plugin-integration-test")
require.NoError(t, err)
defer os.RemoveAll(pluginDir)
// Determine the venv path based on OS
var venvPythonPath string
if runtime.GOOS == "windows" {
venvPythonPath = filepath.Join(pluginDir, "venv", "Scripts", "python.exe")
} else {
venvPythonPath = filepath.Join(pluginDir, "venv", "bin", "python")
}
// Create venv directory structure
require.NoError(t, os.MkdirAll(filepath.Dir(venvPythonPath), 0755))
// Compile a fake "Python interpreter" that serves gRPC health checks and PluginHooks.
// This binary ignores argv[1] (the script path) and just serves the
// go-plugin handshake + gRPC services.
utils.CompileGo(t, `
package main
import (
"context"
"fmt"
"net"
"os"
"os/signal"
"syscall"
"google.golang.org/grpc"
"google.golang.org/grpc/health"
"google.golang.org/grpc/health/grpc_health_v1"
pb "github.com/mattermost/mattermost/server/public/pluginapi/grpc/generated/go/pluginapiv1"
)
type fakePluginHooks struct {
pb.UnimplementedPluginHooksServer
}
func (f *fakePluginHooks) Implemented(ctx context.Context, req *pb.ImplementedRequest) (*pb.ImplementedResponse, error) {
return &pb.ImplementedResponse{Hooks: []string{}}, nil
}
func main() {
// Ignore argv[1] (the script path) - we're pretending to be a Python interpreter
// Start a gRPC server on a random port
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fmt.Fprintf(os.Stderr, "failed to listen: %v\n", err)
os.Exit(1)
}
grpcServer := grpc.NewServer()
// Register gRPC health service with "plugin" status SERVING
// This is required by go-plugin for health checking
healthServer := health.NewServer()
healthServer.SetServingStatus("plugin", grpc_health_v1.HealthCheckResponse_SERVING)
grpc_health_v1.RegisterHealthServer(grpcServer, healthServer)
// Register PluginHooks service (required by supervisor for Python plugins)
pb.RegisterPluginHooksServer(grpcServer, &fakePluginHooks{})
// Start serving in a goroutine
go func() {
if err := grpcServer.Serve(listener); err != nil {
fmt.Fprintf(os.Stderr, "failed to serve: %v\n", err)
}
}()
// Print the go-plugin handshake line
// Format: CORE-PROTOCOL-VERSION | APP-PROTOCOL-VERSION | NETWORK-TYPE | NETWORK-ADDR | PROTOCOL
// For Mattermost plugins, APP-PROTOCOL-VERSION is 1 (from handshake.ProtocolVersion)
addr := listener.Addr().String()
fmt.Printf("1|1|tcp|%s|grpc\n", addr)
os.Stdout.Sync()
// Block until SIGTERM or SIGINT
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
<-sigCh
grpcServer.GracefulStop()
}
`, venvPythonPath)
// Create a dummy plugin.py file (content doesn't matter, the fake interpreter ignores it)
scriptPath := filepath.Join(pluginDir, "plugin.py")
require.NoError(t, os.WriteFile(scriptPath, []byte("# Fake Python plugin script\n"), 0644))
// Create plugin.json manifest that indicates this is a Python plugin
manifest := &model.Manifest{
Id: "python-integration-test",
Version: "1.0.0",
Server: &model.ManifestServer{
Executable: "plugin.py",
},
}
manifestJSON, err := json.Marshal(manifest)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(pluginDir, "plugin.json"), manifestJSON, 0644))
// Create bundle info
bundle := model.BundleInfoForPath(pluginDir)
require.NotNil(t, bundle.Manifest)
// Create logger
logger := mlog.CreateConsoleTestLogger(t)
// Create supervisor using WithCommandFromManifest which will detect Python
// and use the fake interpreter
sup, err := newSupervisor(bundle, nil, nil, logger, nil, WithCommandFromManifest(bundle, nil, nil))
require.NoError(t, err)
require.NotNil(t, sup)
defer sup.Shutdown()
// Verify hooks are wired (Python plugins now have hooks through gRPC)
require.NotNil(t, sup.Hooks(), "Python plugin should have hooks wired")
// Verify health check succeeds - this proves gRPC Ping is working
err = sup.PerformHealthCheck()
require.NoError(t, err, "Health check should succeed for Python plugin")
}
// TestPythonPluginEnvironmentActivation tests that the Environment can
// activate and deactivate a Python plugin with hooks properly wired.
func TestPythonPluginEnvironmentActivation(t *testing.T) {
// Create temp directories
pluginDir, err := os.MkdirTemp("", "python-env-test-plugins")
require.NoError(t, err)
defer os.RemoveAll(pluginDir)
webappDir, err := os.MkdirTemp("", "python-env-test-webapp")
require.NoError(t, err)
defer os.RemoveAll(webappDir)
// Create plugin subdirectory
pluginPath := filepath.Join(pluginDir, "python-test-plugin")
require.NoError(t, os.MkdirAll(pluginPath, 0755))
// Determine the venv path based on OS
var venvPythonPath string
if runtime.GOOS == "windows" {
venvPythonPath = filepath.Join(pluginPath, "venv", "Scripts", "python.exe")
} else {
venvPythonPath = filepath.Join(pluginPath, "venv", "bin", "python")
}
// Create venv directory structure
require.NoError(t, os.MkdirAll(filepath.Dir(venvPythonPath), 0755))
// Compile fake Python interpreter with PluginHooks service
utils.CompileGo(t, `
package main
import (
"context"
"fmt"
"net"
"os"
"os/signal"
"syscall"
"google.golang.org/grpc"
"google.golang.org/grpc/health"
"google.golang.org/grpc/health/grpc_health_v1"
pb "github.com/mattermost/mattermost/server/public/pluginapi/grpc/generated/go/pluginapiv1"
)
type fakePluginHooks struct {
pb.UnimplementedPluginHooksServer
}
func (f *fakePluginHooks) Implemented(ctx context.Context, req *pb.ImplementedRequest) (*pb.ImplementedResponse, error) {
return &pb.ImplementedResponse{Hooks: []string{"OnActivate", "OnDeactivate"}}, nil
}
func (f *fakePluginHooks) OnActivate(ctx context.Context, req *pb.OnActivateRequest) (*pb.OnActivateResponse, error) {
return &pb.OnActivateResponse{}, nil
}
func (f *fakePluginHooks) OnDeactivate(ctx context.Context, req *pb.OnDeactivateRequest) (*pb.OnDeactivateResponse, error) {
return &pb.OnDeactivateResponse{}, nil
}
func main() {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fmt.Fprintf(os.Stderr, "failed to listen: %v\n", err)
os.Exit(1)
}
grpcServer := grpc.NewServer()
healthServer := health.NewServer()
healthServer.SetServingStatus("plugin", grpc_health_v1.HealthCheckResponse_SERVING)
grpc_health_v1.RegisterHealthServer(grpcServer, healthServer)
pb.RegisterPluginHooksServer(grpcServer, &fakePluginHooks{})
go func() {
if err := grpcServer.Serve(listener); err != nil {
fmt.Fprintf(os.Stderr, "failed to serve: %v\n", err)
}
}()
addr := listener.Addr().String()
fmt.Printf("1|1|tcp|%s|grpc\n", addr)
os.Stdout.Sync()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
<-sigCh
grpcServer.GracefulStop()
}
`, venvPythonPath)
// Create dummy plugin.py
require.NoError(t, os.WriteFile(filepath.Join(pluginPath, "plugin.py"), []byte("# Fake\n"), 0644))
// Create plugin.json
manifest := &model.Manifest{
Id: "python-test-plugin",
Version: "1.0.0",
Server: &model.ManifestServer{
Executable: "plugin.py",
},
}
manifestJSON, err := json.Marshal(manifest)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(pluginPath, "plugin.json"), manifestJSON, 0644))
// Create Environment
logger := mlog.CreateConsoleTestLogger(t)
env, err := NewEnvironment(
func(m *model.Manifest) API { return nil },
nil,
pluginDir,
webappDir,
logger,
nil,
)
require.NoError(t, err)
defer env.Shutdown()
// Activate the Python plugin
retManifest, activated, err := env.Activate("python-test-plugin")
require.NoError(t, err)
assert.True(t, activated, "Plugin should be activated")
assert.NotNil(t, retManifest)
// Verify plugin is active
assert.True(t, env.IsActive("python-test-plugin"))
// Verify health check works through Environment
err = env.PerformHealthCheck("python-test-plugin")
require.NoError(t, err, "Health check should succeed through Environment")
// Deactivate the plugin - hooks are now wired and OnDeactivate will be called
result := env.Deactivate("python-test-plugin")
assert.True(t, result, "Deactivate should return true")
// Verify plugin is no longer active
assert.False(t, env.IsActive("python-test-plugin"))
}
// TestPythonSupervisor_HandshakeTimeout tests that the supervisor times out
// when the fake Python interpreter never prints the handshake line.
func TestPythonSupervisor_HandshakeTimeout(t *testing.T) {
// Create temp plugin directory
pluginDir, err := os.MkdirTemp("", "python-timeout-test")
require.NoError(t, err)
defer os.RemoveAll(pluginDir)
// Determine the venv path based on OS
var venvPythonPath string
if runtime.GOOS == "windows" {
venvPythonPath = filepath.Join(pluginDir, "venv", "Scripts", "python.exe")
} else {
venvPythonPath = filepath.Join(pluginDir, "venv", "bin", "python")
}
// Create venv directory structure
require.NoError(t, os.MkdirAll(filepath.Dir(venvPythonPath), 0755))
// Compile a fake "Python interpreter" that blocks forever without printing handshake.
// This simulates a startup hang (e.g., module import loop, deadlock, etc.)
utils.CompileGo(t, `
package main
import (
"os"
"os/signal"
"syscall"
)
func main() {
// Ignore argv[1] (the script path)
// Never print the handshake line - just block forever
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
<-sigCh
}
`, venvPythonPath)
// Create a dummy plugin.py file
scriptPath := filepath.Join(pluginDir, "plugin.py")
require.NoError(t, os.WriteFile(scriptPath, []byte("# Fake Python plugin script\n"), 0644))
// Create plugin.json manifest
manifest := &model.Manifest{
Id: "python-timeout-test",
Version: "1.0.0",
Server: &model.ManifestServer{
Executable: "plugin.py",
},
}
manifestJSON, err := json.Marshal(manifest)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(pluginDir, "plugin.json"), manifestJSON, 0644))
// Create bundle info
bundle := model.BundleInfoForPath(pluginDir)
require.NotNil(t, bundle.Manifest)
// Create logger
logger := mlog.CreateConsoleTestLogger(t)
// Record start time to verify timeout duration
startTime := time.Now()
// Attempt to create supervisor - should fail with timeout
sup, err := newSupervisor(bundle, nil, nil, logger, nil, WithCommandFromManifest(bundle, nil, nil))
// Verify we got an error
require.Error(t, err)
// Ensure the error mentions timeout or context deadline
errMsg := err.Error()
assert.True(t, strings.Contains(errMsg, "timeout") ||
strings.Contains(errMsg, "deadline") ||
strings.Contains(errMsg, "Unrecognized remote plugin message"),
"Expected timeout-related error, got: %s", errMsg)
// Verify it completed within a reasonable time (StartTimeout is 10s for Python plugins, add buffer)
elapsed := time.Since(startTime)
assert.Less(t, elapsed, 15*time.Second, "Should timeout within 15 seconds (StartTimeout + buffer)")
// Supervisor should be nil on error
if sup != nil {
sup.Shutdown()
}
}
// TestPythonSupervisor_InvalidHandshake tests that the supervisor fails
// when the fake Python interpreter prints an invalid handshake (wrong protocol).
func TestPythonSupervisor_InvalidHandshake(t *testing.T) {
// Create temp plugin directory
pluginDir, err := os.MkdirTemp("", "python-invalid-handshake-test")
require.NoError(t, err)
defer os.RemoveAll(pluginDir)
// Determine the venv path based on OS
var venvPythonPath string
if runtime.GOOS == "windows" {
venvPythonPath = filepath.Join(pluginDir, "venv", "Scripts", "python.exe")
} else {
venvPythonPath = filepath.Join(pluginDir, "venv", "bin", "python")
}
// Create venv directory structure
require.NoError(t, os.MkdirAll(filepath.Dir(venvPythonPath), 0755))
// Compile a fake "Python interpreter" that prints an invalid handshake.
// The supervisor expects grpc protocol but we print netrpc.
utils.CompileGo(t, `
package main
import (
"fmt"
"net"
"os"
"os/signal"
"syscall"
)
func main() {
// Start a listener to get a valid address
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fmt.Fprintf(os.Stderr, "failed to listen: %v\n", err)
os.Exit(1)
}
defer listener.Close()
addr := listener.Addr().String()
// Print handshake with netrpc protocol (supervisor expects grpc for Python plugins)
// Format: CORE-PROTOCOL-VERSION | APP-PROTOCOL-VERSION | NETWORK-TYPE | NETWORK-ADDR | PROTOCOL
fmt.Printf("1|1|tcp|%s|netrpc\n", addr)
os.Stdout.Sync()
// Block until SIGTERM
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
<-sigCh
}
`, venvPythonPath)
// Create a dummy plugin.py file
scriptPath := filepath.Join(pluginDir, "plugin.py")
require.NoError(t, os.WriteFile(scriptPath, []byte("# Fake Python plugin script\n"), 0644))
// Create plugin.json manifest
manifest := &model.Manifest{
Id: "python-invalid-handshake-test",
Version: "1.0.0",
Server: &model.ManifestServer{
Executable: "plugin.py",
},
}
manifestJSON, err := json.Marshal(manifest)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(pluginDir, "plugin.json"), manifestJSON, 0644))
// Create bundle info
bundle := model.BundleInfoForPath(pluginDir)
require.NotNil(t, bundle.Manifest)
// Create logger
logger := mlog.CreateConsoleTestLogger(t)
// Attempt to create supervisor - should fail due to protocol mismatch
sup, err := newSupervisor(bundle, nil, nil, logger, nil, WithCommandFromManifest(bundle, nil, nil))
// Verify we got an error
require.Error(t, err)
// Error message should indicate protocol issues
errMsg := err.Error()
assert.True(t, strings.Contains(errMsg, "protocol") ||
strings.Contains(errMsg, "Incompatible") ||
strings.Contains(errMsg, "grpc") ||
strings.Contains(errMsg, "Unrecognized"),
"Expected protocol-related error, got: %s", errMsg)
// Supervisor should be nil on error
if sup != nil {
sup.Shutdown()
}
}
// TestPythonSupervisor_MalformedHandshake tests that the supervisor fails
// when the fake Python interpreter prints a malformed handshake line.
func TestPythonSupervisor_MalformedHandshake(t *testing.T) {
// Create temp plugin directory
pluginDir, err := os.MkdirTemp("", "python-malformed-handshake-test")
require.NoError(t, err)
defer os.RemoveAll(pluginDir)
// Determine the venv path based on OS
var venvPythonPath string
if runtime.GOOS == "windows" {
venvPythonPath = filepath.Join(pluginDir, "venv", "Scripts", "python.exe")
} else {
venvPythonPath = filepath.Join(pluginDir, "venv", "bin", "python")
}
// Create venv directory structure
require.NoError(t, os.MkdirAll(filepath.Dir(venvPythonPath), 0755))
// Compile a fake "Python interpreter" that prints a malformed handshake.
// The handshake should have 5 pipe-separated parts, we only print 2.
utils.CompileGo(t, `
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
)
func main() {
// Print a malformed handshake (only 2 parts instead of 5)
fmt.Printf("1|invalid\n")
os.Stdout.Sync()
// Block until SIGTERM
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
<-sigCh
}
`, venvPythonPath)
// Create a dummy plugin.py file
scriptPath := filepath.Join(pluginDir, "plugin.py")
require.NoError(t, os.WriteFile(scriptPath, []byte("# Fake Python plugin script\n"), 0644))
// Create plugin.json manifest
manifest := &model.Manifest{
Id: "python-malformed-handshake-test",
Version: "1.0.0",
Server: &model.ManifestServer{
Executable: "plugin.py",
},
}
manifestJSON, err := json.Marshal(manifest)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(pluginDir, "plugin.json"), manifestJSON, 0644))
// Create bundle info
bundle := model.BundleInfoForPath(pluginDir)
require.NotNil(t, bundle.Manifest)
// Create logger
logger := mlog.CreateConsoleTestLogger(t)
// Attempt to create supervisor - should fail due to malformed handshake
sup, err := newSupervisor(bundle, nil, nil, logger, nil, WithCommandFromManifest(bundle, nil, nil))
// Verify we got an error
require.Error(t, err)
// Error message should indicate parsing/format issues
errMsg := err.Error()
assert.True(t, strings.Contains(errMsg, "Unrecognized") ||
strings.Contains(errMsg, "parse") ||
strings.Contains(errMsg, "format") ||
strings.Contains(errMsg, "invalid") ||
strings.Contains(errMsg, "protocol"),
"Expected parsing-related error, got: %s", errMsg)
// Supervisor should be nil on error
if sup != nil {
sup.Shutdown()
}
}
// TestPythonSupervisor_Restart tests the full crash and restart flow:
// 1. Start a healthy fake Python plugin
// 2. Verify health check succeeds
// 3. Kill the plugin process to simulate a crash
// 4. Verify health check fails after crash
// 5. Call RestartPlugin and verify health check succeeds again
func TestPythonSupervisor_Restart(t *testing.T) {
// Create temp directories
pluginDir, err := os.MkdirTemp("", "python-restart-test-plugins")
require.NoError(t, err)
defer os.RemoveAll(pluginDir)
webappDir, err := os.MkdirTemp("", "python-restart-test-webapp")
require.NoError(t, err)
defer os.RemoveAll(webappDir)
// Create plugin subdirectory
pluginPath := filepath.Join(pluginDir, "python-restart-test")
require.NoError(t, os.MkdirAll(pluginPath, 0755))
// Determine the venv path based on OS
var venvPythonPath string
if runtime.GOOS == "windows" {
venvPythonPath = filepath.Join(pluginPath, "venv", "Scripts", "python.exe")
} else {
venvPythonPath = filepath.Join(pluginPath, "venv", "bin", "python")
}
// Create venv directory structure
require.NoError(t, os.MkdirAll(filepath.Dir(venvPythonPath), 0755))
// Compile fake Python interpreter that serves gRPC health checks and PluginHooks
utils.CompileGo(t, `
package main
import (
"context"
"fmt"
"net"
"os"
"os/signal"
"syscall"
"google.golang.org/grpc"
"google.golang.org/grpc/health"
"google.golang.org/grpc/health/grpc_health_v1"
pb "github.com/mattermost/mattermost/server/public/pluginapi/grpc/generated/go/pluginapiv1"
)
type fakePluginHooks struct {
pb.UnimplementedPluginHooksServer
}
func (f *fakePluginHooks) Implemented(ctx context.Context, req *pb.ImplementedRequest) (*pb.ImplementedResponse, error) {
return &pb.ImplementedResponse{Hooks: []string{"OnActivate"}}, nil
}
func (f *fakePluginHooks) OnActivate(ctx context.Context, req *pb.OnActivateRequest) (*pb.OnActivateResponse, error) {
return &pb.OnActivateResponse{}, nil
}
func main() {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fmt.Fprintf(os.Stderr, "failed to listen: %v\n", err)
os.Exit(1)
}
grpcServer := grpc.NewServer()
healthServer := health.NewServer()
healthServer.SetServingStatus("plugin", grpc_health_v1.HealthCheckResponse_SERVING)
grpc_health_v1.RegisterHealthServer(grpcServer, healthServer)
pb.RegisterPluginHooksServer(grpcServer, &fakePluginHooks{})
go func() {
if err := grpcServer.Serve(listener); err != nil {
fmt.Fprintf(os.Stderr, "failed to serve: %v\n", err)
}
}()
addr := listener.Addr().String()
fmt.Printf("1|1|tcp|%s|grpc\n", addr)
os.Stdout.Sync()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
<-sigCh
grpcServer.GracefulStop()
}
`, venvPythonPath)
// Create dummy plugin.py
require.NoError(t, os.WriteFile(filepath.Join(pluginPath, "plugin.py"), []byte("# Fake\n"), 0644))
// Create plugin.json
manifest := &model.Manifest{
Id: "python-restart-test",
Version: "1.0.0",
Server: &model.ManifestServer{
Executable: "plugin.py",
},
}
manifestJSON, err := json.Marshal(manifest)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(pluginPath, "plugin.json"), manifestJSON, 0644))
// Create Environment
logger := mlog.CreateConsoleTestLogger(t)
env, err := NewEnvironment(
func(m *model.Manifest) API { return nil },
nil,
pluginDir,
webappDir,
logger,
nil,
)
require.NoError(t, err)
defer env.Shutdown()
// Step 1: Activate the Python plugin
retManifest, activated, err := env.Activate("python-restart-test")
require.NoError(t, err)
assert.True(t, activated, "Plugin should be activated")
assert.NotNil(t, retManifest)
assert.True(t, env.IsActive("python-restart-test"))
// Step 2: Verify health check succeeds
err = env.PerformHealthCheck("python-restart-test")
require.NoError(t, err, "Initial health check should succeed")
// Step 3: Kill the plugin process to simulate a crash
// Access the registered plugin to get the supervisor and kill the underlying process
rp, ok := env.registeredPlugins.Load("python-restart-test")
require.True(t, ok, "Plugin should be registered")
registeredPlug := rp.(registeredPlugin)
require.NotNil(t, registeredPlug.supervisor, "Supervisor should exist")
require.NotNil(t, registeredPlug.supervisor.client, "Client should exist")
// Kill the plugin process
registeredPlug.supervisor.client.Kill()
// Step 4: Verify health check fails after crash
// Use a short polling loop instead of arbitrary sleep
healthCheckFailed := false
for i := 0; i < 10; i++ {
err = env.PerformHealthCheck("python-restart-test")
if err != nil {
healthCheckFailed = true
break
}
time.Sleep(100 * time.Millisecond)
}
require.True(t, healthCheckFailed, "Health check should fail after killing the process")
// Step 5: Call RestartPlugin and verify recovery
err = env.RestartPlugin("python-restart-test")
require.NoError(t, err, "RestartPlugin should succeed")
// Verify the plugin is active again
assert.True(t, env.IsActive("python-restart-test"), "Plugin should be active after restart")
// Verify health check succeeds after restart
// Use a short polling loop to allow for startup time
healthCheckSucceeded := false
for i := 0; i < 20; i++ {
err = env.PerformHealthCheck("python-restart-test")
if err == nil {
healthCheckSucceeded = true
break
}
time.Sleep(100 * time.Millisecond)
}
require.True(t, healthCheckSucceeded, "Health check should succeed after restart")
}
// TestPythonPluginHookDispatch tests end-to-end hook dispatch for Python plugins.
// It creates a fake Python interpreter that implements the PluginHooks gRPC service,
// verifies the supervisor wires hooks correctly, and tests that hook invocations work.
//
// Note: OnActivate is a special hook that is NOT tracked in hookNameToId (it's excluded
// from code generation), so we test with OnDeactivate and MessageHasBeenPosted which
// ARE tracked via sup.Implements().
func TestPythonPluginHookDispatch(t *testing.T) {
// Create temp plugin directory
pluginDir, err := os.MkdirTemp("", "python-hooks-test")
require.NoError(t, err)
defer os.RemoveAll(pluginDir)
// Determine the venv path based on OS
var venvPythonPath string
if runtime.GOOS == "windows" {
venvPythonPath = filepath.Join(pluginDir, "venv", "Scripts", "python.exe")
} else {
venvPythonPath = filepath.Join(pluginDir, "venv", "bin", "python")
}
// Create venv directory structure
require.NoError(t, os.MkdirAll(filepath.Dir(venvPythonPath), 0755))
// Compile a fake "Python interpreter" that serves:
// 1. gRPC health service (required by go-plugin)
// 2. PluginHooks service with Implemented, OnActivate, OnDeactivate, and MessageHasBeenPosted
utils.CompileGo(t, `
package main
import (
"context"
"fmt"
"net"
"os"
"os/signal"
"syscall"
"google.golang.org/grpc"
"google.golang.org/grpc/health"
"google.golang.org/grpc/health/grpc_health_v1"
pb "github.com/mattermost/mattermost/server/public/pluginapi/grpc/generated/go/pluginapiv1"
)
// fakePluginHooks implements the PluginHooksServer interface
type fakePluginHooks struct {
pb.UnimplementedPluginHooksServer
}
func (f *fakePluginHooks) Implemented(ctx context.Context, req *pb.ImplementedRequest) (*pb.ImplementedResponse, error) {
// Report that we implement OnActivate, OnDeactivate, and MessageHasBeenPosted
// Note: OnActivate is not in hookNameToId (excluded from generation), but OnDeactivate is
return &pb.ImplementedResponse{
Hooks: []string{"OnActivate", "OnDeactivate", "MessageHasBeenPosted"},
}, nil
}
func (f *fakePluginHooks) OnActivate(ctx context.Context, req *pb.OnActivateRequest) (*pb.OnActivateResponse, error) {
return &pb.OnActivateResponse{}, nil
}
func (f *fakePluginHooks) OnDeactivate(ctx context.Context, req *pb.OnDeactivateRequest) (*pb.OnDeactivateResponse, error) {
return &pb.OnDeactivateResponse{}, nil
}
func (f *fakePluginHooks) MessageHasBeenPosted(ctx context.Context, req *pb.MessageHasBeenPostedRequest) (*pb.MessageHasBeenPostedResponse, error) {
return &pb.MessageHasBeenPostedResponse{}, nil
}
func main() {
// Start a gRPC server on a random port
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fmt.Fprintf(os.Stderr, "failed to listen: %v\n", err)
os.Exit(1)
}
grpcServer := grpc.NewServer()
// Register gRPC health service (required by go-plugin)
healthServer := health.NewServer()
healthServer.SetServingStatus("plugin", grpc_health_v1.HealthCheckResponse_SERVING)
grpc_health_v1.RegisterHealthServer(grpcServer, healthServer)
// Register PluginHooks service
pluginHooks := &fakePluginHooks{}
pb.RegisterPluginHooksServer(grpcServer, pluginHooks)
// Start serving in a goroutine
go func() {
if err := grpcServer.Serve(listener); err != nil {
fmt.Fprintf(os.Stderr, "failed to serve: %v\n", err)
}
}()
// Print the go-plugin handshake line
addr := listener.Addr().String()
fmt.Printf("1|1|tcp|%s|grpc\n", addr)
os.Stdout.Sync()
// Block until SIGTERM or SIGINT
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
<-sigCh
grpcServer.GracefulStop()
}
`, venvPythonPath)
// Create a dummy plugin.py file
scriptPath := filepath.Join(pluginDir, "plugin.py")
require.NoError(t, os.WriteFile(scriptPath, []byte("# Fake Python plugin script\n"), 0644))
// Create plugin.json manifest
manifest := &model.Manifest{
Id: "python-hooks-test",
Version: "1.0.0",
Server: &model.ManifestServer{
Executable: "plugin.py",
},
}
manifestJSON, err := json.Marshal(manifest)
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(pluginDir, "plugin.json"), manifestJSON, 0644))
// Create bundle info
bundle := model.BundleInfoForPath(pluginDir)
require.NotNil(t, bundle.Manifest)
// Create logger
logger := mlog.CreateConsoleTestLogger(t)
// Create supervisor using WithCommandFromManifest which will detect Python
sup, err := newSupervisor(bundle, nil, nil, logger, nil, WithCommandFromManifest(bundle, nil, nil))
require.NoError(t, err)
require.NotNil(t, sup)
defer sup.Shutdown()
// Verify hooks are NOT nil (Python plugins now have hooks wired)
require.NotNil(t, sup.Hooks(), "Python plugin should have hooks wired")
// Verify health check succeeds
err = sup.PerformHealthCheck()
require.NoError(t, err, "Health check should succeed")
// Verify Implemented() returns the hooks we registered
impl, implErr := sup.Hooks().Implemented()
require.NoError(t, implErr, "Implemented() should succeed")
assert.Contains(t, impl, "OnActivate", "Implemented should include OnActivate")
assert.Contains(t, impl, "OnDeactivate", "Implemented should include OnDeactivate")
assert.Contains(t, impl, "MessageHasBeenPosted", "Implemented should include MessageHasBeenPosted")
// Test 1: Verify Implements() returns true for OnDeactivate
// Note: OnActivate is excluded from hookNameToId, so we test OnDeactivate instead
assert.True(t, sup.Implements(OnDeactivateID), "sup.Implements(OnDeactivateID) should return true")
// Test 2: Verify Implements() returns true for MessageHasBeenPosted
assert.True(t, sup.Implements(MessageHasBeenPostedID), "sup.Implements(MessageHasBeenPostedID) should return true")
// Test 3: Verify Implements() returns false for ChannelHasBeenCreated (not in implemented list)
assert.False(t, sup.Implements(ChannelHasBeenCreatedID), "sup.Implements(ChannelHasBeenCreatedID) should return false")
// Test 4: Call OnActivate and verify it succeeds
err = sup.Hooks().OnActivate()
require.NoError(t, err, "OnActivate should succeed")
// Test 5: Call OnDeactivate and verify it succeeds
err = sup.Hooks().OnDeactivate()
require.NoError(t, err, "OnDeactivate should succeed")
// Test 6: Call MessageHasBeenPosted and verify it doesn't error
// (void hook, so we just verify no panic/error)
sup.Hooks().MessageHasBeenPosted(nil, &model.Post{
Id: "test-post-id",
ChannelId: "test-channel-id",
UserId: "test-user-id",
Message: "Test message",
})
}
// TestPythonAPIServerStartup tests that the API server starts correctly
// when both apiImpl and registrar are provided.
func TestPythonAPIServerStartup(t *testing.T) {
// Create a registrar that records if it was called
registrarCalled := false
mockRegistrar := func(grpcServer *grpc.Server, api API) {
registrarCalled = true
}
// Start the API server with nil API (we're just testing server startup)
// The registrar will be called regardless of apiImpl being nil
addr, cleanup, err := startAPIServer(nil, mockRegistrar)
require.NoError(t, err)
require.NotNil(t, cleanup)
defer cleanup()
// Verify the registrar was called
assert.True(t, registrarCalled, "API server registrar should have been called")
// Verify address is in host:port format
assert.Contains(t, addr, ":", "API target should be in host:port format")
assert.NotEmpty(t, addr, "API target address should not be empty")
// Verify we can connect to the server
conn, err := grpc.Dial(addr, grpc.WithInsecure())
require.NoError(t, err, "Should be able to connect to API server")
conn.Close()
}
// TestPythonAPIServerLifecycle tests that the API server properly shuts down
// when cleanup is called.
func TestPythonAPIServerLifecycle(t *testing.T) {
// Start an API server
addr, cleanup, err := startAPIServer(nil, func(grpcServer *grpc.Server, api API) {
// Register nothing - just testing server lifecycle
})
require.NoError(t, err)
require.NotNil(t, cleanup)
require.NotEmpty(t, addr)
// Verify we can connect to the server
conn, err := grpc.Dial(addr, grpc.WithInsecure())
require.NoError(t, err, "Should be able to connect to API server")
conn.Close()
// Call cleanup to stop the server
cleanup()
// Give a small delay for the server to stop
time.Sleep(100 * time.Millisecond)
// Try to connect again - should fail or get connection refused
// We use a context with timeout to avoid hanging
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
_, err = grpc.DialContext(ctx, addr, grpc.WithInsecure(), grpc.WithBlock())
assert.Error(t, err, "Connection should fail after server shutdown")
}
// TestPythonCommandWithAPIServer tests that WithCommandFromManifest correctly
// sets up the MATTERMOST_PLUGIN_API_TARGET environment variable when an API
// implementation and registrar are provided.
func TestPythonCommandWithAPIServer(t *testing.T) {
// Create temp plugin directory
dir, err := os.MkdirTemp("", "python-api-server-test")
require.NoError(t, err)
defer os.RemoveAll(dir)
// Create fake venv
var venvPythonPath string
if runtime.GOOS == "windows" {
venvPythonPath = filepath.Join(dir, "venv", "Scripts", "python.exe")
} else {
venvPythonPath = filepath.Join(dir, "venv", "bin", "python")
}
require.NoError(t, os.MkdirAll(filepath.Dir(venvPythonPath), 0755))
require.NoError(t, os.WriteFile(venvPythonPath, []byte("#!/usr/bin/env python"), 0755))
// Create plugin script
require.NoError(t, os.WriteFile(filepath.Join(dir, "plugin.py"), []byte("# Python plugin"), 0644))
// Create manifest
manifest := &model.Manifest{
Id: "python-api-test-plugin",
Server: &model.ManifestServer{
Executable: "plugin.py",
},
}
bundleInfo := &model.BundleInfo{
Path: dir,
Manifest: manifest,
}
clientConfig := &plugin.ClientConfig{}
sup := &supervisor{}
// Test 1: Without API (nil apiImpl), MATTERMOST_PLUGIN_API_TARGET should NOT be set
err = WithCommandFromManifest(bundleInfo, nil, nil)(sup, clientConfig)
require.NoError(t, err)
require.NotNil(t, clientConfig.Cmd)
// MATTERMOST_PLUGIN_API_TARGET should NOT be in cmd.Env when apiImpl is nil
foundAPITarget := false
if clientConfig.Cmd.Env != nil {
for _, env := range clientConfig.Cmd.Env {
if strings.HasPrefix(env, "MATTERMOST_PLUGIN_API_TARGET=") {
foundAPITarget = true
break
}
}
}
assert.False(t, foundAPITarget, "MATTERMOST_PLUGIN_API_TARGET should NOT be set when apiImpl is nil")
}