mattermost/server/public/plugin/python_supervisor.go
Nick Misasi 453ab3dc74 feat(12-01): start gRPC PluginAPI server for Python plugins
- Add startAPIServer function to start gRPC server on random port
- Add APIServerRegistrar type to break import cycle with apiserver package
- Modify WithCommandFromManifest to accept apiImpl and registrar
- Update configurePythonCommand to start API server and set MATTERMOST_PLUGIN_API_TARGET env var
- Add apiServerCleanup field to supervisor struct for cleanup on shutdown
- Add SetAPIServerRegistrar method to Environment for dependency injection
- Wire up API server registrar in app/plugin.go

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

230 lines
8.1 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package plugin
import (
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
plugin "github.com/hashicorp/go-plugin"
"github.com/pkg/errors"
"google.golang.org/grpc"
"github.com/mattermost/mattermost/server/public/model"
)
// isPythonPlugin determines if a plugin manifest indicates a Python plugin.
// Detection uses:
// 1. Server.Runtime field explicitly set to "python"
// 2. Server executable ends with .py (fallback for backward compatibility)
func isPythonPlugin(manifest *model.Manifest) bool {
if manifest == nil || manifest.Server == nil {
return false
}
// Check explicit runtime declaration
if manifest.Server.Runtime == "python" {
return true
}
// Fallback: check executable extension for backward compatibility
executable := manifest.GetExecutableForRuntime(runtime.GOOS, runtime.GOARCH)
return strings.HasSuffix(executable, ".py")
}
// findPythonInterpreter locates a Python interpreter for the given plugin directory.
// Search order:
// 1. Plugin venv (venv/ or .venv/)
// 2. System python3 via PATH
// 3. System python via PATH
// It never hardcodes paths like /usr/bin/python3 to support pyenv, brew, containers.
func findPythonInterpreter(pluginDir string) (string, error) {
// Build list of venv paths to check based on OS
var venvPaths []string
if runtime.GOOS == "windows" {
venvPaths = []string{
filepath.Join(pluginDir, "venv", "Scripts", "python.exe"),
filepath.Join(pluginDir, ".venv", "Scripts", "python.exe"),
}
} else {
// Unix-like systems (Linux, macOS)
venvPaths = []string{
filepath.Join(pluginDir, "venv", "bin", "python"),
filepath.Join(pluginDir, "venv", "bin", "python3"),
filepath.Join(pluginDir, ".venv", "bin", "python"),
filepath.Join(pluginDir, ".venv", "bin", "python3"),
}
}
// Check venv paths first
for _, path := range venvPaths {
if _, err := os.Stat(path); err == nil {
// Convert to absolute path for exec to find it
absPath, err := filepath.Abs(path)
if err != nil {
return path, nil // Fall back to relative if Abs fails
}
return absPath, nil
}
}
// Fall back to system Python via PATH
for _, pythonCmd := range []string{"python3", "python"} {
if path, err := exec.LookPath(pythonCmd); err == nil {
return path, nil
}
}
return "", errors.New("no Python interpreter found (checked venv and system PATH)")
}
// sanitizePythonScriptPath validates and returns the absolute path to a Python plugin script.
// It uses the same sanitation pattern as WithExecutableFromManifest to prevent path traversal.
func sanitizePythonScriptPath(pluginDir, executable string) (string, error) {
// Clean and normalize the path
cleaned := filepath.Clean(filepath.Join(".", executable))
// Reject path traversal attempts
if strings.HasPrefix(cleaned, "..") {
return "", fmt.Errorf("invalid Python script path (path traversal): %s", executable)
}
// Build absolute path under plugin directory
scriptPath := filepath.Join(pluginDir, cleaned)
return scriptPath, nil
}
// buildPythonCommand creates an exec.Cmd for running a Python plugin.
// It sets the working directory and configures a graceful shutdown delay.
// The executable parameter should be relative to pluginDir (e.g., "plugin.py").
func buildPythonCommand(pythonPath, executable, pluginDir string) *exec.Cmd {
cmd := exec.Command(pythonPath, executable)
cmd.Dir = pluginDir
// Allow 5 seconds for graceful shutdown before SIGKILL on context cancellation
cmd.WaitDelay = 5 * time.Second
return cmd
}
// WithCommandFromManifest creates a supervisor option that sets the ClientConfig.Cmd
// correctly for both Go and Python plugins.
// For Python plugins:
// - Uses venv-first interpreter discovery
// - Sets SecureConfig to nil (interpreter checksum would be meaningless)
// - Starts a gRPC PluginAPI server if apiImpl and registrar are provided
// - Sets MATTERMOST_PLUGIN_API_TARGET env var with the server address
// For Go plugins:
// - Delegates to existing WithExecutableFromManifest behavior
func WithCommandFromManifest(pluginInfo *model.BundleInfo, apiImpl API, registrar APIServerRegistrar) func(*supervisor, *plugin.ClientConfig) error {
return func(sup *supervisor, clientConfig *plugin.ClientConfig) error {
if isPythonPlugin(pluginInfo.Manifest) {
return configurePythonCommand(pluginInfo, clientConfig, sup, apiImpl, registrar)
}
// For Go plugins, use the existing implementation
return WithExecutableFromManifest(pluginInfo)(sup, clientConfig)
}
}
// APIServerRegistrar is a function that registers the PluginAPI service with a gRPC server.
// This abstraction is used to break the import cycle between plugin and pluginapi/grpc/server.
type APIServerRegistrar func(grpcServer *grpc.Server, apiImpl API)
// startAPIServer starts a gRPC server that serves the PluginAPI service.
// It listens on a random available port and returns:
// - The server address (e.g., "localhost:54321") for the Python subprocess
// - A cleanup function to stop the server
// - An error if the server could not be started
func startAPIServer(apiImpl API, registrar APIServerRegistrar) (string, func(), error) {
// Listen on a random available port
listener, err := net.Listen("tcp", "localhost:0")
if err != nil {
return "", nil, errors.Wrap(err, "failed to create listener for PluginAPI server")
}
// Create a new gRPC server
grpcServer := grpc.NewServer()
// Register the PluginAPI service using the provided registrar
registrar(grpcServer, apiImpl)
// Start serving in a goroutine
go func() {
// Serve blocks until the server is stopped or an error occurs
_ = grpcServer.Serve(listener)
}()
// Extract the assigned address
addr := listener.Addr().String()
// Create cleanup function that stops the server gracefully
cleanup := func() {
grpcServer.GracefulStop()
}
return addr, cleanup, nil
}
// configurePythonCommand sets up the ClientConfig.Cmd for a Python plugin.
// If apiImpl and registrar are provided, it starts a gRPC PluginAPI server and passes the
// address to the Python subprocess via the MATTERMOST_PLUGIN_API_TARGET env var.
func configurePythonCommand(pluginInfo *model.BundleInfo, clientConfig *plugin.ClientConfig, sup *supervisor, apiImpl API, registrar APIServerRegistrar) error {
// Find Python interpreter (venv-first)
pythonPath, err := findPythonInterpreter(pluginInfo.Path)
if err != nil {
return errors.Wrap(err, "failed to find Python interpreter for plugin")
}
// Get and sanitize the script path
executable := pluginInfo.Manifest.GetExecutableForRuntime(runtime.GOOS, runtime.GOARCH)
if executable == "" {
return fmt.Errorf("Python plugin executable not specified in manifest")
}
scriptPath, err := sanitizePythonScriptPath(pluginInfo.Path, executable)
if err != nil {
return err
}
// Verify the script file exists (using full path for the check)
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
return fmt.Errorf("Python plugin script not found: %s", scriptPath)
}
// Build the command - pass executable name (relative to pluginDir), not full scriptPath
// Since cmd.Dir is set to pluginDir, paths are resolved relative to it
cmd := buildPythonCommand(pythonPath, executable, pluginInfo.Path)
// Start API server if apiImpl and registrar are provided
// This must happen BEFORE the Python subprocess starts so the port is ready
if apiImpl != nil && registrar != nil {
addr, cleanup, err := startAPIServer(apiImpl, registrar)
if err != nil {
return errors.Wrap(err, "failed to start PluginAPI server for Python plugin")
}
// Store cleanup function in supervisor for later shutdown
sup.apiServerCleanup = cleanup
// Set environment variable for Python subprocess
// Preserve existing parent env vars and add the API target
cmd.Env = append(os.Environ(), "MATTERMOST_PLUGIN_API_TARGET="+addr)
}
clientConfig.Cmd = cmd
// For Python plugins, we don't use SecureConfig because:
// 1. The checksum would be against the Python interpreter, not the plugin script
// 2. The interpreter is typically shared across plugins
// 3. Script integrity could be verified differently if needed in the future
clientConfig.SecureConfig = nil
return nil
}