Repository: containrrr/watchtower (go)

Issue:
Allow using stdout for session results to be used in scripts
<!--
  Before submitting your issue, please make sure you're using the containrrr/watchtower:latest image.
  If not, switch to this image prior to posting your report. Other forks, or the old `v2tec` image are **not** supported.
-->
**Quick intro**

Hi,
I realize that it might actually be the intended behavior and thus that it should not be considered as a bug. If that's the case, I'm sorry and this issue could be closed :smile:   
However, if that's the intended behavior, I think there could be a debate about it (that could lead to a feature request). I gave more information about that in the "additional context" section.  
Anyway, thanks for your awesome work !  

**Describe the bug**
<!-- A clear and concise description of what the bug is. -->
The regular info messages shown below are written to standard error (stderr) instead of standard output (stdout) when using watchtower with the "run once" mode, even tho the command exits with a 0 return code (command used : `sudo docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower --cleanup --run-once`) :
> time="2022-07-31T21:55:59Z" level=info msg="Watchtower 1.4.0"  
> time="2022-07-31T21:55:59Z" level=info msg="Using no notifications"  
> time="2022-07-31T21:55:59Z" level=info msg="Checking all containers (except explicitly disabled with label)"  
> time="2022-07-31T21:55:59Z" level=info msg="Running a one time update."  
> time="2022-07-31T21:56:02Z" level=info msg="Session done" Failed=0 Scanned=3 Updated=0 notify=no  
> time="2022-07-31T21:56:02Z" level=info msg="Waiting for the notification goroutine to finish" notify=no  

**To Reproduce**
<!--
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
-->
Run the command and echo the return code shows 0 (which is expected) :  
`sudo docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower --cleanup --run-once ; echo $?`  
> time="2022-07-31T21:59:47Z" level=info msg="Watchtower 1.4.0"  
> time="2022-07-31T21:59:47Z" level=info msg="Using no notifications"  
> time="2022-07-31T21:59:47Z" level=info msg="Checking all containers (except explicitly disabled with label)"  
> time="2022-07-31T21:59:47Z" level=info msg="Running a one time update."  
> time="2022-07-31T21:59:50Z" level=info msg="Session done" Failed=0 Scanned=3 Updated=0 notify=no  
> time="2022-07-31T21:59:50Z" level=info msg="Waiting for the notification goroutine to finish" notify=no  
> **0**  
  
However, redirecting standard output to "/dev/null" (or into a file) does nothing :  
`sudo docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower --cleanup --run-once > /dev/null`
> time="2022-07-31T22:02:51Z" level=info msg="Watchtower 1.4.0"  
> time="2022-07-31T22:02:51Z" level=info msg="Using no notifications"  
> time="2022-07-31T22:02:51Z" level=info msg="Checking all containers (except explicitly disabled with label)"  
> time="2022-07-31T22:02:51Z" level=info msg="Running a one time update."  
> time="2022-07-31T22:02:54Z" level=info msg="Session done" Failed=0 Scanned=3 Updated=0 notify=no  
> time="2022-07-31T22:02:54Z" level=info msg="Waiting for the notification goroutine to finish" notify=no  
  
While redirecting standard error to "/dev/null" (or into a file) does redirect the ouput :
`sudo docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower --cleanup --run-once 2> /dev/null` --> gives no output  
  
`sudo docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower --cleanup --run-once 2> test.txt ; cat test.txt`
> time="2022-07-31T22:07:40Z" level=info msg="Watchtower 1.4.0"  
> time="2022-07-31T22:07:40Z" level=info msg="Using no notifications"  
> time="2022-07-31T22:07:40Z" level=info msg="Checking all containers (except explicitly disabled with label)"  
> time="2022-07-31T22:07:40Z" level=info msg="Running a one time update."  
> time="2022-07-31T22:07:43Z" level=info msg="Session done" Failed=0 Scanned=3 Updated=0 notify=no  
> time="2022-07-31T22:07:43Z" level=info msg="Waiting for the notification goroutine to finish" notify=no    
  
This behavior also happens if a container gets updated (at first I was wondering if those messages were designed to go to standard error if no update were available).

**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->

I expected those info messages to be written in standard output and not standard error.
If those info messages are meant to be written in standard error, I may have a suggestion (see the "Additional context" section).

**Environment**
<!-- 
We want to know:
 - Platform
 - Architecture
 - Docker version 
-->
Tested on 2 different environment (same result for both)   
  
***Environment 1***
- Debian 11 installed directly on hardware
- X86_64
- Docker version 20.10.17, build 100c701

***Environment 2***
- Arch Linux VM installed on Proxmox
- X86_64
- Docker version 20.10.17, build 100c70180f

**Additional context**
<!--
  Add any other context about the problem here.
-->
As I said in the introduction, I initially thought it was a bug but then realized it could actually be the intended behavior (I'm sorry if that's the case).   
I understand that there's no real/standard convention about redirecting logging outputs to `stdout` or `stderr`, both are acceptable specially when a command doesn't have a "regular output" like it's the case here. 
However, these info messages contain a crucial information (at least to me) : The name of container that got updated.  
  
Here's my situation (to illustrate my point) :  
I'm not a fan of automatic updates processes. However, I like to simplify them as much as possible, (especially since I have a lot of servers that run docker containers). This is where the watchtower's `run once` mode becomes handy.  
So, to simplify my docker containers update process, I wrote an ansible playbook that launches watchtower with the `run once` mode on every of my servers, which I launch manually and regularly. 
To be able to see which containers got updated by watchtower (in order to know which ones need to be tested afterward to validate the update), I asked the playbook to redirect the standard output of the watchtower command to its own output. This is how I saw that the standard output of Watchtower does not contains info messages and thus the name of the container(s) that got updated.  
Of course, redirecting standard error of the Watchtower command instead of standard output in the ansible playbook (or redirecting `stderr` in `stdout` in the command) does work, but it feels a little weird to have to redirect **stderr** to get a standard information such as the name of containers.
  
According to that, here's my thought about this :
- If the info messages were not specifically meant to be written in the `stderr`, then I guess this bug report is legit.
- If the info messages were indeed meant to be written in the `stderr`, I would suggest a feature request that does output the name of the container(s) that got updated in the `stdout`. So people like me that use watchtower with the `run once` mode inside a script or an ansible playbook (for instance) doesn't have to redirect `stderr` to get this information (which is crucial for me). *One could argue that people using watchtower directly as a command on their machines (and not inside an ansible playbook like I do) would then get the name of updated container(s) twice as both `stdout` and `stderr` are shown by default when launching a command. But I still think that a dedicated line that gives the name of container(s) that got updated once treatments are finished would be useful and more easily readable then retrieving those names in the info logs messages in the form of `Stopping /container_name (container_id) with SIGTERM\`, `Creating /container_name\`, etc... Especially if you have servers that host a lot of docker containers.*

I know my case may be a bit particular but I think this would also benefits people that uses watchtower is a more "standard" way.
Anyway, as I also said earlier, **thanks a lot for your awesome work with Watchtower**, and sorry for this way too long report... :smile:

If this is indeed the intended behavior, don't hesitate to close this bug report and I'll open a feature request instead !

Pull request description:
feat: add porcelain output
This PR adds the `--porcelain` flag to help with using watchtower in scripts. When set, it uses a built-in template (`porcelain.v1.summary-no-log`) and writes notifications to `stdout`.
The output is meant to be stable and easily processed by common scripting tools like `awk` etc. 

Versioning of the templates is done by passing the version to `--porcelain` as an argument, with only `v1` being supported for now. That way we can add newer versions of templates without breaking scripts.

The output of the `v1` template looks like this:
```
container_name1 (container_image1:tag): Updated
container_name2 (container_image2:tag): Fresh Error: no credentials
```
(fresh here just means that it's the latest version _we know of_, but checking failed so it might not be)

or:
```
no containers matched filter
```

Fixes #1336 

Patch:
diff --git a/.all-contributorsrc b/.all-contributorsrc
index 1f2b253..c7f5407 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -822,6 +822,15 @@
       "contributions": [
         "code"
       ]
+    },
+    {
+      "login": "EDIflyer",
+      "name": "EDIflyer",
+      "avatar_url": "https://avatars.githubusercontent.com/u/13610277?v=4",
+      "profile": "https://github.com/EDIflyer",
+      "contributions": [
+        "doc"
+      ]
     }
   ],
   "contributorsPerLine": 7,
diff --git a/README.md b/README.md
index f9dee1c..e34353f 100644
--- a/README.md
+++ b/README.md
@@ -155,6 +155,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
     <td align="center"><a href="https://github.com/patricegautier"><img src="https://avatars.githubusercontent.com/u/38435239?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Patrice</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=patricegautier" title="Code">💻</a></td>
     <td align="center"><a href="http://jamesw.link/me"><img src="https://avatars.githubusercontent.com/u/8067792?v=4?s=100" width="100px;" alt=""/><br /><sub><b>James White</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=jamesmacwhite" title="Documentation">📖</a></td>
     <td align="center"><a href="https://ko-fi.com/foxite"><img src="https://avatars.githubusercontent.com/u/20421657?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Dirk Kok</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=Foxite" title="Code">💻</a></td>
+    <td align="center"><a href="https://github.com/EDIflyer"><img src="https://avatars.githubusercontent.com/u/13610277?v=4?s=100" width="100px;" alt=""/><br /><sub><b>EDIflyer</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=EDIflyer" title="Documentation">📖</a></td>
   </tr>
 </table>
 
diff --git a/cmd/root.go b/cmd/root.go
index a350410..daa9437 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -39,7 +39,6 @@ var (
 	lifecycleHooks bool
 	rollingRestart bool
 	scope          string
-	// Set on build using ldflags
 )
 
 var rootCmd = NewRootCommand()
@@ -75,6 +74,7 @@ func Execute() {
 // PreRun is a lifecycle hook that runs before the command is executed.
 func PreRun(cmd *cobra.Command, _ []string) {
 	f := cmd.PersistentFlags()
+	flags.ProcessFlagAliases(f)
 
 	if enabled, _ := f.GetBool("no-color"); enabled {
 		log.SetFormatter(&log.TextFormatter{
@@ -94,18 +94,7 @@ func PreRun(cmd *cobra.Command, _ []string) {
 		log.SetLevel(log.TraceLevel)
 	}
 
-	pollingSet := f.Changed("interval")
-	schedule, _ := f.GetString("schedule")
-	cronLen := len(schedule)
-
-	if pollingSet && cronLen > 0 {
-		log.Fatal("Only schedule or interval can be defined, not both.")
-	} else if cronLen > 0 {
-		scheduleSpec, _ = f.GetString("schedule")
-	} else {
-		interval, _ := f.GetInt("interval")
-		scheduleSpec = "@every " + strconv.Itoa(interval) + "s"
-	}
+	scheduleSpec, _ = f.GetString("schedule")
 
 	flags.GetSecretsFromFiles(cmd)
 	cleanup, noRestart, monitorOnly, timeout = flags.ReadFlags(cmd)
@@ -119,7 +108,9 @@ func PreRun(cmd *cobra.Command, _ []string) {
 	rollingRestart, _ = f.GetBool("rolling-restart")
 	scope, _ = f.GetString("scope")
 
-	log.Debug(scope)
+	if scope != "" {
+		log.Debugf(`Using scope %q`, scope)
+	}
 
 	// configure environment vars for client
 	err := flags.EnvConfig(cmd)
diff --git a/docs/notifications.md b/docs/notifications.md
index 7921e19..a69b00b 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -181,7 +181,8 @@ If you want to disable TLS verification for the Gotify instance, you can use eit
 
 To send notifications via shoutrrr, the following command-line options, or their corresponding environment variables, can be set:
 
--   `--notification-url` (env. `WATCHTOWER_NOTIFICATION_URL`): The shoutrrr service URL to be used.
+-   `--notification-url` (env. `WATCHTOWER_NOTIFICATION_URL`): The shoutrrr service URL to be used.  This option can also reference a file, in which case the contents of the file are used.
+
 
 Go to [containrrr.dev/shoutrrr/v0.6/services/overview](https://containrrr.dev/shoutrrr/v0.6/services/overview) to
 learn more about the different service URLs you can use. You can define multiple services by space separating the
diff --git a/docs/usage-overview.md b/docs/usage-overview.md
index 1462ba6..8c1e12f 100644
--- a/docs/usage-overview.md
+++ b/docs/usage-overview.md
@@ -27,12 +27,12 @@ docker run -d \
 
 Also check out [this Stack Overflow answer](https://stackoverflow.com/a/30494145/7872793) for more options on how to pass environment variables.
 
-Mounting the host's docker config file:
+Alternatively if you 2FA authentication setup on Docker Hub then passing username and password will be insufficient.  Instead you can run `docker login` to store your credentials in `$HOME/.docker/config.json` and then mount this config file to make it available to the Watchtower container:
 
 ```bash
 docker run -d \
   --name watchtower \
-  -v /home/<user>/.docker/config.json:/config.json \
+  -v $HOME/.docker/config.json:/config.json \
   -v /var/run/docker.sock:/var/run/docker.sock \
   containrrr/watchtower container_to_watch --debug
 ```
diff --git a/internal/flags/flags.go b/internal/flags/flags.go
index 2af8678..bae02a8 100644
--- a/internal/flags/flags.go
+++ b/internal/flags/flags.go
@@ -1,7 +1,9 @@
 package flags
 
 import (
+	"bufio"
 	"errors"
+	"fmt"
 	"io/ioutil"
 	"os"
 	"strings"
@@ -152,22 +154,32 @@ func RegisterSystemFlags(rootCmd *cobra.Command) {
 		"",
 		viper.GetString("WATCHTOWER_HTTP_API_TOKEN"),
 		"Sets an authentication token to HTTP API requests.")
+
 	flags.BoolP(
 		"http-api-periodic-polls",
 		"",
 		viper.GetBool("WATCHTOWER_HTTP_API_PERIODIC_POLLS"),
 		"Also run periodic updates (specified with --interval and --schedule) if HTTP API is enabled")
+
 	// https://no-color.org/
 	flags.BoolP(
 		"no-color",
 		"",
 		viper.IsSet("NO_COLOR"),
 		"Disable ANSI color escape codes in log output")
+
 	flags.StringP(
 		"scope",
 		"",
 		viper.GetString("WATCHTOWER_SCOPE"),
 		"Defines a monitoring scope for the Watchtower instance.")
+
+	flags.StringP(
+		"porcelain",
+		"P",
+		viper.GetString("WATCHTOWER_PORCELAIN"),
+		`Write session results to stdout using a stable versioned format. Supported values: "v1"`)
+		
 }
 
 // RegisterNotificationFlags that are used by watchtower to send notifications
@@ -342,6 +354,10 @@ Should only be used for testing.`)
 		viper.GetString("WATCHTOWER_WARN_ON_HEAD_FAILURE"),
 		"When to warn about HEAD pull requests failing. Possible values: always, auto or never")
 
+	flags.Bool(
+		"notification-log-stdout",
+		viper.GetBool("WATCHTOWER_NOTIFICATION_LOG_STDOUT"),
+		"Write notification logs to stdout instead of logging (to stderr)")
 }
 
 // SetDefaults provides default values for environment variables
@@ -444,6 +460,7 @@ func GetSecretsFromFiles(rootCmd *cobra.Command) {
 		"notification-slack-hook-url",
 		"notification-msteams-hook",
 		"notification-gotify-token",
+		"notification-url",
 	}
 	for _, secret := range secrets {
 		getSecretFromFile(flags, secret)
@@ -452,10 +469,33 @@ func GetSecretsFromFiles(rootCmd *cobra.Command) {
 
 // getSecretFromFile will check if the flag contains a reference to a file; if it does, replaces the value of the flag with the contents of the file.
 func getSecretFromFile(flags *pflag.FlagSet, secret string) {
-	value, err := flags.GetString(secret)
-	if err != nil {
-		log.Error(err)
+	flag := flags.Lookup(secret)
+	if sliceValue, ok := flag.Value.(pflag.SliceValue); ok {
+		oldValues := sliceValue.GetSlice()
+		values := make([]string, 0, len(oldValues))
+		for _, value := range oldValues {
+			if value != "" && isFile(value) {
+				file, err := os.Open(value)
+				if err != nil {
+					log.Fatal(err)
+				}
+				scanner := bufio.NewScanner(file)
+				for scanner.Scan() {
+					line := scanner.Text()
+					if line == "" {
+						continue
+					}
+					values = append(values, line)
+				}
+			} else {
+				values = append(values, value)
+			}
+		}
+		sliceValue.Replace(values)
+		return
 	}
+
+	value := flag.Value.String()
 	if value != "" && isFile(value) {
 		file, err := ioutil.ReadFile(value)
 		if err != nil {
@@ -479,3 +519,60 @@ func isFile(s string) bool {
 	_, err := os.Stat(s)
 	return !errors.Is(err, os.ErrNotExist)
 }
+
+// ProcessFlagAliases updates the value of flags that are being set by helper flags
+func ProcessFlagAliases(flags *pflag.FlagSet) {
+
+	porcelain, err := flags.GetString(`porcelain`)
+	if err != nil {
+		log.Fatalf(`Failed to get flag: %v`, err)
+	}
+	if porcelain != "" {
+	    if porcelain != "v1" {
+	        log.Fatalf(`Unknown porcelain version %q. Supported values: "v1"`, porcelain)
+	    }
+		if err = appendFlagValue(flags, `notification-url`, `logger://`); err != nil {
+			log.Errorf(`Failed to set flag: %v`, err)
+		}
+		setFlagIfDefault(flags, `notification-log-stdout`, `true`)
+		setFlagIfDefault(flags, `notification-report`, `true`)
+		tpl := fmt.Sprintf(`porcelain.%s.summary-no-log`, porcelain)
+		setFlagIfDefault(flags, `notification-template`, tpl)
+	}
+
+	if flags.Changed(`interval`) && flags.Changed(`schedule`) {
+		log.Fatal(`Only schedule or interval can be defined, not both.`)
+	}
+
+	// update schedule flag to match interval if it's set, or to the default if none of them are
+	if flags.Changed(`interval`) || !flags.Changed(`schedule`) {
+		interval, _ := flags.GetInt(`interval`)
+		flags.Set(`schedule`, fmt.Sprintf(`@every %ds`, interval))
+	}
+}
+
+func appendFlagValue(flags *pflag.FlagSet, name string, values ...string) error {
+	flag := flags.Lookup(name)
+	if flag == nil {
+		return fmt.Errorf(`invalid flag name %q`, name)
+	}
+
+	if flagValues, ok := flag.Value.(pflag.SliceValue); ok {
+		for _, value := range values {
+			flagValues.Append(value)
+		}
+	} else {
+		return fmt.Errorf(`the value for flag %q is not a slice value`, name)
+	}
+
+	return nil
+}
+
+func setFlagIfDefault(flags *pflag.FlagSet, name string, value string) {
+	if flags.Changed(name) {
+		return
+	}
+	if err := flags.Set(name, value); err != nil {
+		log.Errorf(`Failed to set flag: %v`, err)
+	}
+}
diff --git a/pkg/filters/filters.go b/pkg/filters/filters.go
index a283301..fa5ed2a 100644
--- a/pkg/filters/filters.go
+++ b/pkg/filters/filters.go
@@ -1,8 +1,10 @@
 package filters
 
 import (
-	t "github.com/containrrr/watchtower/pkg/types"
+	"regexp"
 	"strings"
+
+	t "github.com/containrrr/watchtower/pkg/types"
 )
 
 // WatchtowerContainersFilter filters only watchtower containers
@@ -19,9 +21,21 @@ func FilterByNames(names []string, baseFilter t.Filter) t.Filter {
 
 	return func(c t.FilterableContainer) bool {
 		for _, name := range names {
-			if (name == c.Name()) || (name == c.Name()[1:]) {
+			if name == c.Name() || name == c.Name()[1:] {
 				return baseFilter(c)
 			}
+
+			if re, err := regexp.Compile(name); err == nil {
+				indices := re.FindStringIndex(c.Name())
+				if indices == nil {
+					continue
+				}
+				start := indices[0]
+				end := indices[1]
+				if start <= 1 && end >= len(c.Name())-1 {
+					return baseFilter(c)
+				}
+			}
 		}
 		return false
 	}
@@ -95,7 +109,7 @@ func BuildFilter(names []string, enableLabel bool, scope string) (t.Filter, stri
 	filter = FilterByNames(names, filter)
 
 	if len(names) > 0 {
-		sb.WriteString("with name \"")
+		sb.WriteString("which name matches \"")
 		for i, n := range names {
 			sb.WriteString(n)
 			if i < len(names)-1 {
diff --git a/pkg/notifications/common_templates.go b/pkg/notifications/common_templates.go
new file mode 100644
index 0000000..64a53c0
--- /dev/null
+++ b/pkg/notifications/common_templates.go
@@ -0,0 +1,39 @@
+package notifications
+
+var commonTemplates = map[string]string{
+	`default-legacy`: "{{range .}}{{.Message}}{{println}}{{end}}",
+
+	`default`: `
+{{- if .Report -}}
+  {{- with .Report -}}
+    {{- if ( or .Updated .Failed ) -}}
+{{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed
+      {{- range .Updated}}
+- {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}}
+      {{- end -}}
+      {{- range .Fresh}}
+- {{.Name}} ({{.ImageName}}): {{.State}}
+	  {{- end -}}
+	  {{- range .Skipped}}
+- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
+	  {{- end -}}
+	  {{- range .Failed}}
+- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
+	  {{- end -}}
+    {{- end -}}
+  {{- end -}}
+{{- else -}}
+  {{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}}
+{{- end -}}`,
+
+	`porcelain.v1.summary-no-log`: `
+{{- if .Report -}}
+  {{- range .Report.All }}
+    {{- .Name}} ({{.ImageName}}): {{.State -}}
+    {{- with .Error}} Error: {{.}}{{end}}{{ println }}
+  {{- else -}}
+    no containers matched filter
+  {{- end -}}
+{{- end -}}`,
+}
+
diff --git a/pkg/notifications/email.go b/pkg/notifications/email.go
index e162209..3ebb4c0 100644
--- a/pkg/notifications/email.go
+++ b/pkg/notifications/email.go
@@ -15,7 +15,6 @@ const (
 )
 
 type emailTypeNotifier struct {
-	url                                string
 	From, To                           string
 	Server, User, Password, SubjectTag string
 	Port                               int
diff --git a/pkg/notifications/notifier.go b/pkg/notifications/notifier.go
index d4c8601..fba5dc0 100644
--- a/pkg/notifications/notifier.go
+++ b/pkg/notifications/notifier.go
@@ -21,20 +21,21 @@ func NewNotifier(c *cobra.Command) ty.Notifier {
 		log.Fatalf("Notifications invalid log level: %s", err.Error())
 	}
 
-	acceptedLogLevels := slackrus.LevelThreshold(logLevel)
+	levels := slackrus.LevelThreshold(logLevel)
 	// slackrus does not allow log level TRACE, even though it's an accepted log level for logrus
-	if len(acceptedLogLevels) == 0 {
+	if len(levels) == 0 {
 		log.Fatalf("Unsupported notification log level provided: %s", level)
 	}
 
 	reportTemplate, _ := f.GetBool("notification-report")
+	stdout, _ := f.GetBool("notification-log-stdout")
 	tplString, _ := f.GetString("notification-template")
 	urls, _ := f.GetStringArray("notification-url")
 
 	data := GetTemplateData(c)
 	urls, delay := AppendLegacyUrls(urls, c, data.Title)
 
-	return newShoutrrrNotifier(tplString, acceptedLogLevels, !reportTemplate, data, delay, urls...)
+	return newShoutrrrNotifier(tplString, levels, !reportTemplate, data, delay, stdout, urls...)
 }
 
 // AppendLegacyUrls creates shoutrrr equivalent URLs from legacy notification flags
diff --git a/pkg/notifications/shoutrrr.go b/pkg/notifications/shoutrrr.go
index 3940d22..e816cf7 100644
--- a/pkg/notifications/shoutrrr.go
+++ b/pkg/notifications/shoutrrr.go
@@ -3,6 +3,7 @@ package notifications
 import (
 	"bytes"
 	stdlog "log"
+	"os"
 	"strings"
 	"text/template"
 	"time"
@@ -11,35 +12,14 @@ import (
 	"github.com/containrrr/shoutrrr/pkg/types"
 	t "github.com/containrrr/watchtower/pkg/types"
 	log "github.com/sirupsen/logrus"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
 )
 
 // LocalLog is a logrus logger that does not send entries as notifications
 var LocalLog = log.WithField("notify", "no")
 
 const (
-	shoutrrrDefaultLegacyTemplate = "{{range .}}{{.Message}}{{println}}{{end}}"
-	shoutrrrDefaultTemplate       = `
-{{- if .Report -}}
-  {{- with .Report -}}
-    {{- if ( or .Updated .Failed ) -}}
-{{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed
-      {{- range .Updated}}
-- {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}}
-      {{- end -}}
-      {{- range .Fresh}}
-- {{.Name}} ({{.ImageName}}): {{.State}}
-	  {{- end -}}
-	  {{- range .Skipped}}
-- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
-	  {{- end -}}
-	  {{- range .Failed}}
-- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
-	  {{- end -}}
-    {{- end -}}
-  {{- end -}}
-{{- else -}}
-  {{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}}
-{{- end -}}`
 	shoutrrrType = "shoutrrr"
 )
 
@@ -79,9 +59,9 @@ func (n *shoutrrrTypeNotifier) GetNames() []string {
 	return names
 }
 
-func newShoutrrrNotifier(tplString string, acceptedLogLevels []log.Level, legacy bool, data StaticData, delay time.Duration, urls ...string) t.Notifier {
+func newShoutrrrNotifier(tplString string, levels []log.Level, legacy bool, data StaticData, delay time.Duration, stdout bool, urls ...string) t.Notifier {
 
-	notifier := createNotifier(urls, acceptedLogLevels, tplString, legacy, data)
+	notifier := createNotifier(urls, levels, tplString, legacy, data, stdout)
 	log.AddHook(notifier)
 
 	// Do the sending in a separate goroutine so we don't block the main process.
@@ -90,14 +70,19 @@ func newShoutrrrNotifier(tplString string, acceptedLogLevels []log.Level, legacy
 	return notifier
 }
 
-func createNotifier(urls []string, levels []log.Level, tplString string, legacy bool, data StaticData) *shoutrrrTypeNotifier {
+func createNotifier(urls []string, levels []log.Level, tplString string, legacy bool, data StaticData, stdout bool) *shoutrrrTypeNotifier {
 	tpl, err := getShoutrrrTemplate(tplString, legacy)
 	if err != nil {
 		log.Errorf("Could not use configured notification template: %s. Using default template", err)
 	}
 
-	traceWriter := log.StandardLogger().WriterLevel(log.TraceLevel)
-	r, err := shoutrrr.NewSender(stdlog.New(traceWriter, "Shoutrrr: ", 0), urls...)
+	var logger types.StdLogger
+	if stdout {
+		logger = stdlog.New(os.Stdout, ``, 0)
+	} else {
+		logger = stdlog.New(log.StandardLogger().WriterLevel(log.TraceLevel), "Shoutrrr: ", 0)
+	}
+	r, err := shoutrrr.NewSender(logger, urls...)
 	if err != nil {
 		log.Fatalf("Failed to initialize Shoutrrr notifications: %s\n", err.Error())
 	}
@@ -190,7 +175,7 @@ func (n *shoutrrrTypeNotifier) Close() {
 	// Use fmt so it doesn't trigger another notification.
 	LocalLog.Info("Waiting for the notification goroutine to finish")
 
-	_ = <-n.done
+	<-n.done
 }
 
 // Levels return what log levels trigger notifications
@@ -217,10 +202,15 @@ func getShoutrrrTemplate(tplString string, legacy bool) (tpl *template.Template,
 	funcs := template.FuncMap{
 		"ToUpper": strings.ToUpper,
 		"ToLower": strings.ToLower,
-		"Title":   strings.Title,
+		"Title":   cases.Title(language.AmericanEnglish).String,
 	}
 	tplBase := template.New("").Funcs(funcs)
 
+	if builtin, found := commonTemplates[tplString]; found {
+		log.WithField(`template`, tplString).Debug(`Using common template`)
+		tplString = builtin
+	}
+
 	// If we succeed in getting a non-empty template configuration
 	// try to parse the template string.
 	if tplString != "" {
@@ -228,16 +218,16 @@ func getShoutrrrTemplate(tplString string, legacy bool) (tpl *template.Template,
 	}
 
 	// If we had an error (either from parsing the template string
-	// or from getting the template configuration) or we a
+	// or from getting the template configuration) or a
 	// template wasn't configured (the empty template string)
 	// fallback to using the default template.
 	if err != nil || tplString == "" {
-		defaultTemplate := shoutrrrDefaultTemplate
+		defaultKey := `default`
 		if legacy {
-			defaultTemplate = shoutrrrDefaultLegacyTemplate
+			defaultKey = `default-legacy`
 		}
 
-		tpl = template.Must(tplBase.Parse(defaultTemplate))
+		tpl = template.Must(tplBase.Parse(commonTemplates[defaultKey]))
 	}
 
 	return
diff --git a/pkg/notifications/slack.go b/pkg/notifications/slack.go
index faff944..34d21a3 100644
--- a/pkg/notifications/slack.go
+++ b/pkg/notifications/slack.go
@@ -43,7 +43,7 @@ func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Convert
 
 func (s *slackTypeNotifier) GetURL(c *cobra.Command, title string) (string, error) {
 	trimmedURL := strings.TrimRight(s.HookURL, "/")
-	trimmedURL = strings.TrimLeft(trimmedURL, "https://")
+	trimmedURL = strings.TrimPrefix(trimmedURL, "https://")
 	parts := strings.Split(trimmedURL, "/")
 
 	if parts[0] == "discord.com" || parts[0] == "discordapp.com" {
@@ -56,6 +56,11 @@ func (s *slackTypeNotifier) GetURL(c *cobra.Command, title string) (string, erro
 			SplitLines: true,
 			Username:   s.Username,
 		}
+
+		if s.IconURL != "" {
+			conf.Avatar = s.IconURL
+		}
+
 		return conf.GetURL().String(), nil
 	}
 
diff --git a/pkg/session/report.go b/pkg/session/report.go
index 646a0c0..707eb91 100644
--- a/pkg/session/report.go
+++ b/pkg/session/report.go
@@ -1,8 +1,9 @@
 package session
 
 import (
-	"github.com/containrrr/watchtower/pkg/types"
 	"sort"
+
+	"github.com/containrrr/watchtower/pkg/types"
 )
 
 type report struct {
@@ -32,6 +33,33 @@ func (r *report) Stale() []types.ContainerReport {
 func (r *report) Fresh() []types.ContainerReport {
 	return r.fresh
 }
+func (r *report) All() []types.ContainerReport {
+	allLen := len(r.scanned) + len(r.updated) + len(r.failed) + len(r.skipped) + len(r.stale) + len(r.fresh)
+	all := make([]types.ContainerReport, 0, allLen)
+
+	presentIds := map[types.ContainerID][]string{}
+
+	appendUnique := func(reports []types.ContainerReport) {
+		for _, cr := range reports {
+			if _, found := presentIds[cr.ID()]; found {
+				continue
+			}
+			all = append(all, cr)
+			presentIds[cr.ID()] = nil
+		}
+	}
+
+	appendUnique(r.updated)
+	appendUnique(r.failed)
+	appendUnique(r.skipped)
+	appendUnique(r.stale)
+	appendUnique(r.fresh)
+	appendUnique(r.scanned)
+
+	sort.Sort(sortableContainers(all))
+
+	return all
+}
 
 // NewReport creates a types.Report from the supplied Progress
 func NewReport(progress Progress) types.Report {
diff --git a/pkg/types/report.go b/pkg/types/report.go
index 8013b58..f454fc6 100644
--- a/pkg/types/report.go
+++ b/pkg/types/report.go
@@ -8,6 +8,7 @@ type Report interface {
 	Skipped() []ContainerReport
 	Stale() []ContainerReport
 	Fresh() []ContainerReport
+	All() []ContainerReport
 }
 
 // ContainerReport represents a container that was included in watchtower session
diff --git a/scripts/docker-util.sh b/scripts/docker-util.sh
index 13a84ca..bd0dbda 100644
--- a/scripts/docker-util.sh
+++ b/scripts/docker-util.sh
@@ -122,4 +122,65 @@ function container-started() {
     return 1
   fi
   docker container inspect "$Name" | jq -r .[].State.StartedAt
+}
+
+
+function container-exists() {
+  local Name=$1
+  if [ -z "$Name" ];  then
+    echo "NAME missing"
+    return 1
+  fi
+  
+  docker container inspect "$Name" 1> /dev/null 2> /dev/null
+}
+
+function registry-exists() {
+  container-exists "$CONTAINER_PREFIX-registry"
+}
+
+function create-container() {
+  local container_name=$1
+    if [ -z "$container_name" ];  then
+    echo "NAME missing"
+    return 1
+  fi
+  local image_name="${2:-$container_name}"
+
+  echo -en "Creating \e[94m$container_name\e[0m container... "
+  local result
+  result=$(docker run -d --name "$container_name" "$(registry-host)/$image_name" 2>&1)
+  if [ "${#result}" -eq 64 ]; then
+    echo -e "\e[92m${result:0:12}\e[0m"
+    return 0
+  else
+    echo -e "\e[91mFailed!\n\e[97m$result\e[0m"
+    return 1
+  fi
+}
+
+function remove-images() {
+  local image_name=$1
+  if [ -z "$image_name" ];  then
+    echo "NAME missing"
+    return 1
+  fi
+
+  local images
+  mapfile -t images < <(docker images -q "$image_name" | uniq)
+  if [ -n "${images[*]}" ]; then
+    docker image rm "${images[@]}"
+  else
+    echo "No images matched \"$image_name\""
+  fi
+}
+
+function remove-repo-images() {
+  local image_name=$1
+  if [ -z "$image_name" ];  then
+    echo "NAME missing"
+    return 1
+  fi
+
+  remove-images "$(registry-host)/images/$image_name"
 }
\ No newline at end of file
diff --git a/scripts/du-cli.sh b/scripts/du-cli.sh
old mode 100644
new mode 100755
index 973dec5..611f720
--- a/scripts/du-cli.sh
+++ b/scripts/du-cli.sh
@@ -16,7 +16,7 @@ case $1 in
         registry-host
         ;;
       *)
-        echo "Unknown keyword \"$2\""
+        echo "Unknown registry action \"$2\""
         ;;
     esac
     ;;
@@ -28,8 +28,11 @@ case $1 in
       latest)
         latest-image-rev "$3"
         ;;
+      rm)
+        remove-repo-images "$3"
+        ;;
       *)
-        echo "Unknown keyword \"$2\""
+        echo "Unknown image action \"$2\""
         ;;
     esac
     ;;
@@ -47,8 +50,26 @@ case $1 in
       started)
         container-started "$3"
         ;;
+      create)
+        create-container "${@:3:2}"
+        ;;
+      create-stale)
+        if [ -z "$3" ]; then
+          echo "NAME missing"
+          exit 1
+        fi
+        if ! registry-exists; then
+          echo "Registry container missing! Creating..."
+          start-registry || exit 1
+        fi
+        image_name="images/$3"
+        container_name=$3
+        $0 image rev "$image_name" || exit 1
+        $0 container create "$container_name" "$image_name" || exit 1
+        $0 image rev "$image_name" || exit 1
+        ;;
       *)
-        echo "Unknown keyword \"$2\""
+        echo "Unknown container action \"$2\""
         ;;
     esac
     ;;


Test patch:
diff --git a/internal/flags/flags_test.go b/internal/flags/flags_test.go
index 1a904dd..570ebf5 100644
--- a/internal/flags/flags_test.go
+++ b/internal/flags/flags_test.go
@@ -5,6 +5,7 @@ import (
 	"os"
 	"testing"
 
+	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -50,6 +51,7 @@ func TestGetSecretsFromFilesWithString(t *testing.T) {
 
 	err := os.Setenv("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD", value)
 	require.NoError(t, err)
+	defer os.Unsetenv("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD")
 
 	testGetSecretsFromFiles(t, "notification-email-server-password", value)
 }
@@ -69,17 +71,40 @@ func TestGetSecretsFromFilesWithFile(t *testing.T) {
 
 	err = os.Setenv("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD", file.Name())
 	require.NoError(t, err)
+	defer os.Unsetenv("WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD")
 
 	testGetSecretsFromFiles(t, "notification-email-server-password", value)
 }
 
-func testGetSecretsFromFiles(t *testing.T, flagName string, expected string) {
+func TestGetSliceSecretsFromFiles(t *testing.T) {
+	values := []string{"entry2", "", "entry3"}
+
+	// Create the temporary file which will contain a secret.
+	file, err := ioutil.TempFile(os.TempDir(), "watchtower-")
+	require.NoError(t, err)
+	defer os.Remove(file.Name()) // Make sure to remove the temporary file later.
+
+	// Write the secret to the temporary file.
+	for _, value := range values {
+		_, err = file.WriteString("\n" + value)
+		require.NoError(t, err)
+	}
+	file.Close()
+
+	testGetSecretsFromFiles(t, "notification-url", `[entry1,entry2,entry3]`,
+		`--notification-url`, "entry1",
+		`--notification-url`, file.Name())
+}
+
+func testGetSecretsFromFiles(t *testing.T, flagName string, expected string, args ...string) {
 	cmd := new(cobra.Command)
 	SetDefaults()
 	RegisterNotificationFlags(cmd)
+	require.NoError(t, cmd.ParseFlags(args))
 	GetSecretsFromFiles(cmd)
-	value, err := cmd.PersistentFlags().GetString(flagName)
-	require.NoError(t, err)
+	flag := cmd.PersistentFlags().Lookup(flagName)
+	require.NotNil(t, flag)
+	value := flag.Value.String()
 
 	assert.Equal(t, expected, value)
 }
@@ -103,3 +128,71 @@ func TestIsFile(t *testing.T) {
 	assert.False(t, isFile("https://google.com"), "an URL should never be considered a file")
 	assert.True(t, isFile(os.Args[0]), "the currently running binary path should always be considered a file")
 }
+
+func TestReadFlags(t *testing.T) {
+	logrus.StandardLogger().ExitFunc = func(_ int) { t.FailNow() }
+
+}
+
+func TestProcessFlagAliases(t *testing.T) {
+	logrus.StandardLogger().ExitFunc = func(_ int) { t.FailNow() }
+	cmd := new(cobra.Command)
+	SetDefaults()
+	RegisterDockerFlags(cmd)
+	RegisterSystemFlags(cmd)
+	RegisterNotificationFlags(cmd)
+
+	require.NoError(t, cmd.ParseFlags([]string{
+	    `--porcelain`, `v1`, 
+	    `--interval`, `10`,
+	}))
+	flags := cmd.Flags()
+	ProcessFlagAliases(flags)
+
+	urls, _ := flags.GetStringArray(`notification-url`)
+	assert.Contains(t, urls, `logger://`)
+
+	logStdout, _ := flags.GetBool(`notification-log-stdout`)
+	assert.True(t, logStdout)
+
+	report, _ := flags.GetBool(`notification-report`)
+	assert.True(t, report)
+
+	template, _ := flags.GetString(`notification-template`)
+	assert.Equal(t, `porcelain.v1.summary-no-log`, template)
+
+	sched, _ := flags.GetString(`schedule`)
+	assert.Equal(t, `@every 10s`, sched)
+}
+
+func TestProcessFlagAliasesSchedAndInterval(t *testing.T) {
+	logrus.StandardLogger().ExitFunc = func(_ int) { panic(`FATAL`) }
+	cmd := new(cobra.Command)
+	SetDefaults()
+	RegisterDockerFlags(cmd)
+	RegisterSystemFlags(cmd)
+	RegisterNotificationFlags(cmd)
+
+	require.NoError(t, cmd.ParseFlags([]string{`--schedule`, `@now`, `--interval`, `10`}))
+	flags := cmd.Flags()
+
+	assert.PanicsWithValue(t, `FATAL`, func() {
+		ProcessFlagAliases(flags)
+	})
+}
+
+func TestProcessFlagAliasesInvalidPorcelaineVersion(t *testing.T) {
+	logrus.StandardLogger().ExitFunc = func(_ int) { panic(`FATAL`) }
+	cmd := new(cobra.Command)
+	SetDefaults()
+	RegisterDockerFlags(cmd)
+	RegisterSystemFlags(cmd)
+	RegisterNotificationFlags(cmd)
+
+	require.NoError(t, cmd.ParseFlags([]string{`--porcelain`, `cowboy`}))
+	flags := cmd.Flags()
+
+	assert.PanicsWithValue(t, `FATAL`, func() {
+		ProcessFlagAliases(flags)
+	})
+}
diff --git a/pkg/filters/filters_test.go b/pkg/filters/filters_test.go
index c07b181..951e7ca 100644
--- a/pkg/filters/filters_test.go
+++ b/pkg/filters/filters_test.go
@@ -47,6 +47,28 @@ func TestFilterByNames(t *testing.T) {
 	container.AssertExpectations(t)
 }
 
+func TestFilterByNamesRegex(t *testing.T) {
+	names := []string{`ba(b|ll)oon`}
+
+	filter := FilterByNames(names, NoFilter)
+	assert.NotNil(t, filter)
+
+	container := new(mocks.FilterableContainer)
+	container.On("Name").Return("balloon")
+	assert.True(t, filter(container))
+	container.AssertExpectations(t)
+
+	container = new(mocks.FilterableContainer)
+	container.On("Name").Return("spoon")
+	assert.False(t, filter(container))
+	container.AssertExpectations(t)
+
+	container = new(mocks.FilterableContainer)
+	container.On("Name").Return("baboonious")
+	assert.False(t, filter(container))
+	container.AssertExpectations(t)
+}
+
 func TestFilterByEnableLabel(t *testing.T) {
 	filter := FilterByEnableLabel(NoFilter)
 	assert.NotNil(t, filter)
@@ -68,8 +90,7 @@ func TestFilterByEnableLabel(t *testing.T) {
 }
 
 func TestFilterByScope(t *testing.T) {
-	var scope string
-	scope = "testscope"
+	scope := "testscope"
 
 	filter := FilterByScope(scope, NoFilter)
 	assert.NotNil(t, filter)
@@ -148,11 +169,12 @@ func TestFilterByImage(t *testing.T) {
 }
 
 func TestBuildFilter(t *testing.T) {
-	var names []string
-	names = append(names, "test")
+	names := []string{"test", "valid"}
 
 	filter, desc := BuildFilter(names, false, "")
 	assert.Contains(t, desc, "test")
+	assert.Contains(t, desc, "or")
+	assert.Contains(t, desc, "valid")
 
 	container := new(mocks.FilterableContainer)
 	container.On("Name").Return("Invalid")
diff --git a/pkg/notifications/notifier_test.go b/pkg/notifications/notifier_test.go
index 3ddb7f8..1b004dc 100644
--- a/pkg/notifications/notifier_test.go
+++ b/pkg/notifications/notifier_test.go
@@ -149,6 +149,8 @@ var _ = Describe("notifications", func() {
 			color := notifications.ColorInt
 			data := notifications.GetTemplateData(command)
 			title := url.QueryEscape(data.Title)
+			username := "containrrrbot"
+			iconURL := "https://containrrr.dev/watchtower-sq180.png"
 			expected := fmt.Sprintf("discord://%s@%s?color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&title=%s&username=watchtower", token, channel, color, title)
 			buildArgs := func(url string) []string {
 				return []string{
@@ -167,6 +169,27 @@ var _ = Describe("notifications", func() {
 				hookURL := fmt.Sprintf("https://%s/api/webhooks/%s/%s/slack", "discordapp.com", channel, token)
 				testURL(buildArgs(hookURL), expected, time.Duration(0))
 			})
+			When("icon URL and username are specified", func() {
+				It("should return the expected URL", func() {
+					hookURL := fmt.Sprintf("https://%s/api/webhooks/%s/%s/slack", "discord.com", channel, token)
+					expectedOutput := fmt.Sprintf("discord://%s@%s?avatar=%s&color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&title=%s&username=%s", token, channel, url.QueryEscape(iconURL), color, title, username)
+					expectedDelay := time.Duration(7) * time.Second
+					args := []string{
+						"--notifications",
+						"slack",
+						"--notification-slack-hook-url",
+						hookURL,
+						"--notification-slack-identifier",
+						username,
+						"--notification-slack-icon-url",
+						iconURL,
+						"--notifications-delay",
+						fmt.Sprint(expectedDelay.Seconds()),
+					}
+
+					testURL(args, expectedOutput, expectedDelay)
+				})
+			})
 		})
 		When("converting a slack service config into a shoutrrr url", func() {
 			command := cmd.NewRootCommand()
diff --git a/pkg/notifications/shoutrrr_test.go b/pkg/notifications/shoutrrr_test.go
index 46ab78d..0a10eb1 100644
--- a/pkg/notifications/shoutrrr_test.go
+++ b/pkg/notifications/shoutrrr_test.go
@@ -73,6 +73,16 @@ var _ = Describe("Shoutrrr", func() {
 		})
 	})
 
+	When("passing a common template name", func() {
+		It("should format using that template", func() {
+			expected := `
+updt1 (mock/updt1:latest): Updated
+`[1:]
+			data := mockDataFromStates(s.UpdatedState)
+			Expect(getTemplatedResult(`porcelain.v1.summary-no-log`, false, data)).To(Equal(expected))
+		})
+	})
+
 	When("using legacy templates", func() {
 
 		When("no custom template is provided", func() {
@@ -80,7 +90,7 @@ var _ = Describe("Shoutrrr", func() {
 				cmd := new(cobra.Command)
 				flags.RegisterNotificationFlags(cmd)
 
-				shoutrrr := createNotifier([]string{}, logrus.AllLevels, "", true, StaticData{})
+				shoutrrr := createNotifier([]string{}, logrus.AllLevels, "", true, StaticData{}, false)
 
 				entries := []*logrus.Entry{
 					{
@@ -168,7 +178,6 @@ var _ = Describe("Shoutrrr", func() {
 	})
 
 	When("using report templates", func() {
-
 		When("no custom template is provided", func() {
 			It("should format the messages using the default template", func() {
 				expected := `4 Scanned, 2 Updated, 1 Failed
@@ -236,7 +245,7 @@ Turns out everything is on fire
 	When("batching notifications", func() {
 		When("no messages are queued", func() {
 			It("should not send any notification", func() {
-				shoutrrr := newShoutrrrNotifier("", allButTrace, true, StaticData{}, time.Duration(0), "logger://")
+				shoutrrr := newShoutrrrNotifier("", allButTrace, true, StaticData{}, time.Duration(0), false, "logger://")
 				shoutrrr.StartNotification()
 				shoutrrr.SendNotification(nil)
 				Consistently(logBuffer).ShouldNot(gbytes.Say(`Shoutrrr:`))
@@ -244,7 +253,7 @@ Turns out everything is on fire
 		})
 		When("at least one message is queued", func() {
 			It("should send a notification", func() {
-				shoutrrr := newShoutrrrNotifier("", allButTrace, true, StaticData{}, time.Duration(0), "logger://")
+				shoutrrr := newShoutrrrNotifier("", allButTrace, true, StaticData{}, time.Duration(0), false, "logger://")
 				shoutrrr.StartNotification()
 				logrus.Info("This log message is sponsored by ContainrrrVPN")
 				shoutrrr.SendNotification(nil)
@@ -258,7 +267,7 @@ Turns out everything is on fire
 			shoutrrr := createNotifier([]string{"logger://"}, allButTrace, "", true, StaticData{
 				Host:  "test.host",
 				Title: "",
-			})
+			}, false)
 			_, found := shoutrrr.params.Title()
 			Expect(found).ToNot(BeTrue())
 		})
@@ -290,7 +299,7 @@ type blockingRouter struct {
 }
 
 func (b blockingRouter) Send(_ string, _ *types.Params) []error {
-	_ = <-b.unlock
+	<-b.unlock
 	b.sent <- true
 	return nil
 }