From 1d354512e6a9c9417276f76239c1210fd51f6d73 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 30 Mar 2026 22:21:07 +0200 Subject: [PATCH] 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. --- go.mod | 1 + go.sum | 2 ++ pkg/plugins/manager.go | 72 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index d73b11ea1..0508c72dd 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index ca5fe1488..22ac3e398 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/plugins/manager.go b/pkg/plugins/manager.go index a704ccb7a..302478243 100644 --- a/pkg/plugins/manager.go +++ b/pkg/plugins/manager.go @@ -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()) +}