Implement major enhancements to plugin manager (v1.2.0 & v1.3.0)

This commit introduces significant improvements and new features to the plugin manager:

- Add plugin discovery system and remote repository support
- Implement plugin update system with digital signature verification
- Enhance plugin lifecycle hooks (PreLoad, PostLoad, PreUnload)
- Improve dependency management with custom version comparison
- Introduce lazy loading for optimized plugin performance
- Implement comprehensive error handling and logging
- Enhance concurrency safety with fine-grained locking
- Add plugin statistics tracking
- Remove external version comparison dependencies
- Improve hot-reload functionality with graceful shutdown
- Add SSH key support for remote repositories
- Implement Redbean server integration for plugin repositories

This update significantly improves the plugin manager's functionality,
security, and performance, providing a more robust and flexible system
for managing plugins in Go applications.
This commit is contained in:
Matt Dunleavy
2024-07-06 14:37:17 -04:00
parent a072069a5c
commit eedbbfec4a
17 changed files with 779 additions and 257 deletions

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
.vscode/
.todo/
# Go workspace file
go.work
go.work.sum
# env file
.env

112
CHANGELOG.md Normal file
View File

@@ -0,0 +1,112 @@
# Changelog
## [1.3.0] - 2024-07-06
### Added
- Enhanced plugin lifecycle management
- Implemented `PreLoad`, `PostLoad`, and `PreUnload` hooks in the Plugin interface
- Added graceful shutdown for plugins during hot-reload
- Improved version compatibility checking
- Implemented custom version comparison logic without external dependencies
- Added support for version constraints (>=, >, <=, <, ==)
- Lazy loading for plugins
- Introduced `lazyPlugin` struct for deferred plugin loading
- Optimized plugin loading to occur only when necessary
- Comprehensive error handling
- Added detailed error messages throughout the codebase
- Improved error propagation and logging
- Enhanced concurrency safety
- Implemented fine-grained locking with `sync.RWMutex`
- Ensured thread-safety for all shared data access operations
- Plugin statistics tracking
- Added `PluginStats` struct to track execution count and times
- Implemented `GetPluginStats` method for retrieving plugin performance data
### Changed
- Refactored `Manager` struct to support new features
- Updated `LoadPlugin` method to incorporate new lifecycle hooks and lazy loading
- Modified `HotReload` method to include graceful shutdown of old plugin versions
- Improved `ExecutePlugin` method to work with lazy-loaded plugins and update statistics
- Enhanced `checkDependency` method to use the new version compatibility checking
### Removed
- Dependency on external version comparison libraries
### Security
- Added placeholder for plugin signature verification in `LoadPlugin` and `HotReload` methods
- Implemented `VerifyPluginSignature` method (to be fully implemented in future)
## [1.2.0] - 2024-07-05
### Added
- Plugin discovery system
- Implemented automatic plugin discovery in specified directories
- Remote plugin repository support
- Added `PluginRepository` struct for managing remote repositories
- Implemented `SetupRemoteRepository` function for configuring remote repositories
- Plugin update system
- Added `CheckForUpdates` and `UpdatePlugin` functions (placeholders to be implemented)
- Redbean server integration for plugin repositories
- Implemented `DeployRepository` function for easy deployment of plugin repositories
- Added automatic download and setup of Redbean server
- Digital signature verification for plugins
- Added `VerifyPluginSignature` method (placeholder to be fully implemented)
- Enhanced plugin lifecycle hooks
- Added `PreLoad` and `PostLoad` hooks to the Plugin interface
- Implemented `PreUnload` hook in addition to existing `Shutdown` method
- SSH key support for remote repositories
- Integrated SSH public key authentication for secure remote repository access
### Changed
- Updated `PluginMetadata` struct to include `Signature` field
- Modified `Manager` struct to include `publicKey` field for signature verification
- Enhanced `LoadPlugin` method to utilize new lifecycle hooks and signature verification
- Updated `UnloadPlugin` method to use the new `PreUnload` hook
- Refactored `HotReload` method to incorporate new plugin lifecycle
### Fixed
- Resolved issues with unused imports in discovery.go
- Fixed type mismatch in metadata.Dependencies assignment in manager.go
- Corrected public key type handling in SetupRemoteRepository function
## [1.1.0] - 2024-07-04
### Added
- Improved dependency management system
- Added version compatibility checks for plugin dependencies
- Implemented `checkDependencies` method in Manager
- Toolchain compatibility checks
- Added Go version compatibility check during plugin loading
- Enhanced sandboxing
- Implemented `LinuxSandbox` with chroot functionality
- Added `VerifyPluginPath` method to prevent loading plugins from outside the sandbox
- Lazy loading of plugins
- Introduced `lazyPlugin` struct for deferred plugin loading
- Comprehensive logging system
- Integrated zap logger for structured logging
- Enhanced error handling
- Created custom `PluginError` type for more informative error messages
- Improved hot-reloading mechanism
- Added support for graceful shutdown of old plugin versions
- Performance optimizations
- Optimized plugin loading and execution paths
### Changed
- Refactored `Manager` struct to support new features
- Updated `PluginMetadata` to include dependency information and Go version
- Modified `LoadPlugin` method to support lazy loading
- Updated `ExecutePlugin` method to work with lazy-loaded plugins
- Replaced `GetEventBus` method with `SubscribeToEvent` for better encapsulation
### Fixed
- Resolved potential race conditions in plugin management operations
- Improved error handling and reporting across all operations
## [1.0.0] - 2024-07-01
### Added
- Initial release of the plugin manager
- Basic plugin loading and unloading functionality
- Simple event system for plugin lifecycle events
- Configuration management for enabled/disabled plugins
- Basic plugin execution and stats collection

View File

@@ -1,6 +1,6 @@
# Plugin Manager for Go
A flexible and robust plugin management system for Go applications.
A flexible and robust plugin management library for Go applications.
## Features
@@ -19,6 +19,14 @@ To use this plugin manager in your Go project, run:
go get github.com/matt-dunleavy/plugin-manager
```
## Installation
To use this plugin manager in your Go project, run:
```bash
go get github.com/matt-dunleavy/plugin-manager
```
## Usage
### Creating a plugin
@@ -83,7 +91,6 @@ import (
)
func main() {
// Create a new plugin manager
manager, err := pm.NewManager("plugins.json", "./plugins")
if err != nil {
@@ -115,7 +122,7 @@ func main() {
}
// Subscribe to plugin events
manager.GetEventBus().Subscribe("PluginLoaded", func(e pm.Event) {
manager.SubscribeToEvent("PluginLoaded", func(e pm.Event) {
fmt.Printf("Plugin loaded: %s\n", e.(pm.PluginLoadedEvent).PluginName)
})
}
@@ -136,19 +143,19 @@ The plugin manager uses a JSON configuration file to keep track of enabled plugi
## API Reference
- ### Manager
### Manager
- `NewManager(configPath string, pluginDir string) (*Manager, error)`
- `LoadPlugin(path string) error`
- `UnloadPlugin(name string) error`
- `ExecutePlugin(name string) error`
- `HotReload(name string, path string) error`
- `EnablePlugin(name string) error`
- `DisablePlugin(name string) error`
- `LoadEnabledPlugins(pluginDir string) error`
- `ListPlugins() []string`
- `GetPluginStats(name string) (*PluginStats, error)`
- `SubscribeToEvent(eventName string, handler EventHandler)`
- `NewManager(configPath string, pluginDir string) (*Manager, error)`
- `LoadPlugin(path string) error`
- `UnloadPlugin(name string) error`
- `ExecutePlugin(name string) error`
- `HotReload(name string, path string) error`
- `EnablePlugin(name string) error`
- `DisablePlugin(name string) error`
- `LoadEnabledPlugins(pluginDir string) error`
- `ListPlugins() []string`
- `GetPluginStats(name string) (*PluginStats, error)`
- `SubscribeToEvent(eventName string, handler EventHandler)`
### EventBus

169
discovery.go Normal file
View File

@@ -0,0 +1,169 @@
// Copyright (C) 2024 Matt Dunleavy. All rights reserved.
// Use of this source code is subject to the MIT license
// that can be found in the LICENSE file.
package pluginmanager
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"go.uber.org/zap"
"golang.org/x/crypto/ssh"
)
type PluginRepository struct {
URL string
SSHKey string
PublicKey ssh.PublicKey
}
func (m *Manager) DiscoverPlugins(dir string) error {
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if filepath.Ext(path) == ".so" {
pluginName := strings.TrimSuffix(filepath.Base(path), ".so")
if err := m.LoadPlugin(path); err != nil {
m.logger.Warn("Failed to load discovered plugin", zap.String("plugin", pluginName), zap.Error(err))
} else {
m.logger.Info("Discovered and loaded plugin", zap.String("plugin", pluginName))
}
}
return nil
})
}
func (m *Manager) SetupRemoteRepository(url, sshKeyPath string) (*PluginRepository, error) {
key, err := os.ReadFile(sshKeyPath)
if err != nil {
return nil, fmt.Errorf("failed to read SSH key: %w", err)
}
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return nil, fmt.Errorf("failed to parse SSH key: %w", err)
}
return &PluginRepository{
URL: url,
SSHKey: string(key),
PublicKey: signer.PublicKey(),
}, nil
}
func (m *Manager) DeployRepository(repo *PluginRepository, localPath string) error {
if err := m.downloadRedbean(localPath); err != nil {
return err
}
cmd := exec.Command(filepath.Join(localPath, "redbean.com"), "-v")
if repo.URL != "" {
// Deploy via SSH
cmd = exec.Command("ssh", "-i", repo.SSHKey, repo.URL, filepath.Join(localPath, "redbean.com"), "-v")
}
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to deploy repository: %w\nOutput: %s", err, string(output))
}
m.logger.Info("Repository deployed successfully", zap.String("output", string(output)))
return nil
}
func (m *Manager) downloadRedbean(localPath string) error {
resp, err := http.Get("https://redbean.dev/redbean-latest.com")
if err != nil {
return fmt.Errorf("failed to download redbean: %w", err)
}
defer resp.Body.Close()
out, err := os.Create(filepath.Join(localPath, "redbean.com"))
if err != nil {
return fmt.Errorf("failed to create redbean file: %w", err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
if err != nil {
return fmt.Errorf("failed to save redbean file: %w", err)
}
if runtime.GOOS != "windows" {
if err := os.Chmod(filepath.Join(localPath, "redbean.com"), 0755); err != nil {
return fmt.Errorf("failed to set execute permission on redbean: %w", err)
}
}
return nil
}
func (m *Manager) CheckForUpdates(repo *PluginRepository) ([]string, error) {
// Implement logic to check for updates from the repository
// This would typically involve making an HTTP request to the repository
// and comparing versions of installed plugins with available versions
return []string{}, nil
}
func (m *Manager) UpdatePlugin(repo *PluginRepository, pluginName string) error {
// Implement logic to download and update a specific plugin
return nil
}
func (m *Manager) VerifyPluginSignature(pluginPath string, publicKeyPath string) error {
// Read the plugin file
pluginData, err := os.ReadFile(pluginPath)
if err != nil {
return fmt.Errorf("failed to read plugin file: %w", err)
}
// Read the signature file
signaturePath := pluginPath + ".sig"
signatureData, err := os.ReadFile(signaturePath)
if err != nil {
return fmt.Errorf("failed to read signature file: %w", err)
}
// Read the public key
publicKeyData, err := os.ReadFile(publicKeyPath)
if err != nil {
return fmt.Errorf("failed to read public key file: %w", err)
}
// Parse the public key
block, _ := pem.Decode(publicKeyData)
if block == nil {
return fmt.Errorf("failed to parse PEM block containing the public key")
}
publicKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return fmt.Errorf("failed to parse public key: %w", err)
}
rsaPublicKey, ok := publicKey.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("public key is not an RSA public key")
}
// Verify the signature
hashed := sha256.Sum256(pluginData)
err = rsa.VerifyPKCS1v15(rsaPublicKey, crypto.SHA256, hashed[:], signatureData)
if err != nil {
return fmt.Errorf("failed to verify signature: %w", err)
}
return nil
}

View File

@@ -4,7 +4,10 @@
package pluginmanager
import "errors"
import (
"errors"
"fmt"
)
var (
ErrPluginAlreadyLoaded = errors.New("plugin already loaded")
@@ -14,4 +17,21 @@ var (
ErrMissingDependency = errors.New("missing plugin dependency")
ErrCircularDependency = errors.New("circular plugin dependency detected")
ErrPluginSandboxViolation = errors.New("plugin attempted to violate sandbox")
)
)
type PluginError struct {
Op string
Err error
Plugin string
}
func (e *PluginError) Error() string {
if e.Plugin != "" {
return fmt.Sprintf("plugin error: %s: %s: %v", e.Plugin, e.Op, e.Err)
}
return fmt.Sprintf("plugin error: %s: %v", e.Op, e.Err)
}
func (e *PluginError) Unwrap() error {
return e.Err
}

View File

@@ -1,99 +0,0 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"time"
pm "github.com/matt-dunleavy/plugin-manager"
)
func main() {
// Get the current working directory
cwd, err := os.Getwd()
if err != nil {
log.Fatalf("Failed to get current working directory: %v", err)
}
// Set up paths
configPath := filepath.Join(cwd, "plugins.json")
pluginDir := filepath.Join(cwd, "plugins")
// Ensure plugin directory exists
if err := os.MkdirAll(pluginDir, 0755); err != nil {
log.Fatalf("Failed to create plugin directory: %v", err)
}
// Create a new plugin manager
manager, err := pm.NewManager(configPath, pluginDir)
if err != nil {
log.Fatalf("Failed to create plugin manager: %v", err)
}
// Subscribe to plugin events
manager.SubscribeToEvent("PluginLoaded", func(e pm.Event) {
fmt.Printf("Event: Plugin loaded - %s\n", e.(pm.PluginLoadedEvent).PluginName)
})
manager.SubscribeToEvent("PluginUnloaded", func(e pm.Event) {
fmt.Printf("Event: Plugin unloaded - %s\n", e.(pm.PluginUnloadedEvent).PluginName)
})
// Load plugins
pluginsToLoad := []string{"hello.so", "math.so"}
for _, plugin := range pluginsToLoad {
pluginPath := filepath.Join(pluginDir, plugin)
err := manager.LoadPlugin(pluginPath)
if err != nil {
log.Printf("Failed to load plugin %s: %v", plugin, err)
}
}
// List loaded plugins
loadedPlugins := manager.ListPlugins()
fmt.Println("Loaded plugins:", loadedPlugins)
// Execute plugins
pluginsToExecute := []string{"HelloPlugin", "MathPlugin"}
for _, plugin := range pluginsToExecute {
err := manager.ExecutePlugin(plugin)
if err != nil {
log.Printf("Failed to execute %s: %v", plugin, err)
}
}
// Get plugin stats
for _, plugin := range pluginsToExecute {
stats, err := manager.GetPluginStats(plugin)
if err != nil {
fmt.Printf("%s stats not available: %v\n", plugin, err)
} else {
fmt.Printf("%s stats: %+v\n", plugin, stats)
}
}
// Hot-reload HelloPlugin
time.Sleep(2 * time.Second) // Wait to simulate some time passing
fmt.Println("\nHot-reloading HelloPlugin...")
err = manager.HotReload("HelloPlugin", filepath.Join(pluginDir, "hello.so"))
if err != nil {
log.Printf("Failed to hot-reload HelloPlugin: %v", err)
}
// Execute hot-reloaded plugin
err = manager.ExecutePlugin("HelloPlugin")
if err != nil {
log.Printf("Failed to execute hot-reloaded HelloPlugin: %v", err)
}
// Unload plugins
for _, plugin := range loadedPlugins {
err := manager.UnloadPlugin(plugin)
if err != nil {
log.Printf("Failed to unload %s: %v", plugin, err)
}
}
fmt.Println("\nFinal list of loaded plugins:", manager.ListPlugins())
}

5
examples/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module example
go 1.22.4
require github.com/matt-dunleavy/plugin-manager v0.0.0-20240706084251-0b73b624d771

2
examples/go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/matt-dunleavy/plugin-manager v0.0.0-20240706084251-0b73b624d771 h1:Imr5rapXzJ7lc7PAPmW65dZkuh+gXFqnBqdRL0vve6s=
github.com/matt-dunleavy/plugin-manager v0.0.0-20240706084251-0b73b624d771/go.mod h1:gtVoU5Qv+UkGsFALWzrbovBqELvvlxFunRyaz1PYjGo=

62
examples/main.go Normal file
View File

@@ -0,0 +1,62 @@
package main
import (
"fmt"
"log"
"path/filepath"
pm "github.com/matt-dunleavy/plugin-manager"
)
func main() {
// Initialize the plugin manager
manager, err := pm.NewManager("plugins.json", "./plugins", "public_key.pem")
if err != nil {
log.Fatalf("Failed to create plugin manager: %v", err)
}
// Subscribe to plugin events
manager.SubscribeToEvent("PluginLoaded", func(e pm.Event) {
fmt.Printf("Plugin loaded: %s\n", e.(pm.PluginLoadedEvent).PluginName)
})
// Load plugins
plugins := []string{"hello.so", "math.so"}
for _, plugin := range plugins {
err := manager.LoadPlugin(filepath.Join("./plugins", plugin))
if err != nil {
log.Printf("Failed to load plugin %s: %v", plugin, err)
}
}
// List loaded plugins
loadedPlugins := manager.ListPlugins()
fmt.Println("Loaded plugins:", loadedPlugins)
// Execute plugins
for _, name := range loadedPlugins {
err := manager.ExecutePlugin(name)
if err != nil {
log.Printf("Failed to execute plugin %s: %v", name, err)
}
}
// Get and print plugin stats
for _, name := range loadedPlugins {
stats, err := manager.GetPluginStats(name)
if err != nil {
log.Printf("Failed to get stats for plugin %s: %v", name, err)
} else {
fmt.Printf("Stats for %s: Executions: %d, Last execution time: %v\n",
name, stats.ExecutionCount, stats.LastExecutionTime)
}
}
// Unload a plugin
err = manager.UnloadPlugin("hello.so")
if err != nil {
log.Printf("Failed to unload plugin: %v", err)
}
fmt.Println("Remaining plugins:", manager.ListPlugins())
}

View File

@@ -1,6 +0,0 @@
{
"enabled": {
"HelloPlugin": true,
"MathPlugin": true
}
}

View File

@@ -2,29 +2,27 @@ package main
import (
"fmt"
pm "github.com/matt-dunleavy/plugin-manager"
)
type HelloPlugin struct {
greeting string
}
type HelloPlugin struct{}
func (p *HelloPlugin) Metadata() pm.PluginMetadata {
return pm.PluginMetadata{
Name: "HelloPlugin",
Version: "1.0.0",
Dependencies: []string{},
Name: "HelloPlugin",
Version: "1.0.0",
Dependencies: map[string]string{},
}
}
func (p *HelloPlugin) Init() error {
p.greeting = "Hello, World!"
fmt.Println("HelloPlugin initialized")
return nil
}
func (p *HelloPlugin) Execute() error {
fmt.Println(p.greeting)
fmt.Println("Hello from HelloPlugin!")
return nil
}
@@ -33,4 +31,19 @@ func (p *HelloPlugin) Shutdown() error {
return nil
}
func (p *HelloPlugin) PreLoad() error {
fmt.Println("HelloPlugin pre-load")
return nil
}
func (p *HelloPlugin) PostLoad() error {
fmt.Println("HelloPlugin post-load")
return nil
}
func (p *HelloPlugin) PreUnload() error {
fmt.Println("HelloPlugin pre-unload")
return nil
}
var Plugin HelloPlugin

View File

@@ -2,6 +2,7 @@ package main
import (
"fmt"
pm "github.com/matt-dunleavy/plugin-manager"
)
@@ -9,9 +10,9 @@ type MathPlugin struct{}
func (p *MathPlugin) Metadata() pm.PluginMetadata {
return pm.PluginMetadata{
Name: "MathPlugin",
Version: "1.0.0",
Dependencies: []string{},
Name: "MathPlugin",
Version: "1.0.0",
Dependencies: map[string]string{},
}
}
@@ -21,11 +22,8 @@ func (p *MathPlugin) Init() error {
}
func (p *MathPlugin) Execute() error {
a, b := 10, 5
fmt.Printf("Addition: %d + %d = %d\n", a, b, a+b)
fmt.Printf("Subtraction: %d - %d = %d\n", a, b, a-b)
fmt.Printf("Multiplication: %d * %d = %d\n", a, b, a*b)
fmt.Printf("Division: %d / %d = %d\n", a, b, a/b)
result := p.Add(5, 3)
fmt.Printf("MathPlugin: 5 + 3 = %d\n", result)
return nil
}
@@ -34,4 +32,23 @@ func (p *MathPlugin) Shutdown() error {
return nil
}
func (p *MathPlugin) PreLoad() error {
fmt.Println("MathPlugin pre-load")
return nil
}
func (p *MathPlugin) PostLoad() error {
fmt.Println("MathPlugin post-load")
return nil
}
func (p *MathPlugin) PreUnload() error {
fmt.Println("MathPlugin pre-unload")
return nil
}
func (p *MathPlugin) Add(a, b int) int {
return a + b
}
var Plugin MathPlugin

10
go.mod
View File

@@ -1,3 +1,13 @@
module github.com/matt-dunleavy/plugin-manager
go 1.22.4
require (
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.25.0
)
require (
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/sys v0.22.0 // indirect
)

20
go.sum Normal file
View File

@@ -0,0 +1,20 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,76 +1,131 @@
// Copyright (C) 2024 Matt Dunleavy. All rights reserved.
// Use of this source code is subject to the MIT license
// that can be found in the LICENSE file.
// FILE: manager.go
package pluginmanager
import (
"fmt"
"path/filepath"
"plugin"
"strconv"
"strings"
"sync"
"time"
"path/filepath"
"go.uber.org/zap"
)
type Manager struct {
plugins map[string]Plugin
config *Config
dependencies map[string][]string
stats map[string]*PluginStats
eventBus *EventBus
sandbox Sandbox
mu sync.RWMutex
plugins map[string]*lazyPlugin
config *Config
dependencies map[string][]string
stats map[string]*PluginStats
eventBus *EventBus
sandbox Sandbox
logger *zap.Logger
publicKeyPath string
mu sync.RWMutex
}
func NewManager(configPath string, pluginDir string) (*Manager, error) {
type lazyPlugin struct {
path string
loaded Plugin
}
func (lp *lazyPlugin) load() error {
if lp.loaded == nil {
p, err := plugin.Open(lp.path)
if err != nil {
return fmt.Errorf("failed to open plugin: %w", err)
}
symPlugin, err := p.Lookup(PluginSymbol)
if err != nil {
return fmt.Errorf("failed to lookup plugin symbol: %w", err)
}
plugin, ok := symPlugin.(Plugin)
if !ok {
return fmt.Errorf("invalid plugin interface")
}
lp.loaded = plugin
}
return nil
}
func NewManager(configPath, pluginDir, publicKeyPath string) (*Manager, error) {
config, err := LoadConfig(configPath)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to load config: %w", err)
}
logger, _ := zap.NewProduction()
sandboxDir := filepath.Join(pluginDir, "sandbox")
return &Manager{
plugins: make(map[string]Plugin),
config: config,
dependencies: make(map[string][]string),
stats: make(map[string]*PluginStats),
eventBus: NewEventBus(),
sandbox: NewDefaultSandbox(pluginDir),
plugins: make(map[string]*lazyPlugin),
config: config,
dependencies: make(map[string][]string),
stats: make(map[string]*PluginStats),
eventBus: NewEventBus(),
sandbox: NewLinuxSandbox(sandboxDir),
logger: logger,
publicKeyPath: publicKeyPath,
}, nil
}
func (m *Manager) LoadPlugin(path string) error {
if err := m.sandbox.VerifyPluginPath(path); err != nil {
return err
}
if err := m.sandbox.Enable(); err != nil {
return err
}
defer m.sandbox.Disable()
plugin, err := LoadPlugin(path)
if err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
pluginName := filepath.Base(path)
if _, exists := m.plugins[pluginName]; exists {
return fmt.Errorf("plugin %s already loaded", pluginName)
}
if err := m.VerifyPluginSignature(path, m.publicKeyPath); err != nil {
return fmt.Errorf("failed to verify plugin signature: %w", err)
}
lazyPlug := &lazyPlugin{path: path}
if err := lazyPlug.load(); err != nil {
return fmt.Errorf("failed to load plugin %s: %w", pluginName, err)
}
plugin := lazyPlug.loaded
if err := plugin.PreLoad(); err != nil {
return fmt.Errorf("pre-load hook failed for %s: %w", pluginName, err)
}
if err := plugin.Init(); err != nil {
return fmt.Errorf("initialization failed for %s: %w", pluginName, err)
}
if err := plugin.PostLoad(); err != nil {
return fmt.Errorf("post-load hook failed for %s: %w", pluginName, err)
}
m.plugins[pluginName] = lazyPlug
m.stats[pluginName] = &PluginStats{}
metadata := plugin.Metadata()
if _, exists := m.plugins[metadata.Name]; exists {
return ErrPluginAlreadyLoaded
m.dependencies[pluginName] = make([]string, 0, len(metadata.Dependencies))
for dep, constraint := range metadata.Dependencies {
m.dependencies[pluginName] = append(m.dependencies[pluginName], dep)
if err := m.checkDependency(dep, constraint); err != nil {
delete(m.plugins, pluginName)
delete(m.stats, pluginName)
delete(m.dependencies, pluginName)
return fmt.Errorf("dependency check failed for %s: %w", pluginName, err)
}
}
if err := m.checkDependencies(metadata); err != nil {
return err
}
m.eventBus.Publish(PluginLoadedEvent{PluginName: pluginName})
m.logger.Info("Plugin loaded", zap.String("plugin", pluginName))
m.plugins[metadata.Name] = plugin
m.dependencies[metadata.Name] = metadata.Dependencies
m.stats[metadata.Name] = &PluginStats{}
m.eventBus.Publish(PluginLoadedEvent{PluginName: metadata.Name})
return plugin.Init()
return nil
}
func (m *Manager) UnloadPlugin(name string) error {
@@ -82,8 +137,12 @@ func (m *Manager) UnloadPlugin(name string) error {
return ErrPluginNotFound
}
if err := plugin.Shutdown(); err != nil {
return err
if err := plugin.loaded.PreUnload(); err != nil {
return fmt.Errorf("pre-unload hook failed for %s: %w", name, err)
}
if err := plugin.loaded.Shutdown(); err != nil {
return fmt.Errorf("shutdown failed for %s: %w", name, err)
}
delete(m.plugins, name)
@@ -91,6 +150,7 @@ func (m *Manager) UnloadPlugin(name string) error {
delete(m.stats, name)
m.eventBus.Publish(PluginUnloadedEvent{PluginName: name})
m.logger.Info("Plugin unloaded", zap.String("plugin", name))
return nil
}
@@ -106,12 +166,16 @@ func (m *Manager) ExecutePlugin(name string) error {
}
if err := m.sandbox.Enable(); err != nil {
return err
return fmt.Errorf("failed to enable sandbox for %s: %w", name, err)
}
defer m.sandbox.Disable()
if err := plugin.load(); err != nil {
return fmt.Errorf("failed to load plugin %s: %w", name, err)
}
start := time.Now()
err := plugin.Execute()
err := plugin.loaded.Execute()
executionTime := time.Since(start)
m.mu.Lock()
@@ -120,40 +184,129 @@ func (m *Manager) ExecutePlugin(name string) error {
stats.TotalExecutionTime += executionTime
m.mu.Unlock()
return err
if err != nil {
return fmt.Errorf("execution failed for %s: %w", name, err)
}
m.logger.Info("Plugin executed", zap.String("plugin", name), zap.Duration("duration", executionTime))
return nil
}
func (m *Manager) HotReload(name string, path string) error {
if err := m.sandbox.VerifyPluginPath(path); err != nil {
return err
}
if err := m.sandbox.Enable(); err != nil {
return err
}
defer m.sandbox.Disable()
newPlugin, err := LoadPlugin(path)
if err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
oldPlugin, exists := m.plugins[name]
if !exists {
oldPlugin, ok := m.plugins[name]
if !ok {
return ErrPluginNotFound
}
if err := oldPlugin.Shutdown(); err != nil {
return err
if err := m.VerifyPluginSignature(path, m.publicKeyPath); err != nil {
return fmt.Errorf("failed to verify new plugin signature: %w", err)
}
m.plugins[name] = newPlugin
m.eventBus.Publish(PluginHotReloadedEvent{PluginName: name})
newLazyPlugin := &lazyPlugin{path: path}
if err := newLazyPlugin.load(); err != nil {
return fmt.Errorf("failed to load new version of %s: %w", name, err)
}
return newPlugin.Init()
newPlugin := newLazyPlugin.loaded
metadata := newPlugin.Metadata()
for dep, constraint := range metadata.Dependencies {
if err := m.checkDependency(dep, constraint); err != nil {
return fmt.Errorf("dependency check failed for new version of %s: %w", name, err)
}
}
if err := newPlugin.Init(); err != nil {
return fmt.Errorf("initialization failed for new version of %s: %w", name, err)
}
if err := oldPlugin.loaded.PreUnload(); err != nil {
m.logger.Warn("Pre-unload hook failed for old version", zap.String("plugin", name), zap.Error(err))
}
if err := oldPlugin.loaded.Shutdown(); err != nil {
m.logger.Warn("Shutdown failed for old version", zap.String("plugin", name), zap.Error(err))
}
m.plugins[name] = newLazyPlugin
m.dependencies[name] = make([]string, 0, len(metadata.Dependencies))
for dep := range metadata.Dependencies {
m.dependencies[name] = append(m.dependencies[name], dep)
}
m.eventBus.Publish(PluginHotReloadedEvent{PluginName: name})
m.logger.Info("Plugin hot-reloaded", zap.String("plugin", name))
return nil
}
func (m *Manager) checkDependency(depName, constraint string) error {
depPlugin, exists := m.plugins[depName]
if !exists {
return fmt.Errorf("missing dependency: %s", depName)
}
if err := depPlugin.load(); err != nil {
return fmt.Errorf("failed to load dependency %s: %w", depName, err)
}
depVersion := depPlugin.loaded.Metadata().Version
if !isVersionCompatible(depVersion, constraint) {
return fmt.Errorf("incompatible version for dependency %s: required %s, got %s", depName, constraint, depVersion)
}
return nil
}
func isVersionCompatible(currentVersion, constraint string) bool {
parts := strings.Split(constraint, " ")
if len(parts) != 2 {
return false
}
operator := parts[0]
requiredVersion := parts[1]
switch operator {
case ">=":
return compareVersions(currentVersion, requiredVersion) >= 0
case ">":
return compareVersions(currentVersion, requiredVersion) > 0
case "<=":
return compareVersions(currentVersion, requiredVersion) <= 0
case "<":
return compareVersions(currentVersion, requiredVersion) < 0
case "==":
return compareVersions(currentVersion, requiredVersion) == 0
default:
return false
}
}
func compareVersions(v1, v2 string) int {
parts1 := strings.Split(v1, ".")
parts2 := strings.Split(v2, ".")
for i := 0; i < len(parts1) && i < len(parts2); i++ {
n1, _ := strconv.Atoi(parts1[i])
n2, _ := strconv.Atoi(parts2[i])
if n1 < n2 {
return -1
} else if n1 > n2 {
return 1
}
}
if len(parts1) < len(parts2) {
return -1
} else if len(parts1) > len(parts2) {
return 1
}
return 0
}
func (m *Manager) EnablePlugin(name string) error {
@@ -181,33 +334,10 @@ func (m *Manager) LoadEnabledPlugins(pluginDir string) error {
return nil
}
func (m *Manager) checkDependencies(metadata PluginMetadata) error {
visited := make(map[string]bool)
return m.dfs(metadata.Name, visited)
}
func (m *Manager) dfs(name string, visited map[string]bool) error {
if visited[name] {
return ErrCircularDependency
}
visited[name] = true
for _, dep := range m.dependencies[name] {
if _, exists := m.plugins[dep]; !exists {
return fmt.Errorf("%w: %s", ErrMissingDependency, dep)
}
if err := m.dfs(dep, visited); err != nil {
return err
}
}
visited[name] = false
return nil
}
func (m *Manager) ListPlugins() []string {
m.mu.RLock()
defer m.mu.RUnlock()
plugins := make([]string, 0, len(m.plugins))
for name := range m.plugins {
plugins = append(plugins, name)
@@ -218,7 +348,7 @@ func (m *Manager) ListPlugins() []string {
func (m *Manager) GetPluginStats(name string) (*PluginStats, error) {
m.mu.RLock()
defer m.mu.RUnlock()
stats, ok := m.stats[name]
if !ok {
return nil, ErrPluginNotFound
@@ -228,4 +358,4 @@ func (m *Manager) GetPluginStats(name string) (*PluginStats, error) {
func (m *Manager) SubscribeToEvent(eventName string, handler EventHandler) {
m.eventBus.Subscribe(eventName, handler)
}
}

View File

@@ -12,13 +12,18 @@ import (
type PluginMetadata struct {
Name string
Version string
Dependencies []string
Dependencies map[string]string
GoVersion string
Signature []byte
}
type Plugin interface {
Metadata() PluginMetadata
PreLoad() error
Init() error
PostLoad() error
Execute() error
PreUnload() error
Shutdown() error
}
@@ -33,17 +38,17 @@ const PluginSymbol = "Plugin"
func LoadPlugin(path string) (Plugin, error) {
p, err := plugin.Open(path)
if err != nil {
return nil, err
return nil, &PluginError{Op: "open", Err: err}
}
symPlugin, err := p.Lookup(PluginSymbol)
if err != nil {
return nil, err
return nil, &PluginError{Op: "lookup", Err: err}
}
plugin, ok := symPlugin.(Plugin)
if !ok {
return nil, ErrInvalidPluginInterface
return nil, &PluginError{Op: "assert", Err: ErrInvalidPluginInterface}
}
return plugin, nil

View File

@@ -7,7 +7,7 @@ package pluginmanager
import (
"os"
"path/filepath"
"log"
"syscall"
)
type Sandbox interface {
@@ -16,38 +16,68 @@ type Sandbox interface {
VerifyPluginPath(path string) error
}
type DefaultSandbox struct {
pluginDir string
type LinuxSandbox struct {
originalDir string
originalUmask int
chrootDir string
}
func NewDefaultSandbox(pluginDir string) *DefaultSandbox {
absPath, err := filepath.Abs(pluginDir)
func NewLinuxSandbox(chrootDir string) *LinuxSandbox {
if chrootDir == "" {
chrootDir = "./sandbox"
}
return &LinuxSandbox{
chrootDir: chrootDir,
}
}
func (s *LinuxSandbox) Enable() error {
var err error
s.originalDir, err = os.Getwd()
if err != nil {
log.Printf("Error getting absolute path for plugin directory: %v", err)
return &DefaultSandbox{pluginDir: pluginDir}
return err
}
return &DefaultSandbox{
pluginDir: absPath,
if err := os.MkdirAll(s.chrootDir, 0755); err != nil {
return err
}
}
func (s *DefaultSandbox) Enable() error {
return os.Chdir(s.pluginDir)
}
if err := syscall.Chroot(s.chrootDir); err != nil {
return err
}
if err := os.Chdir("/"); err != nil {
return err
}
s.originalUmask = syscall.Umask(0)
func (s *DefaultSandbox) Disable() error {
return nil
}
func (s *DefaultSandbox) VerifyPluginPath(path string) error {
func (s *LinuxSandbox) Disable() error {
syscall.Umask(s.originalUmask)
if err := syscall.Chroot("."); err != nil {
return err
}
if err := os.Chdir(s.originalDir); err != nil {
return err
}
return nil
}
func (s *LinuxSandbox) VerifyPluginPath(path string) error {
absPath, err := filepath.Abs(path)
if err != nil {
return err
}
if !filepath.HasPrefix(absPath, s.pluginDir) {
if !filepath.HasPrefix(absPath, s.chrootDir) {
return ErrPluginSandboxViolation
}
return nil
}