diff --git a/cmd/channel/email/main.go b/cmd/channel/email/main.go index ac6edd56b..e5d502868 100644 --- a/cmd/channel/email/main.go +++ b/cmd/channel/email/main.go @@ -3,7 +3,9 @@ package main import ( "bytes" "encoding/json" + "errors" "fmt" + "github.com/icinga/icinga-notifications/internal" "github.com/icinga/icinga-notifications/pkg/plugin" "net" "net/smtp" @@ -54,6 +56,10 @@ func (ch *Email) SetConfig(jsonStr json.RawMessage) error { return fmt.Errorf("failed to load config: %s %w", jsonStr, err) } + if ch.From == "fail" { + return errors.New("dummy fail") + } + if ch.Host == "" { ch.Host = "localhost" } @@ -80,7 +86,76 @@ func (ch *Email) SetConfig(jsonStr json.RawMessage) error { } func (ch *Email) GetInfo() *plugin.Info { - return &plugin.Info{Name: "Email"} + var elements []*plugin.FormElement + elements = append(elements, + &plugin.FormElement{ + Name: "host", + Type: "text", + Options: map[string]string{ + "label": "SMTP Host", + "placeholder": "localhost", + }, + }, + &plugin.FormElement{ + Name: "port", + Type: "select", + Options: map[string]string{ + "label": "SMTP Port", + "options": `{"25":25, "465":465, "587":587, "2525":2525}`, + }, + }, + &plugin.FormElement{ + Name: "from", + Type: "text", + Options: map[string]string{ + "auto-complete": "off", + "label": "From", + }, + }, + &plugin.FormElement{ + Name: "password", + Type: "password", + Options: map[string]string{ + "auto-complete": "off", + "label": "Password", + }, + }, + &plugin.FormElement{ + Name: "tls", + Type: "checkbox", + Options: map[string]string{ + "label": "TLS / SSL", + "class": "autosubmit", + "checkedValue": "1", + "uncheckedValue": "0", + "value": "1", + }, + }, + &plugin.FormElement{ + Name: "tls_certcheck", + Type: "checkbox", + Options: map[string]string{ + "label": "Certificate Check", + "class": "autosubmit", + "checkedValue": "1", + "uncheckedValue": "0", + "value": "0", + }, + }, + ) + + configAttrs, err := json.Marshal(elements) + if err != nil { + return nil + } + + return &plugin.Info{ + FileName: "email", + Name: "Email", + Version: internal.Version.Version, + AuthorName: "Icinga GmbH", + ConfigAttributes: configAttrs, + } } func (ch *Email) GetServer() string { diff --git a/cmd/channel/rocketchat/main.go b/cmd/channel/rocketchat/main.go index db919529b..7d77d9d53 100644 --- a/cmd/channel/rocketchat/main.go +++ b/cmd/channel/rocketchat/main.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/icinga/icinga-notifications/internal" "github.com/icinga/icinga-notifications/pkg/plugin" "net/http" "time" @@ -80,5 +81,46 @@ func (ch *RocketChat) SetConfig(jsonStr json.RawMessage) error { } func (ch *RocketChat) GetInfo() *plugin.Info { - return &plugin.Info{Name: "Rocket.Chat"} + var elements []*plugin.FormElement + elements = append(elements, + &plugin.FormElement{ + Name: "url", + Type: "text", + Options: map[string]string{ + "required": "true", + "label": "Rocket.Chat URL", + }, + }, + &plugin.FormElement{ + Name: "user_id", + Type: "text", + Options: map[string]string{ + "required": "true", + "auto-complete": "off", + "label": "User ID", + }, + }, + &plugin.FormElement{ + Name: "token", + Type: "password", + Options: map[string]string{ + "required": "true", + "auto-complete": "off", + "label": "Personal Access Token", + }, + }, + ) + + configAttrs, err := json.Marshal(elements) + if err != nil { + return nil + } + + return &plugin.Info{ + FileName: "rocketchat", + Name: "Rocket.Chat", + Version: internal.Version.Version, + AuthorName: "Icinga GmbH", + ConfigAttributes: configAttrs, + } } diff --git a/cmd/icinga-notifications-daemon/main.go b/cmd/icinga-notifications-daemon/main.go index 4a685245f..b94cc2fb4 100644 --- a/cmd/icinga-notifications-daemon/main.go +++ b/cmd/icinga-notifications-daemon/main.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "github.com/icinga/icinga-notifications/internal" + "github.com/icinga/icinga-notifications/internal/channel" "github.com/icinga/icinga-notifications/internal/config" "github.com/icinga/icinga-notifications/internal/daemonConfig" "github.com/icinga/icinga-notifications/internal/listener" @@ -76,6 +77,8 @@ func main() { } } + channel.SyncPlugins(conf.ChannelPluginDir, logs, db) + runtimeConfig := config.NewRuntimeConfig(db, logs) if err := runtimeConfig.UpdateFromDatabase(context.TODO()); err != nil { logger.Fatalw("failed to load config from database", zap.Error(err)) diff --git a/internal/channel/channel.go b/internal/channel/channel.go index 6486b18fe..abb261b1e 100644 --- a/internal/channel/channel.go +++ b/internal/channel/channel.go @@ -1,17 +1,14 @@ package channel import ( - "bufio" + "errors" "fmt" - "github.com/icinga/icinga-notifications/internal/daemonConfig" - "github.com/icinga/icinga-notifications/pkg/rpc" + "github.com/icinga/icinga-notifications/internal/contracts" + "github.com/icinga/icinga-notifications/internal/event" + "github.com/icinga/icinga-notifications/internal/recipient" + "github.com/icinga/icinga-notifications/pkg/plugin" "go.uber.org/zap" - "io" - "os/exec" - "path/filepath" - "regexp" - "sync" - "time" + "strings" ) type Channel struct { @@ -21,109 +18,140 @@ type Channel struct { Config string `db:"config" json:"-"` // excluded from JSON config dump as this may contain sensitive information Logger *zap.SugaredLogger - cmd *exec.Cmd - rpc *rpc.RPC - mu sync.Mutex -} -func (c *Channel) Start() error { - c.mu.Lock() - defer c.mu.Unlock() + newConfigCh chan struct{} + stopPluginCh chan struct{} + notificationCh chan Req +} - if c.cmd != nil && c.rpc.Err() == nil { - return nil - } +type Req struct { + req *plugin.NotificationRequest + out chan<- error +} - if matched, _ := regexp.MatchString("^[a-zA-Z0-9]*$", c.Type); !matched { - return fmt.Errorf("channel type must only contain a-zA-Z0-9, %q given", c.Type) - } +func (c *Channel) Start(logger *zap.SugaredLogger) { + c.Logger = logger + c.newConfigCh = make(chan struct{}) + c.stopPluginCh = make(chan struct{}) + c.notificationCh = make(chan Req, 1) - path := filepath.Join(daemonConfig.Config().ChannelPluginDir, c.Type) + go c.runPlugin() +} - cmd := exec.Command(path) +func (c *Channel) initPlugin() *Plugin { + c.Logger.Debug("Initializing channel plugin") - writer, err := cmd.StdinPipe() + p, err := NewPlugin(c.Type, c.Logger) if err != nil { - return fmt.Errorf("failed to create stdin pipe: %w", err) + c.Logger.Errorw("Failed to initialize channel plugin", zap.Error(err)) + } else if err := p.SetConfig(c.Config); err != nil { + c.Logger.Errorw("Failed to set channel plugin config", err) + go terminate(p) + p = nil } - reader, err := cmd.StdoutPipe() - if err != nil { - return fmt.Errorf("failed to create stdout pipe: %w", err) - } + return p +} - errPipe, err := cmd.StderrPipe() - if err != nil { - return fmt.Errorf("failed to create stderr pipe: %w", err) - } +func (c *Channel) runPlugin() { + var currentlyRunningPlugin *Plugin + for { + if currentlyRunningPlugin == nil { + currentlyRunningPlugin = c.initPlugin() + } - go forwardLogs(errPipe, c.Logger) + var rpcDone <-chan struct{} + if currentlyRunningPlugin != nil { + rpcDone = currentlyRunningPlugin.rpc.Done() + } - if err = cmd.Start(); err != nil { - return fmt.Errorf("failed to start cmd: %w", err) - } - c.Logger.Debug("Cmd started successfully") + select { + case <-rpcDone: + c.Logger.Debug("rpc.Done(): Restarting plugin") - c.cmd = cmd - c.rpc = rpc.NewRPC(writer, reader, c.Logger) + if currentlyRunningPlugin != nil { + go terminate(currentlyRunningPlugin) + currentlyRunningPlugin = nil + } - if err = c.SetConfig(c.Config); err != nil { - go c.terminate(c.cmd, c.rpc) + continue + case <-c.newConfigCh: + c.Logger.Debug("new config detected: Restarting plugin") - c.cmd = nil - c.rpc = nil + if currentlyRunningPlugin != nil { + go terminate(currentlyRunningPlugin) + currentlyRunningPlugin = nil + } - return fmt.Errorf("failed to set config: %w", err) - } - c.Logger.Debugw("Successfully set config", zap.String("config", c.Config)) + continue + case <-c.stopPluginCh: + c.Logger.Debug("Stopping the channel plugin") - return nil -} + if currentlyRunningPlugin != nil { + go terminate(currentlyRunningPlugin) + currentlyRunningPlugin = nil + } -func (c *Channel) Stop() { - c.mu.Lock() - defer c.mu.Unlock() + return + case req := <-c.notificationCh: + if currentlyRunningPlugin == nil { + currentlyRunningPlugin = c.initPlugin() + } - if c.cmd == nil { - c.Logger.Debug("channel plugin has already been stopped") - return + if currentlyRunningPlugin == nil { + errMsg := "cannot send notification, plugin is not running" + c.Logger.Debug(errMsg) + req.out <- errors.New(errMsg) + } else { + go func(p *Plugin) { + req.out <- p.SendNotification(req.req) + }(currentlyRunningPlugin) + } + } } +} - go c.terminate(c.cmd, c.rpc) - - c.cmd = nil - c.rpc = nil - - c.Logger.Debug("Stopped channel plugin successfully") +func (c *Channel) Stop() { + c.stopPluginCh <- struct{}{} } -func forwardLogs(errPipe io.Reader, logger *zap.SugaredLogger) { - reader := bufio.NewReader(errPipe) - for { - line, err := reader.ReadString('\n') - if err != nil { - if err != io.EOF { - logger.Errorw("Failed to read stderr line", zap.Error(err)) - } +func (c *Channel) ReloadConfig() { + c.newConfigCh <- struct{}{} +} - return - } +func (c *Channel) Notify(contact *recipient.Contact, i contracts.Incident, ev *event.Event, icingaweb2Url string) error { + contactStruct := &plugin.Contact{FullName: contact.FullName} + for _, addr := range contact.Addresses { + contactStruct.Addresses = append(contactStruct.Addresses, &plugin.Address{Type: addr.Type, Address: addr.Address}) + } - logger.Info(line) + if !strings.HasSuffix(icingaweb2Url, "/") { + icingaweb2Url += "/" } -} -// run as go routine to terminate given channel -func (c *Channel) terminate(cmd *exec.Cmd, rpc *rpc.RPC) { - c.Logger.Debug("terminating channel plugin") - _ = rpc.Close() + req := &plugin.NotificationRequest{ + Contact: contactStruct, + Object: &plugin.Object{ + Name: i.ObjectDisplayName(), + Url: ev.URL, + Tags: ev.Tags, + ExtraTags: ev.ExtraTags, + }, + Incident: &plugin.Incident{ + Id: i.ID(), + Url: fmt.Sprintf("%snotifications/incident?id=%d", icingaweb2Url, i.ID()), + }, + Event: &plugin.Event{ + Time: ev.Time, + Type: ev.Type, + Severity: ev.Severity.String(), + Username: ev.Username, + Message: ev.Message, + }, + } - timer := time.AfterFunc(5*time.Second, func() { - c.Logger.Debug("killing the channel plugin") - _ = cmd.Process.Kill() - }) + out := make(chan error, 1) + c.notificationCh <- Req{req: req, out: out} - _ = cmd.Wait() - timer.Stop() - c.Logger.Debug("Channel plugin terminated successfully") + return <-out } diff --git a/internal/channel/plugin.go b/internal/channel/plugin.go index 8d510e980..9d8ee6022 100644 --- a/internal/channel/plugin.go +++ b/internal/channel/plugin.go @@ -1,19 +1,82 @@ package channel import ( + "bufio" "encoding/json" - "errors" "fmt" - "github.com/icinga/icinga-notifications/internal/contracts" - "github.com/icinga/icinga-notifications/internal/event" - "github.com/icinga/icinga-notifications/internal/recipient" + "github.com/icinga/icinga-notifications/internal/daemonConfig" "github.com/icinga/icinga-notifications/pkg/plugin" "github.com/icinga/icinga-notifications/pkg/rpc" - "strings" + "github.com/icinga/icingadb/pkg/icingadb" + "github.com/icinga/icingadb/pkg/logging" + "go.uber.org/zap" + "io" + "os" + "os/exec" + "path/filepath" + "sync" + "time" ) -func (c *Channel) GetInfo() (*plugin.Info, error) { - result, err := c.rpcCall(plugin.MethodGetInfo, nil) +type Plugin struct { + cmd *exec.Cmd + rpc *rpc.RPC + mu sync.Mutex + logger *zap.SugaredLogger +} + +func NewPlugin(pluginName string, logger *zap.SugaredLogger) (*Plugin, error) { + p := &Plugin{logger: logger} + err := p.Start(pluginName) + if err != nil { + return nil, err + } + + return p, nil +} + +func (p *Plugin) Start(pluginName string) error { + p.mu.Lock() + defer p.mu.Unlock() + + cmd := exec.Command(filepath.Join(daemonConfig.Config().ChannelPluginDir, pluginName)) + + writer, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("failed to create stdin pipe: %w", err) + } + + reader, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + + errPipe, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to create stderr pipe: %w", err) + } + + go forwardLogs(errPipe, p.logger) + + if err = cmd.Start(); err != nil { + return fmt.Errorf("failed to start cmd: %w", err) + } + p.logger.Info("Channel plugin started successfully") + + p.cmd = cmd + p.rpc = rpc.NewRPC(writer, reader, p.logger) + + return nil +} + +func (p *Plugin) Stop() { + p.logger.Debug("plugin.Stop() triggered") + + go terminate(p) +} + +func (p *Plugin) GetInfo() (*plugin.Info, error) { + result, err := p.rpc.Call(plugin.MethodGetInfo, nil) if err != nil { return nil, err } @@ -27,60 +90,93 @@ func (c *Channel) GetInfo() (*plugin.Info, error) { return info, nil } -func (c *Channel) SetConfig(config string) error { - _, err := c.rpcCall(plugin.MethodSetConfig, json.RawMessage(config)) +func (p *Plugin) SetConfig(config string) error { + _, err := p.rpc.Call(plugin.MethodSetConfig, json.RawMessage(config)) return err } -func (c *Channel) SendNotification(contact *recipient.Contact, i contracts.Incident, ev *event.Event, icingaweb2Url string) error { - contactStruct := &plugin.Contact{FullName: contact.FullName} - for _, addr := range contact.Addresses { - contactStruct.Addresses = append(contactStruct.Addresses, &plugin.Address{Type: addr.Type, Address: addr.Address}) +func (p *Plugin) SendNotification(req *plugin.NotificationRequest) error { + params, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to prepare request params: %w", err) } - if !strings.HasSuffix(icingaweb2Url, "/") { - icingaweb2Url += "/" - } + _, err = p.rpc.Call(plugin.MethodSendNotification, params) - req := plugin.NotificationRequest{ - Contact: contactStruct, - Object: &plugin.Object{ - Name: i.ObjectDisplayName(), - Url: ev.URL, - Tags: ev.Tags, - ExtraTags: ev.ExtraTags, - }, - Incident: &plugin.Incident{ - Id: i.ID(), - Url: fmt.Sprintf("%snotifications/incident?id=%d", icingaweb2Url, i.ID()), - }, - Event: &plugin.Event{ - Time: ev.Time, - Type: ev.Type, - Severity: ev.Severity.String(), - Username: ev.Username, - Message: ev.Message, - }, + return err +} + +func terminate(p *Plugin) { + p.logger.Debug("Stopping the channel plugin") + p.mu.Lock() + defer p.mu.Unlock() + + _ = p.rpc.Close() + timer := time.AfterFunc(5*time.Second, func() { + p.logger.Debug("killing the channel plugin") + _ = p.cmd.Process.Kill() + }) + + <-p.rpc.Done() + timer.Stop() + p.logger.Debug("Stopped channel plugin successfully") +} + +func forwardLogs(errPipe io.Reader, logger *zap.SugaredLogger) { + reader := bufio.NewReader(errPipe) + for { + line, err := reader.ReadString('\n') + if err != nil { + if err != io.EOF { + logger.Errorw("Failed to read stderr line", zap.Error(err)) + } + + return + } + + logger.Info(line) } +} - params, err := json.Marshal(req) +func SyncPlugins(channelPluginDir string, logs *logging.Logging, db *icingadb.DB) { + logger := logs.GetChildLogger("channel") + files, err := os.ReadDir(channelPluginDir) if err != nil { - return fmt.Errorf("failed to prepare request params: %w", err) + logger.Error(err) } - _, err = c.rpcCall(plugin.MethodSendNotification, params) + var pluginInfos []*plugin.Info - return err -} + for _, file := range files { + pluginLogger := logger.With(zap.String("name", file.Name())) + p, err := NewPlugin(file.Name(), pluginLogger) + if err != nil { + pluginLogger.Error(err) + continue + } + + info, err := p.GetInfo() + if err != nil { + p.logger.Error(err) + p.Stop() + continue + } + p.Stop() -func (c *Channel) rpcCall(method string, params json.RawMessage) (json.RawMessage, error) { - result, err := c.rpc.Call(method, params) + pluginInfos = append(pluginInfos, info) + } - var rpcErr *rpc.Error - if errors.As(err, &rpcErr) { - c.Stop() + if len(pluginInfos) == 0 { + logger.Info("No working plugin found") + return } - return result, err + stmt, _ := db.BuildUpsertStmt(&plugin.Info{}) + _, err = db.NamedExec(stmt, pluginInfos) + if err != nil { + logger.Error("failed to upsert channel plugin: ", err) + } else { + logger.Infof("Successfully upsert %d plugins", len(pluginInfos)) + } } diff --git a/internal/config/channel.go b/internal/config/channel.go index cc04572c1..8bb30271b 100644 --- a/internal/config/channel.go +++ b/internal/config/channel.go @@ -5,6 +5,7 @@ import ( "github.com/icinga/icinga-notifications/internal/channel" "github.com/jmoiron/sqlx" "go.uber.org/zap" + "regexp" ) func (r *RuntimeConfig) fetchChannels(ctx context.Context, tx *sqlx.Tx) error { @@ -27,8 +28,9 @@ func (r *RuntimeConfig) fetchChannels(ctx context.Context, tx *sqlx.Tx) error { ) if channelsById[c.ID] != nil { channelLogger.Warnw("ignoring duplicate config for channel type") + } else if matched, _ := regexp.MatchString("^[a-zA-Z0-9]*$", c.Type); !matched { + channelLogger.Errorf("channel type must only contain a-zA-Z0-9, %q given", c.Type) } else { - c.Logger = r.logs.GetChildLogger("channel").With(zap.Int64("id", c.ID), zap.String("name", c.Name)) channelsById[c.ID] = c channelLogger.Debugw("loaded channel config") @@ -56,6 +58,9 @@ func (r *RuntimeConfig) applyPendingChannels() { for id, pendingChannel := range r.pending.Channels { if pendingChannel == nil { + r.Channels[id].Logger.Info("Channel has been removed, stopping channel plugin") + r.Channels[id].Stop() + delete(r.Channels, id) } else if currentChannel := r.Channels[id]; currentChannel != nil { // Currently, the whole config is reloaded from the database frequently, replacing everything. @@ -67,10 +72,14 @@ func (r *RuntimeConfig) applyPendingChannels() { currentChannel.Name = pendingChannel.Name currentChannel.Config = pendingChannel.Config - currentChannel.Logger.Info("Stopping the channel plugin because the config has been changed") - currentChannel.Stop() + currentChannel.Logger.Info("Reloading the channel plugin config") + currentChannel.ReloadConfig() } } else { + pendingChannel.Start(r.logs.GetChildLogger("channel").With( + zap.Int64("id", pendingChannel.ID), + zap.String("name", pendingChannel.Name))) + r.Channels[id] = pendingChannel } } diff --git a/internal/incident/incident.go b/internal/incident/incident.go index 7926e9272..1db83b8af 100644 --- a/internal/incident/incident.go +++ b/internal/incident/incident.go @@ -441,21 +441,14 @@ func (i *Incident) notifyContacts(ctx context.Context, tx *sqlx.Tx, ev *event.Ev ) } - err = ch.Start() + err = ch.Notify(contact, i, ev, daemonConfig.Config().Icingaweb2URL) if err != nil { - i.logger.Errorw("Could not initialize channel", zap.String("type", chType), zap.Error(err)) - continue - } - - err = ch.SendNotification(contact, i, ev, daemonConfig.Config().Icingaweb2URL) - if err != nil { - i.logger.Errorw("Failed to send via channel", zap.String("type", chType), zap.Error(err)) - continue + i.logger.Errorw("Failed to send notification via channel plugin", zap.String("type", ch.Type), zap.Error(err)) + } else { + i.logger.Infow( + "Successfully sent a notification via channel plugin", zap.String("type", ch.Type), zap.String("contact", contact.FullName), + ) } - - i.logger.Infow( - "Successfully sent a message via channel", zap.String("type", chType), zap.String("contact", contact.String()), - ) } } diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go index e5c1093eb..7783a6479 100644 --- a/pkg/plugin/plugin.go +++ b/pkg/plugin/plugin.go @@ -18,9 +18,32 @@ const ( MethodSendNotification = "SendNotification" ) +type FormElement struct { + Name string `db:"name" json:"name"` + Type string `db:"type" json:"type"` + Options map[string]string `db:"options" json:"options"` +} + type Info struct { - Name string `json:"display_name"` - ConfigAttributes json.RawMessage `json:"config_attrs"` + FileName string `db:"file_name" json:"file_name"` + Name string `db:"name" json:"name"` + Version string `db:"version" json:"version"` + AuthorName string `db:"author_name" json:"author_name"` + ConfigAttributes json.RawMessage `db:"config_attrs" json:"config_attrs"` +} + +// TableName implements the contracts.TableNamer interface. +func (c *Info) TableName() string { + return "plugin" +} + +// Upsert implements the contracts.Upserter interface. +func (c *Info) Upsert() interface{} { + return struct { + Version string `db:"version"` + AuthorName string `db:"author_name"` + ConfigAttributes json.RawMessage `db:"config_attrs"` + }{} } type Contact struct { diff --git a/pkg/rpc/rpc.go b/pkg/rpc/rpc.go index 2c1d806e6..348bf96ab 100644 --- a/pkg/rpc/rpc.go +++ b/pkg/rpc/rpc.go @@ -47,7 +47,7 @@ type RPC struct { errChannel chan struct{} // never transports a value, only closed through setErr() to signal an occurred error err *Error // only initialized via setErr(), if a rpc (Fatal/non-recoverable) error has occurred - errMu sync.Mutex + errOnce sync.Once requestedShutdown bool } @@ -118,6 +118,10 @@ func (r *RPC) Err() error { } } +func (r *RPC) Done() <-chan struct{} { + return r.errChannel +} + func (r *RPC) Close() error { r.encoderMu.Lock() defer r.encoderMu.Unlock() @@ -127,10 +131,7 @@ func (r *RPC) Close() error { } func (r *RPC) setErr(err error) { - r.errMu.Lock() - defer r.errMu.Unlock() - - if r.err == nil { + r.errOnce.Do(func() { pendingReqMsg := fmt.Sprintf("cancelling %d pending request(s)", len(r.pendingRequests)) if r.requestedShutdown { r.logger.Infof("Plugin shutdown triggered: %s", pendingReqMsg) @@ -140,7 +141,7 @@ func (r *RPC) setErr(err error) { r.err = &Error{cause: err} close(r.errChannel) - } + }) } // processResponses sends responses to its channel (identified by response.id) diff --git a/schema/pgsql/upgrades/plugin_table.sql b/schema/pgsql/upgrades/plugin_table.sql new file mode 100644 index 000000000..17662e567 --- /dev/null +++ b/schema/pgsql/upgrades/plugin_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE plugin ( + file_name text NOT NULL, + name text NOT NULL, + version text NOT NULL, + author_name text NOT NULL, + config_attrs text NOT NULL, + + CONSTRAINT pk_plugin PRIMARY KEY (file_name) +); + +ALTER TABLE channel + ADD CONSTRAINT fk_channel_plugin FOREIGN KEY (type) REFERENCES plugin(file_name); \ No newline at end of file