Repository: crazy-max/diun
Pull request #75 (2020-05-26 19:15:51)

Add script notification

codecov[bot]: # [Codecov](https://codecov.io/gh/crazy-max/diun/pull/75?src=pr&el=h1) Report
> Merging [#75](https://codecov.io/gh/crazy-max/diun/pull/75?src=pr&el=desc) into [v3](https://codecov.io/gh/crazy-max/diun/commit/a726851a29ea6c2a370df4d512f7890ba275ed20&el=desc) will **decrease** coverage by `0.91%`.
> The diff coverage is `20.00%`.

[![Impacted file tree graph](https://codecov.io/gh/crazy-max/diun/pull/75/graphs/tree.svg?width=650&height=150&src=pr&token=phc5KYIjsV)](https://codecov.io/gh/crazy-max/diun/pull/75?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##               v3      #75      +/-   ##
==========================================
- Coverage   53.38%   52.47%   -0.92%     
==========================================
  Files          10       10              
  Lines         354      364      +10     
==========================================
+ Hits          189      191       +2     
- Misses        108      112       +4     
- Partials       57       61       +4     
```


| [Impacted Files](https://codecov.io/gh/crazy-max/diun/pull/75?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [internal/config/notif.go](https://codecov.io/gh/crazy-max/diun/pull/75/diff?src=pr&el=tree#diff-aW50ZXJuYWwvY29uZmlnL25vdGlmLmdv) | `38.82% <20.00%> (-2.51%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/crazy-max/diun/pull/75?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/crazy-max/diun/pull/75?src=pr&el=footer). Last update [1f21414...9e768f9](https://codecov.io/gh/crazy-max/diun/pull/75?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 36f29baa..a0408db5 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -25,7 +25,7 @@ about: Create a report to help us improve
 * Platform (windows/linux) :
 * System info (type `uname -a`) : 
 
-```yml
+```yaml
 # paste your YAML configuration file here and remove sensitive data
 ```
 
diff --git a/doc/configuration.md b/doc/configuration.md
index fc0dafc9..52b7ea1d 100644
--- a/doc/configuration.md
+++ b/doc/configuration.md
@@ -10,7 +10,7 @@
 
 ## Overview
 
-```yml
+```yaml
 db:
   path: diun.db
 
@@ -20,19 +20,19 @@ watch:
   first_check_notif: false
 
 notif:
-  amqp:    
+  amqp:
     host: localhost
     port: 5672
     username: guest
     password: guest
     exchange: 
     queue: queue
-  gotify:    
+  gotify:
     endpoint: http://gotify.foo.com
     token: Token123456
     priority: 1
     timeout: 10
-  mail:  
+  mail:
     host: localhost
     port: 25
     ssl: false
@@ -41,15 +41,20 @@ notif:
     password:
     from:
     to:
-  rocketchat:    
+  rocketchat:
     endpoint: http://rocket.foo.com:3000
     channel: "#general"
     user_id: abcdEFGH012345678
     token: Token123456
     timeout: 10
-  slack:    
+  script:
+      cmd: "myprogram"
+      args:
+        - "--anarg"
+        - "another"
+  slack:
     webhook_url: https://hooks.slack.com/services/ABCD12EFG/HIJK34LMN/01234567890abcdefghij
-  teams:    
+  teams:
     webhook_url: https://outlook.office.com/webhook/ABCD12EFG/HIJK34LMN/01234567890abcdefghij
   telegram:
     token: aabbccdd:11223344
@@ -101,6 +106,7 @@ providers:
 * [gotify](notifications.md#gotify)
 * [mail](notifications.md#mail)
 * [rocketchat](notifications.md#rocketchat)
+* [script](notifications.md#script)
 * [slack](notifications.md#slack)
 * [teams](notifications.md#teams)
 * [telegram](notifications.md#telegram)
diff --git a/doc/faq.md b/doc/faq.md
index 789e50ec..1fa0d557 100644
--- a/doc/faq.md
+++ b/doc/faq.md
@@ -8,7 +8,7 @@ If you encounter this kind of error, you are probably using the [file provider](
 
 In the example below, Diun is running (`diun_x.x.x_windows_i386.zip`) on Windows 10 and tries to analyze the `crazymax/cloudflared` image with the detected platform (`windows/386)`:
 
-```yml
+```yaml
 - name: crazymax/cloudflared:2020.2.1
   watch_repo: true
 ```
@@ -22,7 +22,7 @@ Fri, 27 Mar 2020 01:20:03 UTC ERR Cannot list tags from registry error="Error ch
 
 You have to force the platform for this image if you are not on a supported platform. For example:
 
-```yml
+```yaml
 - name: crazymax/cloudflared:2020.2.1
   watch_repo: true
   platform:
diff --git a/doc/notifications.md b/doc/notifications.md
index fee58323..e7a82c32 100644
--- a/doc/notifications.md
+++ b/doc/notifications.md
@@ -4,6 +4,7 @@
 * [Gotify](#gotify)
 * [Mail](#mail)
 * [Rocket.Chat](#rocketchat)
+* [Script](#script)
 * [Slack](#slack)
 * [Teams](#teams)
 * [Telegram](#telegram)
@@ -83,6 +84,28 @@ To be able to send notifications to your Rocket.Chat channel:
 
 ![](../.res/notif-rocketchat.png)
 
+## Script
+
+You can send script notifications with the following settings:
+
+* `script`
+  * `cmd`: Command or script to execute. **required**
+  * `args`: List of args to pass to `cmd`.
+  * `dir`: Specifies the working directory of the command.
+
+Following environment variables are passed to the process and will look like this:
+
+```
+DIUN_VERSION=3.0.0
+DIUN_ENTRY_STATUS=new
+DIUN_ENTRY_PROVIDER=file
+DIUN_ENTRY_IMAGE=docker.io/crazymax/diun:latest
+DIUN_ENTRY_MIMETYPE=application/vnd.docker.distribution.manifest.list.v2+json
+DIUN_ENTRY_DIGEST=sha256:216e3ae7de4ca8b553eb11ef7abda00651e79e537e85c46108284e5e91673e01
+DIUN_ENTRY_CREATED=2020-03-26 12:23:56 +0000 UTC
+DIUN_ENTRY_PLATFORM=linux/adm64
+```
+
 ## Slack
 
 You can send notifications to your Slack channel using an [incoming webhook URL](https://api.slack.com/messaging/webhooks):
diff --git a/doc/providers/docker.md b/doc/providers/docker.md
index 7610f5ee..ac1ff1a6 100644
--- a/doc/providers/docker.md
+++ b/doc/providers/docker.md
@@ -15,7 +15,7 @@ In this section we quickly go over a basic docker-compose file using your local
 
 First of all, let's create a Diun configuration we named `diun.yml`:
 
-```yml
+```yaml
 watch:
   workers: 20
   schedule: "*/30 * * * *"
@@ -29,7 +29,7 @@ Here we use a single Docker provider with a minimum configuration to analyze lab
 
 Now let's create a simple docker-compose file with Diun and some simple services:
 
-```yml
+```yaml
 version: "3.5"
 
 services:
diff --git a/doc/providers/file.md b/doc/providers/file.md
index f453da4f..0fb5018c 100644
--- a/doc/providers/file.md
+++ b/doc/providers/file.md
@@ -16,7 +16,7 @@ The file provider lets you define Docker images to analyze through a YAML file o
 
 Register the file provider:
 
-```yml
+```yaml
 db:
   path: diun.db
 
@@ -39,7 +39,7 @@ providers:
     filename: /path/to/config.yml
 ```
 
-```yml
+```yaml
 ### /path/to/config.yml
 
 # Watch latest tag of crazymax/nextcloud image on docker.io (DockerHub) with registry ID 'someregistryoptions'.
@@ -82,7 +82,7 @@ providers:
 
 Let's take a look with a simple example:
 
-```yml
+```yaml
 db:
   path: diun.db
 
@@ -100,7 +100,7 @@ providers:
     filename: /path/to/config.yml
 ```
 
-```yml
+```yaml
 # /path/to/config.yml
 - name: crazymax/cloudflared
   watch_repo: true
@@ -137,7 +137,7 @@ Defines the path to the [configuration file](#yaml-configuration-file).
 
 > :warning: `filename` and `directory` are mutually exclusive.
 
-```yml
+```yaml
 providers:
   file:
     filename: /path/to/config/conf.yml
@@ -149,7 +149,7 @@ Defines the path to the directory that contains the [configuration files](#yaml-
 
 > :warning: `filename` and `directory` are mutually exclusive.
 
-```yml
+```yaml
 providers:
   file:
     directory: /path/to/config
diff --git a/doc/providers/swarm.md b/doc/providers/swarm.md
index ccc841f7..1eca526c 100644
--- a/doc/providers/swarm.md
+++ b/doc/providers/swarm.md
@@ -15,7 +15,7 @@ In this section we quickly go over a basic stack using your local swarm cluster.
 
 First of all, let's create a Diun configuration we named `diun.yml`:
 
-```yml
+```yaml
 watch:
   workers: 20
   schedule: "*/30 * * * *"
@@ -28,7 +28,7 @@ Here we use our local Swarm provider with a minimum configuration to analyze lab
 
 Now let's create a simple stack for Diun:
 
-```yml
+```yaml
 version: "3.5"
 
 services:
@@ -50,7 +50,7 @@ services:
 
 And another one with a simple service:
 
-```yml
+```yaml
 version: "3.5"
 
 services:
diff --git a/internal/config/dummy.yml b/internal/config/dummy.yml
deleted file mode 100644
index e69de29b..00000000
diff --git a/internal/config/notif.go b/internal/config/notif.go
index e52c115b..61c1f84b 100644
--- a/internal/config/notif.go
+++ b/internal/config/notif.go
@@ -2,6 +2,7 @@ package config
 
 import (
 	"net/mail"
+	"os/exec"
 
 	"github.com/crazy-max/diun/internal/model"
 	"github.com/crazy-max/diun/pkg/utl"
@@ -26,6 +27,9 @@ func (cfg *Config) validateNotif() error {
 	if err := cfg.validateNotifRocketChat(); err != nil {
 		return err
 	}
+	if err := cfg.validateNotifScript(); err != nil {
+		return err
+	}
 	if err := cfg.validateNotifSlack(); err != nil {
 		return err
 	}
@@ -106,6 +110,22 @@ func (cfg *Config) validateNotifRocketChat() error {
 	return nil
 }
 
+func (cfg *Config) validateNotifScript() error {
+	if cfg.Notif.Script == nil {
+		return nil
+	}
+
+	if cfg.Notif.Script.Cmd == "" {
+		return errors.New("command required for script provider")
+	}
+
+	if _, err := exec.LookPath(cfg.Notif.Script.Cmd); err != nil {
+		return errors.Wrap(err, "command not found for script provider")
+	}
+
+	return nil
+}
+
 func (cfg *Config) validateNotifSlack() error {
 	if cfg.Notif.Slack == nil {
 		return nil
diff --git a/internal/model/notif.go b/internal/model/notif.go
index b95b6072..5de20c39 100644
--- a/internal/model/notif.go
+++ b/internal/model/notif.go
@@ -18,6 +18,7 @@ type Notif struct {
 	Gotify     *NotifGotify     `yaml:"gotify,omitempty"`
 	Mail       *NotifMail       `yaml:"mail,omitempty"`
 	RocketChat *NotifRocketChat `yaml:"rocketchat,omitempty"`
+	Script     *NotifScript     `yaml:"script,omitempty"`
 	Slack      *NotifSlack      `yaml:"slack,omitempty"`
 	Teams      *NotifTeams      `yaml:"teams,omitempty"`
 	Telegram   *NotifTelegram   `yaml:"telegram,omitempty"`
@@ -67,6 +68,13 @@ type NotifRocketChat struct {
 	Timeout  int    `yaml:"timeout,omitempty"`
 }
 
+// NotifScript holds script notification configuration details
+type NotifScript struct {
+	Cmd  string   `yaml:"cmd,omitempty"`
+	Args []string `yaml:"args,omitempty"`
+	Dir  string   `yaml:"dir,omitempty"`
+}
+
 // NotifSlack holds slack notification configuration details
 type NotifSlack struct {
 	WebhookURL string `yaml:"webhook_url,omitempty"`
diff --git a/internal/notif/client.go b/internal/notif/client.go
index c0eb8f6a..98ed23e5 100644
--- a/internal/notif/client.go
+++ b/internal/notif/client.go
@@ -7,6 +7,7 @@ import (
 	"github.com/crazy-max/diun/internal/notif/mail"
 	"github.com/crazy-max/diun/internal/notif/notifier"
 	"github.com/crazy-max/diun/internal/notif/rocketchat"
+	"github.com/crazy-max/diun/internal/notif/script"
 	"github.com/crazy-max/diun/internal/notif/slack"
 	"github.com/crazy-max/diun/internal/notif/teams"
 	"github.com/crazy-max/diun/internal/notif/telegram"
@@ -47,6 +48,9 @@ func New(config *model.Notif, app model.App, userAgent string) (*Client, error)
 	if config.RocketChat != nil {
 		c.notifiers = append(c.notifiers, rocketchat.New(config.RocketChat, app, userAgent))
 	}
+	if config.Script != nil {
+		c.notifiers = append(c.notifiers, script.New(config.Script, app))
+	}
 	if config.Slack != nil {
 		c.notifiers = append(c.notifiers, slack.New(config.Slack, app))
 	}
diff --git a/internal/notif/script/client.go b/internal/notif/script/client.go
new file mode 100644
index 00000000..bb65ec16
--- /dev/null
+++ b/internal/notif/script/client.go
@@ -0,0 +1,73 @@
+package script
+
+import (
+	"bytes"
+	"fmt"
+	"os"
+	"os/exec"
+	"strings"
+
+	"github.com/crazy-max/diun/internal/model"
+	"github.com/crazy-max/diun/internal/notif/notifier"
+	"github.com/pkg/errors"
+	"github.com/rs/zerolog/log"
+)
+
+// Client represents an active script notification object
+type Client struct {
+	*notifier.Notifier
+	cfg       *model.NotifScript
+	app       model.App
+	userAgent string
+}
+
+// New creates a new script notification instance
+func New(config *model.NotifScript, app model.App) notifier.Notifier {
+	return notifier.Notifier{
+		Handler: &Client{
+			cfg: config,
+			app: app,
+		},
+	}
+}
+
+// Name returns notifier's name
+func (c *Client) Name() string {
+	return "script"
+}
+
+// Send creates and sends a script notification with an entry
+func (c *Client) Send(entry model.NotifEntry) error {
+	cmd := exec.Command(c.cfg.Cmd, c.cfg.Args...)
+	setSysProcAttr(cmd)
+
+	// Capture output
+	var stdout, stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+
+	// Set working dir
+	if c.cfg.Dir != "" {
+		cmd.Dir = c.cfg.Dir
+	}
+
+	// Set env vars
+	cmd.Env = append(os.Environ(), []string{
+		fmt.Sprintf("DIUN_VERSION=%s", c.app.Version),
+		fmt.Sprintf("DIUN_ENTRY_STATUS=%s", string(entry.Status)),
+		fmt.Sprintf("DIUN_ENTRY_PROVIDER=%s", entry.Provider),
+		fmt.Sprintf("DIUN_ENTRY_IMAGE=%s", entry.Image.String()),
+		fmt.Sprintf("DIUN_ENTRY_MIMETYPE=%s", entry.Manifest.MIMEType),
+		fmt.Sprintf("DIUN_ENTRY_DIGEST=%s", entry.Manifest.Digest),
+		fmt.Sprintf("DIUN_ENTRY_CREATED=%s", entry.Manifest.Created),
+		fmt.Sprintf("DIUN_ENTRY_PLATFORM=%s", entry.Manifest.Platform),
+	}...)
+
+	// Run
+	if err := cmd.Run(); err != nil {
+		return errors.Wrap(err, strings.TrimSpace(stderr.String()))
+	}
+
+	log.Debug().Msgf(strings.TrimSpace(stdout.String()))
+	return nil
+}
diff --git a/internal/notif/script/cmd.go b/internal/notif/script/cmd.go
new file mode 100644
index 00000000..825a3230
--- /dev/null
+++ b/internal/notif/script/cmd.go
@@ -0,0 +1,10 @@
+// +build !windows
+
+package script
+
+import (
+	"os/exec"
+)
+
+func setSysProcAttr(cmd *exec.Cmd) {
+}
diff --git a/internal/notif/script/cmd_windows.go b/internal/notif/script/cmd_windows.go
new file mode 100644
index 00000000..e273d99f
--- /dev/null
+++ b/internal/notif/script/cmd_windows.go
@@ -0,0 +1,10 @@
+package script
+
+import (
+	"os/exec"
+	"syscall"
+)
+
+func setSysProcAttr(cmd *exec.Cmd) {
+	cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
+}


Test patch:
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 66dd2c05..8554b1f3 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -24,18 +24,18 @@ func TestLoad(t *testing.T) {
 		{
 			name: "Fail on wrong file format",
 			cli: model.Cli{
-				Cfgfile: "config.invalid.yml",
+				Cfgfile: "./test/config.invalid.yml",
 			},
 			wantErr: true,
 		},
 		{
 			name: "Success",
 			cli: model.Cli{
-				Cfgfile: "config.test.yml",
+				Cfgfile: "./test/config.test.yml",
 			},
 			wantData: &config.Config{
 				Cli: model.Cli{
-					Cfgfile: "config.test.yml",
+					Cfgfile: "./test/config.test.yml",
 				},
 				App: model.App{
 					ID:      "diun",
@@ -82,6 +82,12 @@ func TestLoad(t *testing.T) {
 						Token:    "Token123456",
 						Timeout:  10,
 					},
+					Script: &model.NotifScript{
+						Cmd: "go",
+						Args: []string{
+							"version",
+						},
+					},
 					Slack: &model.NotifSlack{
 						WebhookURL: "https://hooks.slack.com/services/ABCD12EFG/HIJK34LMN/01234567890abcdefghij",
 					},
@@ -126,7 +132,7 @@ func TestLoad(t *testing.T) {
 						WatchByDefault: utl.NewTrue(),
 					},
 					File: &model.PrdFile{
-						Filename: "./dummy.yml",
+						Filename: "./test/dummy.yml",
 					},
 				},
 			},
diff --git a/internal/config/config.invalid.yml b/internal/config/test/config.invalid.yml
similarity index 100%
rename from internal/config/config.invalid.yml
rename to internal/config/test/config.invalid.yml
diff --git a/internal/config/config.test.yml b/internal/config/test/config.test.yml
similarity index 93%
rename from internal/config/config.test.yml
rename to internal/config/test/config.test.yml
index 3314d2c9..7b39320f 100644
--- a/internal/config/config.test.yml
+++ b/internal/config/test/config.test.yml
@@ -35,9 +35,13 @@ notif:
     user_id: abcdEFGH012345678
     token: Token123456
     timeout: 10
+  script:
+    cmd: "go"
+    args:
+      - "version"
   slack:
     webhook_url: https://hooks.slack.com/services/ABCD12EFG/HIJK34LMN/01234567890abcdefghij
-  teams:    
+  teams:
     webhook_url: https://outlook.office.com/webhook/ABCD12EFG/HIJK34LMN/01234567890abcdefghij
   telegram:
     token: abcdef123456
@@ -69,4 +73,4 @@ providers:
   swarm:
     watch_by_default: true
   file:
-    filename: ./dummy.yml
+    filename: ./test/dummy.yml
diff --git a/internal/config/test/dummy.yml b/internal/config/test/dummy.yml
new file mode 100644
index 00000000..740503eb
--- /dev/null
+++ b/internal/config/test/dummy.yml
@@ -0,0 +1,1 @@
+# noop
diff --git a/internal/config/test/myscript.sh b/internal/config/test/myscript.sh
new file mode 100644
index 00000000..740503eb
--- /dev/null
+++ b/internal/config/test/myscript.sh
@@ -0,0 +1,1 @@
+# noop