mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-04-29 19:10:51 -05:00
feat(plugins): add rudimentary plugin system
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -29,6 +29,7 @@ os-packages/
|
||||
mage_output_file.go
|
||||
mage-static
|
||||
.DS_Store
|
||||
/plugins/*
|
||||
|
||||
# Devenv
|
||||
.devenv*
|
||||
@@ -44,3 +45,4 @@ devenv.local.nix
|
||||
/.claude/
|
||||
PLAN.md
|
||||
/.crush/
|
||||
|
||||
|
||||
52
examples/plugins/example/main.go
Normal file
52
examples/plugins/example/main.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"code.vikunja.io/api/pkg/events"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/plugins"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
)
|
||||
|
||||
type ExamplePlugin struct{}
|
||||
|
||||
func (p *ExamplePlugin) Name() string { return "example" }
|
||||
func (p *ExamplePlugin) Version() string { return "1.0.0" }
|
||||
func (p *ExamplePlugin) Init() error {
|
||||
log.Infof("example plugin initialized")
|
||||
|
||||
events.RegisterListener((&models.TaskCreatedEvent{}).Name(), &TestListener{})
|
||||
|
||||
return nil
|
||||
}
|
||||
func (p *ExamplePlugin) Shutdown() error { return nil }
|
||||
|
||||
func NewPlugin() plugins.Plugin { return &ExamplePlugin{} }
|
||||
|
||||
type TestListener struct{}
|
||||
|
||||
func (t *TestListener) Handle(msg *message.Message) error {
|
||||
log.Infof("TestListener received message: %s", string(msg.Payload))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TestListener) Name() string {
|
||||
return "TestListener"
|
||||
}
|
||||
24
magefile.go
24
magefile.go
@@ -73,6 +73,7 @@ var (
|
||||
"dev:make-event": Dev.MakeEvent,
|
||||
"dev:make-listener": Dev.MakeListener,
|
||||
"dev:make-notification": Dev.MakeNotification,
|
||||
"plugins:build": Plugins.Build,
|
||||
"lint": Check.Golangci,
|
||||
"lint:fix": Check.GolangciFix,
|
||||
"generate:config-yaml": Generate.ConfigYAML,
|
||||
@@ -1393,3 +1394,26 @@ func generateConfigYAMLFromJSON(yamlPath string, commented bool) {
|
||||
func (Generate) ConfigYAML(commented bool) {
|
||||
generateConfigYAMLFromJSON(DefaultConfigYAMLSamplePath, commented)
|
||||
}
|
||||
|
||||
type Plugins mg.Namespace
|
||||
|
||||
// Build compiles a Go plugin at the provided path.
|
||||
func (Plugins) Build(pathToSourceFiles string) error {
|
||||
mg.Deps(initVars)
|
||||
if pathToSourceFiles == "" {
|
||||
return fmt.Errorf("please provide a plugin path")
|
||||
}
|
||||
|
||||
// Convert relative path to absolute path
|
||||
if !strings.HasPrefix(pathToSourceFiles, "/") {
|
||||
absPath, err := filepath.Abs(pathToSourceFiles)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve absolute path: %v", err)
|
||||
}
|
||||
pathToSourceFiles = absPath
|
||||
}
|
||||
|
||||
out := filepath.Join(RootPath, "plugins", filepath.Base(pathToSourceFiles)+".so")
|
||||
runAndStreamOutput("go", "build", "-buildmode=plugin", "-o", out, pathToSourceFiles)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"code.vikunja.io/api/pkg/cron"
|
||||
"code.vikunja.io/api/pkg/initialize"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/plugins"
|
||||
"code.vikunja.io/api/pkg/routes"
|
||||
"code.vikunja.io/api/pkg/utils"
|
||||
"code.vikunja.io/api/pkg/version"
|
||||
@@ -160,5 +161,6 @@ var webCmd = &cobra.Command{
|
||||
e.Logger.Fatal(err)
|
||||
}
|
||||
cron.Stop()
|
||||
plugins.Shutdown()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -207,6 +207,9 @@ const (
|
||||
AutoTLSEnabled Key = `autotls.enabled`
|
||||
AutoTLSEmail Key = `autotls.email`
|
||||
AutoTLSRenewBefore Key = `autotls.renewbefore`
|
||||
|
||||
PluginsEnabled Key = `plugins.enabled`
|
||||
PluginsDir Key = `plugins.dir`
|
||||
)
|
||||
|
||||
// GetString returns a string config value
|
||||
@@ -446,6 +449,9 @@ func InitDefaultConfig() {
|
||||
WebhooksTimeoutSeconds.setDefault(30)
|
||||
// AutoTLS
|
||||
AutoTLSRenewBefore.setDefault("720h") // 30days in hours
|
||||
// Plugins
|
||||
PluginsEnabled.setDefault(false)
|
||||
PluginsDir.setDefault(filepath.Join(ServiceRootpath.GetString(), "plugins"))
|
||||
}
|
||||
|
||||
func GetConfigValueFromFile(configKey string) string {
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"code.vikunja.io/api/pkg/modules/auth/openid"
|
||||
"code.vikunja.io/api/pkg/modules/keyvalue"
|
||||
migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler"
|
||||
"code.vikunja.io/api/pkg/plugins"
|
||||
"code.vikunja.io/api/pkg/red"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
)
|
||||
@@ -98,6 +99,9 @@ func FullInitWithoutAsync() {
|
||||
|
||||
// Load translations
|
||||
i18n.Init()
|
||||
|
||||
// Initialize plugins
|
||||
plugins.Initialize()
|
||||
}
|
||||
|
||||
// FullInit initializes all kinds of things in the right order
|
||||
|
||||
@@ -39,6 +39,11 @@ import (
|
||||
|
||||
var migrations []*xormigrate.Migration
|
||||
|
||||
// AddPluginMigrations adds migrations provided by plugins to the global list.
|
||||
func AddPluginMigrations(ms []*xormigrate.Migration) {
|
||||
migrations = append(migrations, ms...)
|
||||
}
|
||||
|
||||
// A helper function because we need a migration in various places which we can't really solve with an init() function.
|
||||
func initMigration(x *xorm.Engine) *xormigrate.Xormigrate {
|
||||
// Get our own xorm engine if we don't have one
|
||||
|
||||
35
pkg/plugins/interfaces.go
Normal file
35
pkg/plugins/interfaces.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"src.techknowlogick.com/xormigrate"
|
||||
)
|
||||
|
||||
// Plugin is the base interface all plugins need to implement.
|
||||
type Plugin interface {
|
||||
Name() string
|
||||
Version() string
|
||||
Init() error
|
||||
Shutdown() error
|
||||
}
|
||||
|
||||
// MigrationPlugin lets a plugin provide database migrations.
|
||||
type MigrationPlugin interface {
|
||||
Plugin
|
||||
Migrations() []*xormigrate.Migration
|
||||
}
|
||||
119
pkg/plugins/manager.go
Normal file
119
pkg/plugins/manager.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
goplugin "plugin"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/migration"
|
||||
)
|
||||
|
||||
// Manager handles loading and managing plugins.
|
||||
type Manager struct {
|
||||
plugins []Plugin
|
||||
migrationPlugs []MigrationPlugin
|
||||
}
|
||||
|
||||
var manager = &Manager{}
|
||||
|
||||
// ManagerInstance returns the global plugin manager.
|
||||
func ManagerInstance() *Manager { return manager }
|
||||
|
||||
// Initialize loads plugins and runs their migrations and init functions.
|
||||
func Initialize() {
|
||||
if !config.PluginsEnabled.GetBool() {
|
||||
return
|
||||
}
|
||||
|
||||
paths := []string{config.PluginsDir.GetString()}
|
||||
if err := manager.loadPlugins(paths); err != nil {
|
||||
log.Fatalf("Loading plugins failed: %v", err)
|
||||
}
|
||||
|
||||
// Run plugin migrations after core migrations
|
||||
if len(manager.migrationPlugs) > 0 {
|
||||
migration.Migrate(nil)
|
||||
}
|
||||
|
||||
for _, p := range manager.plugins {
|
||||
if err := p.Init(); err != nil {
|
||||
log.Errorf("Plugin %s failed to init: %s", p.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown calls Shutdown on all loaded plugins.
|
||||
func Shutdown() {
|
||||
for _, p := range manager.plugins {
|
||||
if err := p.Shutdown(); err != nil {
|
||||
log.Errorf("Plugin %s shutdown failed: %s", p.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) loadPlugins(paths []string) error {
|
||||
for _, p := range paths {
|
||||
entries, err := os.ReadDir(p)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) loadPlugin(path string) error {
|
||||
pl, err := goplugin.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sym, err := pl.Lookup("NewPlugin")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newPlugin, ok := sym.(func() Plugin)
|
||||
if !ok {
|
||||
return errors.New("invalid plugin entry point")
|
||||
}
|
||||
p := newPlugin()
|
||||
m.plugins = append(m.plugins, p)
|
||||
|
||||
if mp, ok := p.(MigrationPlugin); ok {
|
||||
m.migrationPlugs = append(m.migrationPlugs, mp)
|
||||
migration.AddPluginMigrations(mp.Migrations())
|
||||
}
|
||||
|
||||
log.Infof("Loaded plugin %s", p.Name())
|
||||
|
||||
return nil
|
||||
}
|
||||
48
pkg/plugins/registry.go
Normal file
48
pkg/plugins/registry.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package plugins
|
||||
|
||||
import "sync"
|
||||
|
||||
// Registry keeps track of loaded plugins.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
plugins map[string]Plugin
|
||||
}
|
||||
|
||||
// NewRegistry creates a new Registry.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{plugins: make(map[string]Plugin)}
|
||||
}
|
||||
|
||||
// Add registers a plugin.
|
||||
func (r *Registry) Add(p Plugin) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.plugins[p.Name()] = p
|
||||
}
|
||||
|
||||
// All returns all registered plugins.
|
||||
func (r *Registry) All() []Plugin {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
res := make([]Plugin, 0, len(r.plugins))
|
||||
for _, p := range r.plugins {
|
||||
res = append(res, p)
|
||||
}
|
||||
return res
|
||||
}
|
||||
Reference in New Issue
Block a user