feat(plugins): add plugin system interfaces and manager

Add the core plugin system with four interfaces:
- Plugin: base lifecycle (Name, Version, Init, Shutdown)
- MigrationPlugin: database migrations
- AuthenticatedRouterPlugin: routes behind auth
- UnauthenticatedRouterPlugin: public routes

The Manager handles loading, initialization, shutdown, and route
registration. Includes native .so loader (marked deprecated) and
yaegi loader integration point.
This commit is contained in:
kolaente 2026-03-30 22:21:07 +02:00 committed by kolaente
parent 167380a01e
commit 1d354512e6
3 changed files with 67 additions and 8 deletions

1
go.mod
View File

@ -71,6 +71,7 @@ require (
github.com/stretchr/testify v1.11.1
github.com/swaggo/swag v1.16.6
github.com/tkuchiki/go-timezone v0.2.3
github.com/traefik/yaegi v0.16.1
github.com/ulule/limiter/v3 v3.11.2
github.com/wneessen/go-mail v0.7.2
github.com/yuin/goldmark v1.7.16

2
go.sum
View File

@ -522,6 +522,8 @@ github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk=
github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk=
github.com/tkuchiki/go-timezone v0.2.3 h1:D3TVdIPrFsu9lxGxqNX2wsZwn1MZtTqTW0mdevMozHc=
github.com/tkuchiki/go-timezone v0.2.3/go.mod h1:oFweWxYl35C/s7HMVZXiA19Jr9Y0qJHMaG/J2TES4LY=
github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E=
github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY=
github.com/ulule/limiter/v3 v3.11.2 h1:P4yOrxoEMJbOTfRJR2OzjL90oflzYPPmWg+dvwN2tHA=
github.com/ulule/limiter/v3 v3.11.2/go.mod h1:QG5GnFOCV+k7lrL5Y8kgEeeflPH3+Cviqlqa8SVSQxI=
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=

View File

@ -29,6 +29,18 @@ import (
"github.com/labstack/echo/v5"
)
// YaegiPluginLoader is a function that loads a plugin from a directory of Go source files.
// It is set by the yaegi package's init() to avoid an import cycle.
var YaegiPluginLoader func(dir string) (*LoadedYaegiPlugin, error)
// LoadedYaegiPlugin holds a plugin loaded via Yaegi along with its optional capabilities.
type LoadedYaegiPlugin struct {
Plugin Plugin
AuthRouter AuthenticatedRouterPlugin
UnauthRouter UnauthenticatedRouterPlugin
Migration MigrationPlugin
}
// Manager handles loading and managing plugins.
type Manager struct {
plugins []Plugin
@ -90,6 +102,7 @@ func RegisterPluginRoutes(authenticated *echo.Group, unauthenticated *echo.Group
}
func (m *Manager) loadPlugins(paths []string) error {
loader := config.PluginsLoader.GetString()
for _, p := range paths {
entries, err := os.ReadDir(p)
if err != nil {
@ -99,19 +112,31 @@ func (m *Manager) loadPlugins(paths []string) error {
return err
}
for _, e := range entries {
if filepath.Ext(e.Name()) != ".so" {
continue
}
full := filepath.Join(p, e.Name())
if err := m.loadPlugin(full); err != nil {
log.Errorf("Failed to load plugin %s: %s", e.Name(), err)
switch loader {
case "native":
if filepath.Ext(e.Name()) != ".so" {
continue
}
if err := m.loadNativePlugin(full); err != nil {
log.Errorf("Failed to load native plugin %s: %s", e.Name(), err)
}
case "yaegi":
if !e.IsDir() {
continue
}
if err := m.loadYaegiPlugin(full); err != nil {
log.Errorf("Failed to load yaegi plugin %s: %s", e.Name(), err)
}
}
}
}
return nil
}
func (m *Manager) loadPlugin(path string) error {
// Deprecated: native Go plugins are fragile (require exact Go version and dependency
// match with the host binary) and will be removed in a future version. Use yaegi instead.
func (m *Manager) loadNativePlugin(path string) error {
pl, err := goplugin.Open(path)
if err != nil {
return err
@ -125,7 +150,7 @@ func (m *Manager) loadPlugin(path string) error {
return errors.New("invalid plugin entry point")
}
p := newPlugin()
m.plugins = append(m.plugins, p)
m.registerPlugin(p)
if mp, ok := p.(MigrationPlugin); ok {
m.migrationPlugs = append(m.migrationPlugs, mp)
@ -140,7 +165,38 @@ func (m *Manager) loadPlugin(path string) error {
m.unauthenticatedRouterPlugs = append(m.unauthenticatedRouterPlugs, urp)
}
log.Infof("Loaded plugin %s", p.Name())
return nil
}
func (m *Manager) loadYaegiPlugin(dir string) error {
if YaegiPluginLoader == nil {
return errors.New("yaegi plugin loader not registered")
}
loaded, err := YaegiPluginLoader(dir)
if err != nil {
return err
}
m.registerPlugin(loaded.Plugin)
if loaded.AuthRouter != nil {
m.authenticatedRouterPlugs = append(m.authenticatedRouterPlugs, loaded.AuthRouter)
}
if loaded.UnauthRouter != nil {
m.unauthenticatedRouterPlugs = append(m.unauthenticatedRouterPlugs, loaded.UnauthRouter)
}
if loaded.Migration != nil {
m.migrationPlugs = append(m.migrationPlugs, loaded.Migration)
migration.AddPluginMigrations(loaded.Migration.Migrations())
}
return nil
}
func (m *Manager) registerPlugin(p Plugin) {
m.plugins = append(m.plugins, p)
log.Infof("Loaded plugin %s v%s", p.Name(), p.Version())
}