From 87cef1411e354e5d38792ab0bb34abb73d5ce357 Mon Sep 17 00:00:00 2001 From: RedDec Date: Tue, 28 Nov 2017 12:49:50 +0800 Subject: [PATCH] add http request. unify templates --- .goxc.json | 2 +- README.md | 13 ++++ docs/index.md | 37 +++++++++++ plugins/adp_email.go | 59 ++++++----------- plugins/adp_http.go | 136 ++++++++++++++++++++++++++++++++++++++++ plugins/adp_telegram.go | 79 +++++++++-------------- plugins/utils.go | 62 ++++++++++++++++++ sample/sample3.yaml | 15 +++++ 8 files changed, 313 insertions(+), 90 deletions(-) create mode 100644 plugins/adp_http.go create mode 100644 sample/sample3.yaml diff --git a/.goxc.json b/.goxc.json index 74878e7..c7a4114 100644 --- a/.goxc.json +++ b/.goxc.json @@ -3,7 +3,7 @@ "default", "publish-github" ], - "PackageVersion": "0.1.2", + "PackageVersion": "0.1.3", "TaskSettings": { "publish-github": { "owner": "reddec", diff --git a/README.md b/README.md index a6526e8..efa3a55 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ It’s tool for controlling processes like a supervisord but with some important * Developed for used inside Docker containers * Different strategies for processes * Support template-based email notification +* Support HTTP notification ## Installing @@ -89,3 +90,15 @@ email: Service {{.label}} {{.action}} ``` + +#### HTTP + +Add HTTP request as notification + +```yaml +http: + services: + - myservice + url: "http://example.com/{{.label}}/{{.action}}" + templateFile: "./body.txt" +``` \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index af3325d..2949ba1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,4 @@ + # MONEXEC [![GitHub release](https://img.shields.io/github/release/reddec/monexec.svg)](https://github.com/reddec/monexec/releases) @@ -109,6 +110,9 @@ telegram: _time: {{.time}}_ _host: {{.hostname}}_ ``` + +Since `0.1.4` you also can specify `templateFile` instead of `template` + # How to integrate with email Since `0.1.3` you can receive notifications over email. @@ -188,6 +192,39 @@ email: `template` will be used as fallback for `templateFile`. If template file location is not absolute, it will be calculated from configuration directory. +# How to integrate with HTTP + +Since `0.1.4` you can send notifications over HTTP + +* Supports any kind of methods (by default - `POST` but can be changed in `http.method`) +* **Body** - template-based text same as in `Telegram` or `Email` plugin +* **URL** - also template-based text (yes, with same rules as in `body` ;-) ) +* **Headers** - you can also provide any headers (no, no templates here) +* **Timeout** - limit time for request. By default - `20s` + +Configuration avaiable only from .yaml files: + +```yaml +http: + services: + - myservice + url: "http://example.com/{{.label}}/{{.action}}" + templateFile: "./body.txt" +``` + +`template` will be used as fallback for `templateFile`. If template file location is not absolute, it will be calculated from configuration directory. + + +|Parameter | Type | Required | Default | Description | +|--------------|----------|----------|---------|-------------| +|`url` |`string` | yes | | Target URL +|`method` |`string` | no | POST | HTTP method +|`services` |`list` | yes | | List of services that will trigger plugin +|`headers` |`map` | no | {} | Map (string -> string) of additional headers per request +|`timeout` |`duration`| no | 20s | Request timeout +|`template` |`string` | no | '' | Template string +|`templateFile`|`string` | no | '' | Path to file of template (more priority then `template`, but `template` will be used as fallback) + # Usage `monexec [command-flags...] [args,...]` diff --git a/plugins/adp_email.go b/plugins/adp_email.go index ac2ec9b..ea6090c 100644 --- a/plugins/adp_email.go +++ b/plugins/adp_email.go @@ -1,9 +1,7 @@ package plugins import ( - "bytes" "github.com/reddec/container" - "time" "log" "os" "net/smtp" @@ -13,13 +11,12 @@ import ( ) type Email struct { - Smtp string `yaml:"smtp"` - From string `yaml:"from"` - Password string `yaml:"password"` - To []string `yaml:"to"` - Template string `yaml:"template"` - TemplateFile string `yaml:"templateFile"` // template file (relative to config dir) has priority. Template supports basic utils - Services []string `yaml:"services"` + Smtp string `yaml:"smtp"` + From string `yaml:"from"` + Password string `yaml:"password"` + To []string `yaml:"to"` + Services []string `yaml:"services"` + withTemplate `mapstructure:",squash" yaml:",inline"` log *log.Logger hostname string @@ -27,24 +24,11 @@ type Email struct { workDir string } -func (c *Email) renderAndSend(params map[string]interface{}) { - message := &bytes.Buffer{} - - parser, err := parseFileOrTemplate(c.TemplateFile, c.Template, c.log) - if err != nil { - c.log.Println("failed parse template:", err) - return - } - renderErr := parser.Execute(message, params) - if renderErr != nil { - c.log.Println("failed render:", renderErr, "; params:", params) - return - } - - c.log.Println(message.String()) +func (c *Email) renderAndSend(message string) { + c.log.Println(message) host, _, _ := net.SplitHostPort(c.Smtp) auth := smtp.PlainAuth("", c.From, c.Password, host) - err = smtp.SendMail(c.Smtp, auth, c.From, c.To, message.Bytes()) + err := smtp.SendMail(c.Smtp, auth, c.From, c.To, []byte(message)) if err != nil { c.log.Println("failed send mail:", err) } else { @@ -54,14 +38,12 @@ func (c *Email) renderAndSend(params map[string]interface{}) { func (c *Email) Spawned(runnable container.Runnable, id container.ID) { if c.servicesSet[runnable.Label()] { - params := map[string]interface{}{ - "action": "spawned", - "id": id, - "label": runnable.Label(), - "hostname": c.hostname, - "time": time.Now().String(), + content, renderErr := c.renderDefault("spawned", string(id), runnable.Label(), nil, c.log) + if renderErr != nil { + c.log.Println("failed render:", renderErr) + } else { + c.renderAndSend(content) } - c.renderAndSend(params) } } @@ -74,15 +56,12 @@ func (c *Email) Prepare() error { func (c *Email) Stopped(runnable container.Runnable, id container.ID, err error) { if c.servicesSet[runnable.Label()] { - params := map[string]interface{}{ - "action": "stopped", - "id": id, - "error": err, - "label": runnable.Label(), - "hostname": c.hostname, - "time": time.Now().String(), + content, renderErr := c.renderDefault("stopped", string(id), runnable.Label(), err, c.log) + if renderErr != nil { + c.log.Println("failed render:", renderErr) + } else { + c.renderAndSend(content) } - c.renderAndSend(params) } } diff --git a/plugins/adp_http.go b/plugins/adp_http.go new file mode 100644 index 0000000..ae95aa5 --- /dev/null +++ b/plugins/adp_http.go @@ -0,0 +1,136 @@ +package plugins + +import ( + "log" + "github.com/reddec/container" + "os" + "github.com/pkg/errors" + "path/filepath" + "net/http" + "bytes" + "github.com/Masterminds/sprig" + "html/template" + "io" + "time" + "context" +) + +type Http struct { + URL string `yaml:"url" mapstructure:"url"` // template URL string + Method string `yaml:"method"` // default POST + Headers map[string]string `yaml:"headers" mapstructure:"headers"` // additional header (non-template) + Services []string `yaml:"services"` + Timeout time.Duration `yaml:"timeout"` + withTemplate `mapstructure:",squash" yaml:",inline"` + log *log.Logger `yaml:"-"` + servicesSet map[string]bool + workDir string +} + +func (c *Http) renderAndSend(message string, params map[string]interface{}) { + c.log.Println(message) + + tpl, err := template.New("").Funcs(sprig.FuncMap()).Parse(string(c.URL)) + if err != nil { + c.log.Println("failed parse URL as template:", err) + return + } + urlM := &bytes.Buffer{} + err = tpl.Execute(urlM, params) + if err != nil { + c.log.Println("failed execute URL as template:", err) + return + } + + req, err := http.NewRequest(c.Method, urlM.String(), bytes.NewBufferString(message)) + if err != nil { + c.log.Println("failed prepare request:", err) + return + } + + ctx, closer := context.WithTimeout(context.Background(), c.Timeout) + defer closer() + + res, err := http.DefaultClient.Do(req.WithContext(ctx)) + if err != nil { + c.log.Println("failed make request:", err) + return + } + io.Copy(os.Stdout, res.Body) // allow keep-alive + res.Body.Close() +} + +func (c *Http) Spawned(runnable container.Runnable, id container.ID) { + if c.servicesSet[runnable.Label()] { + content, params, renderErr := c.renderDefaultParams("spawned", string(id), runnable.Label(), nil, c.log) + if renderErr != nil { + c.log.Println("failed render:", renderErr) + } else { + c.renderAndSend(content, params) + } + } +} + +func (c *Http) Prepare() error { + c.servicesSet = makeSet(c.Services) + c.log = log.New(os.Stderr, "[http] ", log.LstdFlags) + if c.Method == "" { + c.Method = "POST" + } + if c.Timeout == 0 { + c.Timeout = 20 * time.Second + } + return nil +} + +func (c *Http) Stopped(runnable container.Runnable, id container.ID, err error) { + if c.servicesSet[runnable.Label()] { + content, params, renderErr := c.renderDefaultParams("stopped", string(id), runnable.Label(), err, c.log) + if renderErr != nil { + c.log.Println("failed render:", renderErr) + } else { + c.renderAndSend(content, params) + } + } +} + +func (a *Http) MergeFrom(other interface{}) (error) { + b := other.(*Http) + if a.URL == "" { + a.URL = b.URL + } + if a.URL != b.URL { + return errors.New("different urls") + } + if a.Method == "" { + a.Method = b.Method + } + if a.Method != b.Method { + return errors.New("different methods") + } + if a.Timeout == 0 { + a.Timeout = b.Timeout + } + if a.Timeout != b.Timeout { + return errors.New("different timeout") + } + a.withTemplate.resolvePath(a.workDir) + b.withTemplate.resolvePath(b.workDir) + if err := a.withTemplate.MergeFrom(&b.withTemplate); err != nil { + return err + } + if a.Headers == nil { + a.Headers = make(map[string]string) + } + for k, v := range b.Headers { + a.Headers[k] = v + } + a.Services = append(a.Services, b.Services...) + return nil +} + +func init() { + registerPlugin("http", func(file string) PluginConfig { + return &Http{workDir: filepath.Dir(file)} + }) +} diff --git a/plugins/adp_telegram.go b/plugins/adp_telegram.go index 8bd8db3..34b21a1 100644 --- a/plugins/adp_telegram.go +++ b/plugins/adp_telegram.go @@ -2,25 +2,23 @@ package plugins import ( "github.com/reddec/container" - "text/template" "log" "os" - "bytes" "gopkg.in/telegram-bot-api.v4" - "time" "errors" + "path/filepath" ) type Telegram struct { Token string `yaml:"token"` Recipients []int64 `yaml:"recipients"` Services []string `yaml:"services"` - Template string `yaml:"template"` + withTemplate `mapstructure:",squash" yaml:",inline"` - servicesSet map[string]bool `yaml:"-"` - templateBin *template.Template `yaml:"-"` - logger *log.Logger `yaml:"-"` - bot *tgbotapi.BotAPI `yaml:"-"` + servicesSet map[string]bool `yaml:"-"` + logger *log.Logger `yaml:"-"` + bot *tgbotapi.BotAPI `yaml:"-"` + workDir string hostname string } @@ -29,11 +27,6 @@ func (c *Telegram) Prepare() error { for _, srv := range c.Services { c.servicesSet[srv] = true } - t, err := template.New("").Parse(c.Template) - if err != nil { - return err - } - c.templateBin = t c.logger = log.New(os.Stderr, "[telegram] ", log.LstdFlags) bot, err := tgbotapi.NewBotAPI(c.Token) if err != nil { @@ -46,46 +39,35 @@ func (c *Telegram) Prepare() error { func (c *Telegram) Stopped(runnable container.Runnable, id container.ID, err error) { if c.servicesSet[runnable.Label()] { - params := map[string]interface{}{ - "action": "stopped", - "id": id, - "error": err, - "label": runnable.Label(), - "hostname": c.hostname, - "time": time.Now().String(), + content, renderErr := c.renderDefault("stopped", string(id), runnable.Label(), err, c.logger) + if renderErr != nil { + c.logger.Println("failed render:", renderErr) + } else { + c.renderAndSend(content) } - c.renderAndSend(params) } } -func (c *Telegram) renderAndSend(params map[string]interface{}) { - message := &bytes.Buffer{} - renderErr := c.templateBin.Execute(message, params) - if renderErr != nil { - c.logger.Println("failed render:", renderErr, "; params:", params) - } else { - msg := tgbotapi.NewMessage(0, message.String()) - msg.ParseMode = "markdown" - for _, r := range c.Recipients { - msg.ChatID = r - _, err := c.bot.Send(msg) - if err != nil { - c.logger.Println("failed send message to", r, "due to", err) - } +func (c *Telegram) renderAndSend(message string) { + msg := tgbotapi.NewMessage(0, message) + msg.ParseMode = "markdown" + for _, r := range c.Recipients { + msg.ChatID = r + _, err := c.bot.Send(msg) + if err != nil { + c.logger.Println("failed send message to", r, "due to", err) } } } func (c *Telegram) Spawned(runnable container.Runnable, id container.ID) { if c.servicesSet[runnable.Label()] { - params := map[string]interface{}{ - "action": "spawned", - "id": id, - "label": runnable.Label(), - "hostname": c.hostname, - "time": time.Now().String(), + content, renderErr := c.renderDefault("spawned", string(id), runnable.Label(), nil, c.logger) + if renderErr != nil { + c.logger.Println("failed render:", renderErr) + } else { + c.renderAndSend(content) } - c.renderAndSend(params) } } @@ -97,11 +79,10 @@ func (a *Telegram) MergeFrom(other interface{}) (error) { if a.Token != b.Token { return errors.New("token are different") } - if a.Template == "" { - a.Template = b.Template - } - if a.Template != b.Template { - return errors.New("different templates") + a.withTemplate.resolvePath(a.workDir) + b.withTemplate.resolvePath(b.workDir) + if err := a.withTemplate.MergeFrom(&b.withTemplate); err != nil { + return err } a.Recipients = append(a.Recipients, b.Recipients...) a.Services = append(a.Services, b.Services...) @@ -109,7 +90,7 @@ func (a *Telegram) MergeFrom(other interface{}) (error) { } func init() { - registerPlugin("telegram", func(string) PluginConfig { - return new(Telegram) + registerPlugin("telegram", func(file string) PluginConfig { + return &Telegram{workDir: filepath.Dir(file)} }) } diff --git a/plugins/utils.go b/plugins/utils.go index 02a5de5..802b8c5 100644 --- a/plugins/utils.go +++ b/plugins/utils.go @@ -7,8 +7,70 @@ import ( "io/ioutil" "bytes" "path/filepath" + "github.com/pkg/errors" + "time" + "os" ) +type withTemplate struct { + Template string `yaml:"template"` + TemplateFile string `yaml:"templateFile"` // template file (relative to config dir) has priority. Template supports basic utils +} + +func (wt *withTemplate) renderDefault(action, id, label string, err error, logger *log.Logger) (string, error) { + s, _, err := wt.renderDefaultParams(action, id, label, err, logger) + return s, err +} + +func (wt *withTemplate) renderDefaultParams(action, id, label string, err error, logger *log.Logger) (string, map[string]interface{}, error) { + hostname, _ := os.Hostname() + params := map[string]interface{}{ + "id": id, + "label": label, + "error": err, + "action": action, + "hostname": hostname, + "time": time.Now().String(), + } + s, err := wt.render(params, logger) + return s, params, err +} + +func (wt *withTemplate) render(params map[string]interface{}, logger *log.Logger) (string, error) { + parser, err := parseFileOrTemplate(wt.TemplateFile, wt.Template, logger) + if err != nil { + return "", errors.Wrap(err, "parse template") + } + message := &bytes.Buffer{} + + renderErr := parser.Execute(message, params) + if renderErr != nil { + logger.Println("failed render:", renderErr, "; params:", params) + return "", err + } + return message.String(), nil +} + +func (wt *withTemplate) resolvePath(workDir string) { + wt.TemplateFile = realPath(wt.TemplateFile, workDir) +} + +func (wt *withTemplate) MergeFrom(other *withTemplate) error { + if wt.TemplateFile == "" { + wt.TemplateFile = other.TemplateFile + } + if wt.TemplateFile != other.TemplateFile { + return errors.New("template files are different") + } + if wt.Template == "" { + wt.Template = other.Template + } + if wt.Template != other.Template { + return errors.New("different templates") + } + return nil +} + func unique(names []string) []string { var hash = make(map[string]struct{}) for _, name := range names { diff --git a/sample/sample3.yaml b/sample/sample3.yaml new file mode 100644 index 0000000..d411ad5 --- /dev/null +++ b/sample/sample3.yaml @@ -0,0 +1,15 @@ +services: +- label: listener3 + command: /bin/bash + args: + - -c + - nc -l 9001 + stop_timeout: 5s + restart_delay: 5s + restart: -1 + +http: + services: + - listener3 + url: "http://127.0.0.1:9000/{{.label}}/{{.action}}" + templateFile: "./email.html" \ No newline at end of file