mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-03 20:40:00 -05:00
- 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>
230 lines
8.1 KiB
Go
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
|
|
}
|