Repository: containrrr/watchtower
Pull request #1007 (2021-06-30 10:26:59)

feat(log): add context fields to lifecycle events
This just adds some context (container name, when available, or id) to some of the log messages that are emitted during lifecycle events.
Basically going from:
```
DEBU[0064] Executing pre-update command.
DEBU[0064] Awaiting timeout or completion                exec-id= exit-code=127 running=false
ERRO[0064] Command exited with code 127
INFO[0064] Skipping container as the pre-update command failed
DEBU[0065] No post-check command supplied. Skipping
DEBU[0065] No post-check command supplied. Skipping
DEBU[0065] No post-check command supplied. Skipping
DEBU[0065] No post-check command supplied. Skipping
```
to
```
DEBU[0064] Executing pre-update command.                 container=/testcontainer2
DEBU[0064] Awaiting timeout or completion                exec-id= exit-code=127 running=false
ERRO[0064] Command exited with code 127
INFO[0064] Skipping container as the pre-update command failed
DEBU[0065] No post-check command supplied. Skipping      container=/testcontainer4
DEBU[0065] No post-check command supplied. Skipping      container=/testcontainer3
DEBU[0065] No post-check command supplied. Skipping      container=/testcontainer2
DEBU[0065] No post-check command supplied. Skipping      container=/testcontainer1
```

codecov[bot]: # [Codecov](https://codecov.io/gh/containrrr/watchtower/pull/1007?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr) Report
> Merging [#1007](https://codecov.io/gh/containrrr/watchtower/pull/1007?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr) (8b64740) into [main](https://codecov.io/gh/containrrr/watchtower/commit/ea300b7a7f506c7682fb0092db438b59d2b4efc2?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr) (ea300b7) will **decrease** coverage by `0.08%`.
> The diff coverage is `0.00%`.

[![Impacted file tree graph](https://codecov.io/gh/containrrr/watchtower/pull/1007/graphs/tree.svg?width=650&height=150&src=pr&token=8pxWgB380Y&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr)](https://codecov.io/gh/containrrr/watchtower/pull/1007?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr)

```diff
@@            Coverage Diff             @@
##             main    #1007      +/-   ##
==========================================
- Coverage   59.63%   59.54%   -0.09%     
==========================================
  Files          22       22              
  Lines        1412     1414       +2     
==========================================
  Hits          842      842              
- Misses        500      502       +2     
  Partials       70       70              
```


| [Impacted Files](https://codecov.io/gh/containrrr/watchtower/pull/1007?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr) | Coverage Δ | |
|---|---|---|
| [pkg/container/client.go](https://codecov.io/gh/containrrr/watchtower/pull/1007/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr#diff-cGtnL2NvbnRhaW5lci9jbGllbnQuZ28=) | `14.16% <0.00%> (-0.12%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/containrrr/watchtower/pull/1007?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/containrrr/watchtower/pull/1007?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr). Last update [ea300b7...8b64740](https://codecov.io/gh/containrrr/watchtower/pull/1007?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr).


Patch:
diff --git a/.all-contributorsrc b/.all-contributorsrc
index 58246fd..afe73da 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -767,6 +767,15 @@
       "contributions": [
         "doc"
       ]
+    },
+    {
+      "login": "djquan",
+      "name": "Dan Quan",
+      "avatar_url": "https://avatars.githubusercontent.com/u/3526705?v=4",
+      "profile": "https://quan.io",
+      "contributions": [
+        "doc"
+      ]
     }
   ],
   "contributorsPerLine": 7,
diff --git a/README.md b/README.md
index 8a18a58..a51b266 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,6 @@
   [![Circle CI](https://circleci.com/gh/containrrr/watchtower.svg?style=shield)](https://circleci.com/gh/containrrr/watchtower)
   [![codecov](https://codecov.io/gh/containrrr/watchtower/branch/main/graph/badge.svg)](https://codecov.io/gh/containrrr/watchtower)
   [![GoDoc](https://godoc.org/github.com/containrrr/watchtower?status.svg)](https://godoc.org/github.com/containrrr/watchtower)
-  [![Microbadger](https://images.microbadger.com/badges/image/containrrr/watchtower.svg)](https://microbadger.com/images/containrrr/watchtower)
   [![Go Report Card](https://goreportcard.com/badge/github.com/containrrr/watchtower)](https://goreportcard.com/report/github.com/containrrr/watchtower)
   [![latest version](https://img.shields.io/github/tag/containrrr/watchtower.svg)](https://github.com/containrrr/watchtower/releases)
   [![Apache-2.0 License](https://img.shields.io/github/license/containrrr/watchtower.svg)](https://www.apache.org/licenses/LICENSE-2.0)
@@ -146,6 +145,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
     <td align="center"><a href="https://reinout.vanrees.org"><img src="https://avatars.githubusercontent.com/u/121433?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Reinout van Rees</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=reinout" title="Documentation">📖</a></td>
     <td align="center"><a href="https://github.com/DasSkelett"><img src="https://avatars.githubusercontent.com/u/28812678?v=4?s=100" width="100px;" alt=""/><br /><sub><b>DasSkelett</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=DasSkelett" title="Code">💻</a></td>
     <td align="center"><a href="https://github.com/zenjabba"><img src="https://avatars.githubusercontent.com/u/679864?v=4?s=100" width="100px;" alt=""/><br /><sub><b>zenjabba</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=zenjabba" title="Documentation">📖</a></td>
+    <td align="center"><a href="https://quan.io"><img src="https://avatars.githubusercontent.com/u/3526705?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Dan Quan</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=djquan" title="Documentation">📖</a></td>
   </tr>
 </table>
 
diff --git a/cmd/root.go b/cmd/root.go
index fb7c29b..47f7133 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -3,6 +3,7 @@ package cmd
 import (
 	"github.com/containrrr/watchtower/internal/meta"
 	"math"
+	"net/http"
 	"os"
 	"os/signal"
 	"strconv"
@@ -197,7 +198,7 @@ func Run(c *cobra.Command, names []string) {
 		httpAPI.RegisterHandler(metricsHandler.Path, metricsHandler.Handle)
 	}
 
-	if err := httpAPI.Start(enableUpdateAPI && !unblockHTTPAPI); err != nil {
+	if err := httpAPI.Start(enableUpdateAPI && !unblockHTTPAPI); err != nil && err != http.ErrServerClosed {
 		log.Error("failed to start API", err)
 	}
 
@@ -259,24 +260,43 @@ func formatDuration(d time.Duration) string {
 }
 
 func writeStartupMessage(c *cobra.Command, sched time.Time, filtering string) {
-	if noStartupMessage, _ := c.PersistentFlags().GetBool("no-startup-message"); !noStartupMessage {
-		schedMessage := "Running a one time update."
-		if !sched.IsZero() {
-			until := formatDuration(time.Until(sched))
-			schedMessage = "Scheduling first run: " + sched.Format("2006-01-02 15:04:05 -0700 MST") +
-				"\nNote that the first check will be performed in " + until
-		}
-
-		notifs := "Using no notifications"
-		notifierNames := notifier.GetNames()
-		if len(notifierNames) > 0 {
-			notifs = "Using notifications: " + strings.Join(notifierNames, ", ")
-		}
-
-		log.Info("Watchtower ", meta.Version, "\n", notifs, "\n", filtering, "\n", schedMessage)
-		if log.IsLevelEnabled(log.TraceLevel) {
-			log.Warn("trace level enabled: log will include sensitive information as credentials and tokens")
-		}
+	noStartupMessage, _ := c.PersistentFlags().GetBool("no-startup-message")
+
+	var startupLog *log.Entry
+	if noStartupMessage {
+		startupLog = notifications.LocalLog
+	} else {
+		startupLog = log.NewEntry(log.StandardLogger())
+		// Batch up startup messages to send them as a single notification
+		notifier.StartNotification()
+	}
+
+	startupLog.Info("Watchtower ", meta.Version)
+
+	notifierNames := notifier.GetNames()
+	if len(notifierNames) > 0 {
+		startupLog.Info("Using notifications: " + strings.Join(notifierNames, ", "))
+	} else {
+		startupLog.Info("Using no notifications")
+	}
+
+	startupLog.Info(filtering)
+
+	if !sched.IsZero() {
+		until := formatDuration(time.Until(sched))
+		startupLog.Info("Scheduling first run: " + sched.Format("2006-01-02 15:04:05 -0700 MST"))
+		startupLog.Info("Note that the first check will be performed in " + until)
+	} else {
+		startupLog.Info("Running a one time update.")
+	}
+
+	if !noStartupMessage {
+		// Send the queued up startup messages, not including the trace warning below (to make sure it's noticed)
+		notifier.SendNotification(nil)
+	}
+
+	if log.IsLevelEnabled(log.TraceLevel) {
+		startupLog.Warn("Trace level enabled: log will include sensitive information as credentials and tokens")
 	}
 }
 
diff --git a/docs/index.md b/docs/index.md
index d59b3b1..ef234ec 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -17,9 +17,6 @@
   <a href="https://godoc.org/github.com/containrrr/watchtower">
     <img alt="GoDoc" src="https://godoc.org/github.com/containrrr/watchtower?status.svg" />
   </a>
-  <a href="https://microbadger.com/images/containrrr/watchtower">
-    <img alt="Microbadger" src="https://images.microbadger.com/badges/image/containrrr/watchtower.svg" />
-  </a>
   <a href="https://goreportcard.com/report/github.com/containrrr/watchtower">
     <img alt="Go Report Card" src="https://goreportcard.com/badge/github.com/containrrr/watchtower" />
   </a>
@@ -58,8 +55,8 @@ the following command:
     ```yaml
     version: "3"
     services:
-    watchtower:
-    image: containrrr/watchtower 
-    volumes:
-    - /var/run/docker.sock:/var/run/docker.sock
+      watchtower:
+        image: containrrr/watchtower
+        volumes:
+          - /var/run/docker.sock:/var/run/docker.sock
     ```
diff --git a/docs/notifications.md b/docs/notifications.md
index c951d29..faa4e4a 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -180,7 +180,7 @@ To send notifications via shoutrrr, the following command-line options, or their
 
 -   `--notification-url` (env. `WATCHTOWER_NOTIFICATION_URL`): The shoutrrr service URL to be used.
 
-Go to [containrrr.github.io/shoutrrr/services/overview](https://containrrr.github.io/shoutrrr/services/overview) to
+Go to [containrrr.dev/shoutrrr/v0.5/services/overview](https://containrrr.dev/shoutrrr/v0.5/services/overview) to
 learn more about the different service URLs you can use. You can define multiple services by space separating the
 URLs. (See example below)
 
diff --git a/docs/private-registries.md b/docs/private-registries.md
index e6edc39..8b3f28d 100644
--- a/docs/private-registries.md
+++ b/docs/private-registries.md
@@ -102,8 +102,9 @@ in a volume that may be mounted onto your watchtower container.
 
 1.  Create the Dockerfile (contents below):
     ```Dockerfile
-    FROM golang:latest
+    FROM golang:1.16
     
+    ENV GO111MODULE off
     ENV CGO_ENABLED 0
     ENV REPO github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cli/docker-credential-ecr-login
     
diff --git a/go.mod b/go.mod
index 225915b..2515454 100644
--- a/go.mod
+++ b/go.mod
@@ -17,7 +17,7 @@ require (
 	github.com/cenkalti/backoff v2.2.1+incompatible // indirect
 	github.com/cloudflare/cfssl v0.0.0-20190911221928-1a911ca1b1d6 // indirect
 	github.com/containerd/continuity v0.0.0-20181203112020-004b46473808 // indirect
-	github.com/containrrr/shoutrrr v0.4.4
+	github.com/containrrr/shoutrrr v0.5.1
 	github.com/docker/cli v0.0.0-20190327152802-57b27434ea29
 	github.com/docker/distribution v2.7.1+incompatible
 	github.com/docker/docker v0.0.0-20190404075923-dbe4a30928d4
diff --git a/go.sum b/go.sum
index e1d7071..6eb935f 100644
--- a/go.sum
+++ b/go.sum
@@ -52,6 +52,10 @@ github.com/containerd/continuity v0.0.0-20181203112020-004b46473808 h1:4BX8f882b
 github.com/containerd/continuity v0.0.0-20181203112020-004b46473808/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
 github.com/containrrr/shoutrrr v0.4.4 h1:vHZ4E/76pKVY+Jyn/qhBz3X540Bn8NI5ppPHK4PyILY=
 github.com/containrrr/shoutrrr v0.4.4/go.mod h1:zqL2BvfC1W4FujrT4b3/ZCLxvD+uoeEpBL7rg9Dqpbg=
+github.com/containrrr/shoutrrr v0.5.0 h1:befKPRMqSvEsHYgxYJq4nuGmSWYvjbhVvb0nNk5OR5Q=
+github.com/containrrr/shoutrrr v0.5.0/go.mod h1:XSU8tOIZ1JG8m6OuPozfGLpj6Ed+S8ZrRJaEodQhbzw=
+github.com/containrrr/shoutrrr v0.5.1 h1:who87ACg0spQdbImaFMsOSh3g2FWyeN5nmO8tCg3llQ=
+github.com/containrrr/shoutrrr v0.5.1/go.mod h1:XSU8tOIZ1JG8m6OuPozfGLpj6Ed+S8ZrRJaEodQhbzw=
 github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
 github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
 github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
diff --git a/internal/actions/mocks/container.go b/internal/actions/mocks/container.go
index f854114..167d571 100644
--- a/internal/actions/mocks/container.go
+++ b/internal/actions/mocks/container.go
@@ -43,6 +43,11 @@ func CreateMockContainer(id string, name string, image string, created time.Time
 
 // CreateMockContainerWithImageInfo should only be used for testing
 func CreateMockContainerWithImageInfo(id string, name string, image string, created time.Time, imageInfo types.ImageInspect) container.Container {
+	return CreateMockContainerWithImageInfoP(id, name, image, created, &imageInfo)
+}
+
+// CreateMockContainerWithImageInfoP should only be used for testing
+func CreateMockContainerWithImageInfoP(id string, name string, image string, created time.Time, imageInfo *types.ImageInspect) container.Container {
 	content := types.ContainerJSON{
 		ContainerJSONBase: &types.ContainerJSONBase{
 			ID:      id,
@@ -57,7 +62,7 @@ func CreateMockContainerWithImageInfo(id string, name string, image string, crea
 	}
 	return *container.NewContainer(
 		&content,
-		&imageInfo,
+		imageInfo,
 	)
 }
 
diff --git a/pkg/container/client.go b/pkg/container/client.go
index 2771733..4e42cba 100644
--- a/pkg/container/client.go
+++ b/pkg/container/client.go
@@ -361,6 +361,7 @@ func (client dockerClient) RemoveImageByID(id t.ImageID) error {
 
 func (client dockerClient) ExecuteCommand(containerID t.ContainerID, command string, timeout int) (SkipUpdate bool, err error) {
 	bg := context.Background()
+	clog := log.WithField("containerID", containerID)
 
 	// Create the exec
 	execConfig := types.ExecConfig{
@@ -379,7 +380,7 @@ func (client dockerClient) ExecuteCommand(containerID t.ContainerID, command str
 		Detach: false,
 	})
 	if attachErr != nil {
-		log.Errorf("Failed to extract command exec logs: %v", attachErr)
+		clog.Errorf("Failed to extract command exec logs: %v", attachErr)
 	}
 
 	// Run the exec
@@ -395,7 +396,7 @@ func (client dockerClient) ExecuteCommand(containerID t.ContainerID, command str
 		var writer bytes.Buffer
 		written, err := writer.ReadFrom(response.Reader)
 		if err != nil {
-			log.Error(err)
+			clog.Error(err)
 		} else if written > 0 {
 			output = strings.TrimSpace(writer.String())
 		}
@@ -428,9 +429,10 @@ func (client dockerClient) waitForExecOrTimeout(bg context.Context, ID string, e
 
 		//goland:noinspection GoNilness
 		log.WithFields(log.Fields{
-			"exit-code": execInspect.ExitCode,
-			"exec-id":   execInspect.ExecID,
-			"running":   execInspect.Running,
+			"exit-code":    execInspect.ExitCode,
+			"exec-id":      execInspect.ExecID,
+			"running":      execInspect.Running,
+			"container-id": execInspect.ContainerID,
 		}).Debug("Awaiting timeout or completion")
 
 		if err != nil {
diff --git a/pkg/container/mocks/ApiServer.go b/pkg/container/mocks/ApiServer.go
index e192496..96c3921 100644
--- a/pkg/container/mocks/ApiServer.go
+++ b/pkg/container/mocks/ApiServer.go
@@ -3,6 +3,7 @@ package mocks
 import (
 	"encoding/json"
 	"fmt"
+	"github.com/onsi/ginkgo"
 	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
@@ -55,6 +56,22 @@ func NewMockAPIServer() *httptest.Server {
 				response = getMockJSONFromDisk("./mocks/data/image01.json")
 			} else if isRequestFor("sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa", r) {
 				response = getMockJSONFromDisk("./mocks/data/image02.json")
+			} else if isRequestFor("containers/ex-cont-id/exec", r) {
+				response = `{"Id": "ex-exec-id"}`
+			} else if isRequestFor("exec/ex-exec-id/start", r) {
+				response = `{"Id": "ex-exec-id"}`
+			} else if isRequestFor("exec/ex-exec-id/json", r) {
+				response = `{
+    				"ExecID": "ex-exec-id",
+					"ContainerID": "ex-cont-id",
+					"Running": false,
+					"ExitCode": 0,
+					"Pid": 0
+				}`
+			} else {
+				// Allow ginkgo to correctly capture the failed assertion, even though this is called from a go func
+				defer ginkgo.GinkgoRecover()
+				ginkgo.Fail(fmt.Sprintf("mock API server endpoint not supported: %q", r.URL.String()))
 			}
 			_, _ = fmt.Fprintln(w, response)
 		},
diff --git a/pkg/lifecycle/lifecycle.go b/pkg/lifecycle/lifecycle.go
index f99913b..d88bb6a 100644
--- a/pkg/lifecycle/lifecycle.go
+++ b/pkg/lifecycle/lifecycle.go
@@ -30,31 +30,33 @@ func ExecutePostChecks(client container.Client, params types.UpdateParams) {
 
 // ExecutePreCheckCommand tries to run the pre-check lifecycle hook for a single container.
 func ExecutePreCheckCommand(client container.Client, container container.Container) {
+	clog := log.WithField("container", container.Name())
 	command := container.GetLifecyclePreCheckCommand()
 	if len(command) == 0 {
-		log.Debug("No pre-check command supplied. Skipping")
+		clog.Debug("No pre-check command supplied. Skipping")
 		return
 	}
 
-	log.Debug("Executing pre-check command.")
+	clog.Debug("Executing pre-check command.")
 	_, err := client.ExecuteCommand(container.ID(), command, 1)
 	if err != nil {
-		log.Error(err)
+		clog.Error(err)
 	}
 }
 
 // ExecutePostCheckCommand tries to run the post-check lifecycle hook for a single container.
 func ExecutePostCheckCommand(client container.Client, container container.Container) {
+	clog := log.WithField("container", container.Name())
 	command := container.GetLifecyclePostCheckCommand()
 	if len(command) == 0 {
-		log.Debug("No post-check command supplied. Skipping")
+		clog.Debug("No post-check command supplied. Skipping")
 		return
 	}
 
-	log.Debug("Executing post-check command.")
+	clog.Debug("Executing post-check command.")
 	_, err := client.ExecuteCommand(container.ID(), command, 1)
 	if err != nil {
-		log.Error(err)
+		clog.Error(err)
 	}
 }
 
@@ -62,38 +64,42 @@ func ExecutePostCheckCommand(client container.Client, container container.Contai
 func ExecutePreUpdateCommand(client container.Client, container container.Container) (SkipUpdate bool, err error) {
 	timeout := container.PreUpdateTimeout()
 	command := container.GetLifecyclePreUpdateCommand()
+	clog := log.WithField("container", container.Name())
+
 	if len(command) == 0 {
-		log.Debug("No pre-update command supplied. Skipping")
+		clog.Debug("No pre-update command supplied. Skipping")
 		return false, nil
 	}
 
 	if !container.IsRunning() || container.IsRestarting() {
-		log.Debug("Container is not running. Skipping pre-update command.")
+		clog.Debug("Container is not running. Skipping pre-update command.")
 		return false, nil
 	}
 
-	log.Debug("Executing pre-update command.")
+	clog.Debug("Executing pre-update command.")
 	return client.ExecuteCommand(container.ID(), command, timeout)
 }
 
 // ExecutePostUpdateCommand tries to run the post-update lifecycle hook for a single container.
 func ExecutePostUpdateCommand(client container.Client, newContainerID types.ContainerID) {
 	newContainer, err := client.GetContainer(newContainerID)
+
 	if err != nil {
-		log.Error(err)
+		log.WithField("containerID", newContainerID.ShortID()).Error(err)
 		return
 	}
+	clog := log.WithField("container", newContainer.Name())
 
 	command := newContainer.GetLifecyclePostUpdateCommand()
 	if len(command) == 0 {
-		log.Debug("No post-update command supplied. Skipping")
+		clog.Debug("No post-update command supplied. Skipping")
 		return
 	}
 
-	log.Debug("Executing post-update command.")
+	clog.Debug("Executing post-update command.")
 	_, err = client.ExecuteCommand(newContainerID, command, 1)
 
 	if err != nil {
-		log.Error(err)
+		clog.Error(err)
 	}
 }
diff --git a/pkg/notifications/notifier.go b/pkg/notifications/notifier.go
index e1cb5e7..7c999d0 100644
--- a/pkg/notifications/notifier.go
+++ b/pkg/notifications/notifier.go
@@ -30,7 +30,8 @@ func NewNotifier(c *cobra.Command) ty.Notifier {
 
 	urls = AppendLegacyUrls(urls, c)
 
-	return newShoutrrrNotifier(tplString, acceptedLogLevels, !reportTemplate, urls...)
+	title := GetTitle(c)
+	return newShoutrrrNotifier(tplString, acceptedLogLevels, !reportTemplate, title, urls...)
 }
 
 // AppendLegacyUrls creates shoutrrr equivalent URLs from legacy notification flags
@@ -66,7 +67,7 @@ func AppendLegacyUrls(urls []string, cmd *cobra.Command) []string {
 
 		shoutrrrURL, err := legacyNotifier.GetURL(cmd)
 		if err != nil {
-			log.Fatal("failed to create notification config:", err)
+			log.Fatal("failed to create notification config: ", err)
 		}
 		urls = append(urls, shoutrrrURL)
 
diff --git a/pkg/notifications/shoutrrr.go b/pkg/notifications/shoutrrr.go
index 41ef126..ac293fb 100644
--- a/pkg/notifications/shoutrrr.go
+++ b/pkg/notifications/shoutrrr.go
@@ -2,7 +2,6 @@ package notifications
 
 import (
 	"bytes"
-	"fmt"
 	stdlog "log"
 	"strings"
 	"text/template"
@@ -13,23 +12,33 @@ import (
 	log "github.com/sirupsen/logrus"
 )
 
+// 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       = `{{- with .Report -}}
+	shoutrrrDefaultTemplate       = `
+{{- if .Report -}}
+  {{- with .Report -}}
+    {{- if ( or .Updated .Failed ) -}}
 {{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed
-{{range .Updated -}}
+      {{- range .Updated}}
 - {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}}
-{{end -}}
-{{range .Fresh -}}
+      {{- end -}}
+      {{- range .Fresh}}
 - {{.Name}} ({{.ImageName}}): {{.State}}
-{{end -}}
-{{range .Skipped -}}
+	  {{- end -}}
+	  {{- range .Skipped}}
 - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
-{{end -}}
-{{range .Failed -}}
+	  {{- end -}}
+	  {{- range .Failed}}
 - {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
-{{end -}}
-{{end -}}`
+	  {{- end -}}
+    {{- end -}}
+  {{- end -}}
+{{- else -}}
+  {{range .Entries -}}{{.Message}}{{"\n"}}{{- end -}}
+{{- end -}}`
 	shoutrrrType = "shoutrrr"
 )
 
@@ -47,6 +56,7 @@ type shoutrrrTypeNotifier struct {
 	messages       chan string
 	done           chan bool
 	legacyTemplate bool
+	params         *types.Params
 }
 
 // GetScheme returns the scheme part of a Shoutrrr URL
@@ -58,6 +68,7 @@ func GetScheme(url string) string {
 	return url[:schemeEnd]
 }
 
+// GetNames returns a list of notification services that has been added
 func (n *shoutrrrTypeNotifier) GetNames() []string {
 	names := make([]string, len(n.Urls))
 	for i, u := range n.Urls {
@@ -66,9 +77,10 @@ func (n *shoutrrrTypeNotifier) GetNames() []string {
 	return names
 }
 
-func newShoutrrrNotifier(tplString string, acceptedLogLevels []log.Level, legacy bool, urls ...string) t.Notifier {
+func newShoutrrrNotifier(tplString string, acceptedLogLevels []log.Level, legacy bool, title string, urls ...string) t.Notifier {
 
 	notifier := createNotifier(urls, acceptedLogLevels, tplString, legacy)
+	notifier.params = &types.Params{"title": title}
 	log.AddHook(notifier)
 
 	// Do the sending in a separate goroutine so we don't block the main process.
@@ -102,13 +114,16 @@ func createNotifier(urls []string, levels []log.Level, tplString string, legacy
 
 func sendNotifications(n *shoutrrrTypeNotifier) {
 	for msg := range n.messages {
-		errs := n.Router.Send(msg, nil)
+		errs := n.Router.Send(msg, n.params)
 
 		for i, err := range errs {
 			if err != nil {
 				scheme := GetScheme(n.Urls[i])
 				// Use fmt so it doesn't trigger another notification.
-				fmt.Printf("Failed to send shoutrrr notification (#%d, %s): %v\n", i, scheme, err)
+				LocalLog.WithFields(log.Fields{
+					"service": scheme,
+					"index":   i,
+				}).WithError(err).Error("Failed to send shoutrrr notification")
 			}
 		}
 	}
@@ -116,53 +131,70 @@ func sendNotifications(n *shoutrrrTypeNotifier) {
 	n.done <- true
 }
 
-func (n *shoutrrrTypeNotifier) buildMessage(data Data) string {
+func (n *shoutrrrTypeNotifier) buildMessage(data Data) (string, error) {
 	var body bytes.Buffer
 	var templateData interface{} = data
 	if n.legacyTemplate {
 		templateData = data.Entries
 	}
 	if err := n.template.Execute(&body, templateData); err != nil {
-		fmt.Printf("Failed to execute Shoutrrrr template: %s\n", err.Error())
+		return "", err
 	}
 
-	return body.String()
+	return body.String(), nil
 }
 
 func (n *shoutrrrTypeNotifier) sendEntries(entries []*log.Entry, report t.Report) {
-	msg := n.buildMessage(Data{entries, report})
+	msg, err := n.buildMessage(Data{entries, report})
+
+	if msg == "" {
+		// Log in go func in case we entered from Fire to avoid stalling
+		go func() {
+			if err != nil {
+				LocalLog.WithError(err).Fatal("Notification template error")
+			} else {
+				LocalLog.Info("Skipping notification due to empty message")
+			}
+		}()
+		return
+	}
 	n.messages <- msg
 }
 
+// StartNotification begins queueing up messages to send them as a batch
 func (n *shoutrrrTypeNotifier) StartNotification() {
 	if n.entries == nil {
 		n.entries = make([]*log.Entry, 0, 10)
 	}
 }
 
+// SendNotification sends the queued up messages as a notification
 func (n *shoutrrrTypeNotifier) SendNotification(report t.Report) {
-	//if n.entries == nil || len(n.entries) <= 0 {
-	//	return
-	//}
-
 	n.sendEntries(n.entries, report)
 	n.entries = nil
 }
 
+// Close prevents further messages from being queued and waits until all the currently queued up messages have been sent
 func (n *shoutrrrTypeNotifier) Close() {
 	close(n.messages)
 
 	// Use fmt so it doesn't trigger another notification.
-	fmt.Println("Waiting for the notification goroutine to finish")
+	LocalLog.Info("Waiting for the notification goroutine to finish")
 
 	_ = <-n.done
 }
 
+// Levels return what log levels trigger notifications
 func (n *shoutrrrTypeNotifier) Levels() []log.Level {
 	return n.logLevels
 }
 
+// Fire is the hook that logrus calls on a new log message
 func (n *shoutrrrTypeNotifier) Fire(entry *log.Entry) error {
+	if entry.Data["notify"] == "no" {
+		// Skip logging if explicitly tagged as non-notify
+		return nil
+	}
 	if n.entries != nil {
 		n.entries = append(n.entries, entry)
 	} else {
diff --git a/pkg/notifications/slack.go b/pkg/notifications/slack.go
index 7f6e0d4..c5c73b2 100644
--- a/pkg/notifications/slack.go
+++ b/pkg/notifications/slack.go
@@ -49,7 +49,7 @@ func (s *slackTypeNotifier) GetURL(c *cobra.Command) (string, error) {
 	if parts[0] == "discord.com" || parts[0] == "discordapp.com" {
 		log.Debug("Detected a discord slack wrapper URL, using shoutrrr discord service")
 		conf := &shoutrrrDisco.Config{
-			Channel:    parts[len(parts)-3],
+			WebhookID:  parts[len(parts)-3],
 			Token:      parts[len(parts)-2],
 			Color:      ColorInt,
 			Title:      GetTitle(c),
@@ -59,15 +59,24 @@ func (s *slackTypeNotifier) GetURL(c *cobra.Command) (string, error) {
 		return conf.GetURL().String(), nil
 	}
 
-	rawTokens := strings.Replace(s.HookURL, "https://hooks.slack.com/services/", "", 1)
-	tokens := strings.Split(rawTokens, "/")
+	webhookToken := strings.Replace(s.HookURL, "https://hooks.slack.com/services/", "", 1)
 
 	conf := &shoutrrrSlack.Config{
 		BotName: s.Username,
-		Token:   tokens,
 		Color:   ColorHex,
+		Channel: "webhook",
 		Title:   GetTitle(c),
 	}
 
+	if s.IconURL != "" {
+		conf.Icon = s.IconURL
+	} else if s.IconEmoji != "" {
+		conf.Icon = s.IconEmoji
+	}
+
+	if err := conf.Token.SetFromProp(webhookToken); err != nil {
+		return "", err
+	}
+
 	return conf.GetURL().String(), nil
 }
diff --git a/pkg/registry/digest/digest.go b/pkg/registry/digest/digest.go
index 858bf33..26fbd8e 100644
--- a/pkg/registry/digest/digest.go
+++ b/pkg/registry/digest/digest.go
@@ -22,6 +22,10 @@ const ContentDigestHeader = "Docker-Content-Digest"
 
 // CompareDigest ...
 func CompareDigest(container types.Container, registryAuth string) (bool, error) {
+	if !container.HasImageInfo() {
+		return false, errors.New("container image info missing")
+	}
+	
 	var digest string
 
 	registryAuth = TransformAuth(registryAuth)


Test patch:
diff --git a/pkg/container/client_test.go b/pkg/container/client_test.go
new file mode 100644
index 0000000..01e4c47
--- /dev/null
+++ b/pkg/container/client_test.go
@@ -0,0 +1,148 @@
+package container
+
+import (
+	"github.com/containrrr/watchtower/pkg/container/mocks"
+	"github.com/containrrr/watchtower/pkg/filters"
+	cli "github.com/docker/docker/client"
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+	"github.com/onsi/gomega/gbytes"
+	"github.com/sirupsen/logrus"
+)
+
+var _ = Describe("the client", func() {
+	var docker *cli.Client
+	var client Client
+	BeforeSuite(func() {
+		server := mocks.NewMockAPIServer()
+		docker, _ = cli.NewClientWithOpts(
+			cli.WithHost(server.URL),
+			cli.WithHTTPClient(server.Client()))
+		client = dockerClient{
+			api:        docker,
+			pullImages: false,
+		}
+	})
+	It("should return a client for the api", func() {
+		Expect(client).NotTo(BeNil())
+	})
+	Describe("WarnOnHeadPullFailed", func() {
+		containerUnknown := *mockContainerWithImageName("unknown.repo/prefix/imagename:latest")
+		containerKnown := *mockContainerWithImageName("docker.io/prefix/imagename:latest")
+
+		When("warn on head failure is set to \"always\"", func() {
+			c := newClientNoAPI(false, false, false, false, false, "always")
+			It("should always return true", func() {
+				Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeTrue())
+				Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeTrue())
+			})
+		})
+		When("warn on head failure is set to \"auto\"", func() {
+			c := newClientNoAPI(false, false, false, false, false, "auto")
+			It("should always return true", func() {
+				Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeFalse())
+			})
+			It("should", func() {
+				Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeTrue())
+			})
+		})
+		When("warn on head failure is set to \"never\"", func() {
+			c := newClientNoAPI(false, false, false, false, false, "never")
+			It("should never return true", func() {
+				Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeFalse())
+				Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeFalse())
+			})
+		})
+	})
+
+	When("listing containers without any filter", func() {
+		It("should return all available containers", func() {
+			containers, err := client.ListContainers(filters.NoFilter)
+			Expect(err).NotTo(HaveOccurred())
+			Expect(len(containers) == 2).To(BeTrue())
+		})
+	})
+	When("listing containers with a filter matching nothing", func() {
+		It("should return an empty array", func() {
+			filter := filters.FilterByNames([]string{"lollercoaster"}, filters.NoFilter)
+			containers, err := client.ListContainers(filter)
+			Expect(err).NotTo(HaveOccurred())
+			Expect(len(containers) == 0).To(BeTrue())
+		})
+	})
+	When("listing containers with a watchtower filter", func() {
+		It("should return only the watchtower container", func() {
+			containers, err := client.ListContainers(filters.WatchtowerContainersFilter)
+			Expect(err).NotTo(HaveOccurred())
+			Expect(len(containers) == 1).To(BeTrue())
+			Expect(containers[0].ImageName()).To(Equal("containrrr/watchtower:latest"))
+		})
+	})
+	When(`listing containers with the "include stopped" option`, func() {
+		It("should return both stopped and running containers", func() {
+			client = dockerClient{
+				api:            docker,
+				pullImages:     false,
+				includeStopped: true,
+			}
+			containers, err := client.ListContainers(filters.NoFilter)
+			Expect(err).NotTo(HaveOccurred())
+			Expect(len(containers) > 0).To(BeTrue())
+		})
+	})
+	When(`listing containers with the "include restart" option`, func() {
+		It("should return both stopped, restarting and running containers", func() {
+			client = dockerClient{
+				api:               docker,
+				pullImages:        false,
+				includeRestarting: true,
+			}
+			containers, err := client.ListContainers(filters.NoFilter)
+			Expect(err).NotTo(HaveOccurred())
+			RestartingContainerFound := false
+			for _, ContainerRunning := range containers {
+				if ContainerRunning.containerInfo.State.Restarting {
+					RestartingContainerFound = true
+				}
+			}
+			Expect(RestartingContainerFound).To(BeTrue())
+			Expect(RestartingContainerFound).NotTo(BeFalse())
+		})
+	})
+	When(`listing containers without restarting ones`, func() {
+		It("should not return restarting containers", func() {
+			client = dockerClient{
+				api:               docker,
+				pullImages:        false,
+				includeRestarting: false,
+			}
+			containers, err := client.ListContainers(filters.NoFilter)
+			Expect(err).NotTo(HaveOccurred())
+			RestartingContainerFound := false
+			for _, ContainerRunning := range containers {
+				if ContainerRunning.containerInfo.State.Restarting {
+					RestartingContainerFound = true
+				}
+			}
+			Expect(RestartingContainerFound).To(BeFalse())
+			Expect(RestartingContainerFound).NotTo(BeTrue())
+		})
+	})
+	Describe(`ExecuteCommand`, func() {
+		When(`logging`, func() {
+			It("should include container id field", func() {
+				// Capture logrus output in buffer
+				logbuf := gbytes.NewBuffer()
+				origOut := logrus.StandardLogger().Out
+				defer logrus.SetOutput(origOut)
+				logrus.SetOutput(logbuf)
+
+				_, err := client.ExecuteCommand("ex-cont-id", "exec-cmd", 1)
+				Expect(err).NotTo(HaveOccurred())
+				// Note: Since Execute requires opening up a raw TCP stream to the daemon for the output, this will fail
+				// when using the mock API server. Regardless of the outcome, the log should include the container ID
+				Eventually(logbuf).Should(gbytes.Say(`containerID="?ex-cont-id"?`))
+			})
+		})
+	})
+})
diff --git a/pkg/container/container_test.go b/pkg/container/container_test.go
index 843169b..5204f7d 100644
--- a/pkg/container/container_test.go
+++ b/pkg/container/container_test.go
@@ -1,136 +1,14 @@
 package container
 
 import (
-	"github.com/containrrr/watchtower/pkg/container/mocks"
-	"github.com/containrrr/watchtower/pkg/filters"
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/api/types/container"
-	cli "github.com/docker/docker/client"
 	"github.com/docker/go-connections/nat"
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/gomega"
 )
 
 var _ = Describe("the container", func() {
-	Describe("the client", func() {
-		var docker *cli.Client
-		var client Client
-		BeforeSuite(func() {
-			server := mocks.NewMockAPIServer()
-			docker, _ = cli.NewClientWithOpts(
-				cli.WithHost(server.URL),
-				cli.WithHTTPClient(server.Client()))
-			client = dockerClient{
-				api:        docker,
-				pullImages: false,
-			}
-		})
-		It("should return a client for the api", func() {
-			Expect(client).NotTo(BeNil())
-		})
-		Describe("WarnOnHeadPullFailed", func() {
-			containerUnknown := *mockContainerWithImageName("unknown.repo/prefix/imagename:latest")
-			containerKnown := *mockContainerWithImageName("docker.io/prefix/imagename:latest")
-
-			When("warn on head failure is set to \"always\"", func() {
-				c := newClientNoAPI(false, false, false, false, false, "always")
-				It("should always return true", func() {
-					Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeTrue())
-					Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeTrue())
-				})
-			})
-			When("warn on head failure is set to \"auto\"", func() {
-				c := newClientNoAPI(false, false, false, false, false, "auto")
-				It("should always return true", func() {
-					Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeFalse())
-				})
-				It("should", func() {
-					Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeTrue())
-				})
-			})
-			When("warn on head failure is set to \"never\"", func() {
-				c := newClientNoAPI(false, false, false, false, false, "never")
-				It("should never return true", func() {
-					Expect(c.WarnOnHeadPullFailed(containerUnknown)).To(BeFalse())
-					Expect(c.WarnOnHeadPullFailed(containerKnown)).To(BeFalse())
-				})
-			})
-		})
-
-		When("listing containers without any filter", func() {
-			It("should return all available containers", func() {
-				containers, err := client.ListContainers(filters.NoFilter)
-				Expect(err).NotTo(HaveOccurred())
-				Expect(len(containers) == 2).To(BeTrue())
-			})
-		})
-		When("listing containers with a filter matching nothing", func() {
-			It("should return an empty array", func() {
-				filter := filters.FilterByNames([]string{"lollercoaster"}, filters.NoFilter)
-				containers, err := client.ListContainers(filter)
-				Expect(err).NotTo(HaveOccurred())
-				Expect(len(containers) == 0).To(BeTrue())
-			})
-		})
-		When("listing containers with a watchtower filter", func() {
-			It("should return only the watchtower container", func() {
-				containers, err := client.ListContainers(filters.WatchtowerContainersFilter)
-				Expect(err).NotTo(HaveOccurred())
-				Expect(len(containers) == 1).To(BeTrue())
-				Expect(containers[0].ImageName()).To(Equal("containrrr/watchtower:latest"))
-			})
-		})
-		When(`listing containers with the "include stopped" option`, func() {
-			It("should return both stopped and running containers", func() {
-				client = dockerClient{
-					api:            docker,
-					pullImages:     false,
-					includeStopped: true,
-				}
-				containers, err := client.ListContainers(filters.NoFilter)
-				Expect(err).NotTo(HaveOccurred())
-				Expect(len(containers) > 0).To(BeTrue())
-			})
-		})
-		When(`listing containers with the "include restart" option`, func() {
-			It("should return both stopped, restarting and running containers", func() {
-				client = dockerClient{
-					api:               docker,
-					pullImages:        false,
-					includeRestarting: true,
-				}
-				containers, err := client.ListContainers(filters.NoFilter)
-				Expect(err).NotTo(HaveOccurred())
-				RestartingContainerFound := false
-				for _, ContainerRunning := range containers {
-					if ContainerRunning.containerInfo.State.Restarting {
-						RestartingContainerFound = true
-					}
-				}
-				Expect(RestartingContainerFound).To(BeTrue())
-				Expect(RestartingContainerFound).NotTo(BeFalse())
-			})
-		})
-		When(`listing containers without restarting ones`, func() {
-			It("should not return restarting containers", func() {
-				client = dockerClient{
-					api:               docker,
-					pullImages:        false,
-					includeRestarting: false,
-				}
-				containers, err := client.ListContainers(filters.NoFilter)
-				Expect(err).NotTo(HaveOccurred())
-				RestartingContainerFound := false
-				for _, ContainerRunning := range containers {
-					if ContainerRunning.containerInfo.State.Restarting {
-						RestartingContainerFound = true
-					}
-				}
-				Expect(RestartingContainerFound).To(BeFalse())
-				Expect(RestartingContainerFound).NotTo(BeTrue())
-			})
-		})
-	})
 	Describe("VerifyConfiguration", func() {
 		When("verifying a container with no image info", func() {
 			It("should return an error", func() {
diff --git a/pkg/notifications/notifications_suite_test.go b/pkg/notifications/notifications_suite_test.go
index 1b77c2a..19d286e 100644
--- a/pkg/notifications/notifications_suite_test.go
+++ b/pkg/notifications/notifications_suite_test.go
@@ -1,6 +1,7 @@
 package notifications_test
 
 import (
+	"github.com/onsi/gomega/format"
 	"testing"
 
 	. "github.com/onsi/ginkgo"
@@ -9,5 +10,6 @@ import (
 
 func TestNotifications(t *testing.T) {
 	RegisterFailHandler(Fail)
+	format.CharactersAroundMismatchToInclude = 20
 	RunSpecs(t, "Notifications Suite")
 }
diff --git a/pkg/notifications/notifier_test.go b/pkg/notifications/notifier_test.go
index 58c1ebb..71bfea3 100644
--- a/pkg/notifications/notifier_test.go
+++ b/pkg/notifications/notifier_test.go
@@ -40,7 +40,7 @@ var _ = Describe("notifications", func() {
 			token := "abvsihdbau"
 			color := notifications.ColorInt
 			title := url.QueryEscape(notifications.GetTitle(command))
-			expected := fmt.Sprintf("discord://%s@%s?color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&splitlines=Yes&title=%s&username=watchtower", token, channel, color, title)
+			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{
 					"--notifications",
@@ -60,31 +60,56 @@ var _ = Describe("notifications", func() {
 			})
 		})
 		When("converting a slack service config into a shoutrrr url", func() {
+			command := cmd.NewRootCommand()
+			flags.RegisterNotificationFlags(command)
+			username := "containrrrbot"
+			tokenA := "AAAAAAAAA"
+			tokenB := "BBBBBBBBB"
+			tokenC := "123456789123456789123456"
+			color := url.QueryEscape(notifications.ColorHex)
+			title := url.QueryEscape(notifications.GetTitle(command))
+			iconURL := "https://containrrr.dev/watchtower-sq180.png"
+			iconEmoji := "whale"
+
+			When("icon URL is specified", func() {
+				It("should return the expected URL", func() {
+
+					hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC)
+					expectedOutput := fmt.Sprintf("slack://hook:%s-%s-%s@webhook?botname=%s&color=%s&icon=%s&title=%s", tokenA, tokenB, tokenC, username, color, url.QueryEscape(iconURL), title)
+
+					args := []string{
+						"--notifications",
+						"slack",
+						"--notification-slack-hook-url",
+						hookURL,
+						"--notification-slack-identifier",
+						username,
+						"--notification-slack-icon-url",
+						iconURL,
+					}
+
+					testURL(args, expectedOutput)
+				})
+			})
 
-			It("should return the expected URL", func() {
-				command := cmd.NewRootCommand()
-				flags.RegisterNotificationFlags(command)
-
-				username := "containrrrbot"
-				tokenA := "aaa"
-				tokenB := "bbb"
-				tokenC := "ccc"
-				color := url.QueryEscape(notifications.ColorHex)
-				title := url.QueryEscape(notifications.GetTitle(command))
-
-				hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC)
-				expectedOutput := fmt.Sprintf("slack://%s@%s/%s/%s?color=%s&title=%s", username, tokenA, tokenB, tokenC, color, title)
-
-				args := []string{
-					"--notifications",
-					"slack",
-					"--notification-slack-hook-url",
-					hookURL,
-					"--notification-slack-identifier",
-					username,
-				}
-
-				testURL(args, expectedOutput)
+			When("icon emoji is specified", func() {
+				It("should return the expected URL", func() {
+					hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC)
+					expectedOutput := fmt.Sprintf("slack://hook:%s-%s-%s@webhook?botname=%s&color=%s&icon=%s&title=%s", tokenA, tokenB, tokenC, username, color, iconEmoji, title)
+
+					args := []string{
+						"--notifications",
+						"slack",
+						"--notification-slack-hook-url",
+						hookURL,
+						"--notification-slack-identifier",
+						username,
+						"--notification-slack-icon-emoji",
+						iconEmoji,
+					}
+
+					testURL(args, expectedOutput)
+				})
 			})
 		})
 	})
@@ -208,6 +233,7 @@ func buildExpectedURL(username string, password string, host string, port int, f
 }
 
 func testURL(args []string, expectedURL string) {
+	defer GinkgoRecover()
 
 	command := cmd.NewRootCommand()
 	flags.RegisterNotificationFlags(command)
diff --git a/pkg/notifications/shoutrrr_test.go b/pkg/notifications/shoutrrr_test.go
index e92655c..1b4ebab 100644
--- a/pkg/notifications/shoutrrr_test.go
+++ b/pkg/notifications/shoutrrr_test.go
@@ -8,11 +8,12 @@ import (
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/gomega"
 	"github.com/onsi/gomega/gbytes"
-
 	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 )
 
+var allButTrace = logrus.AllLevels[0:logrus.TraceLevel]
+
 var legacyMockData = Data{
 	Entries: []*logrus.Entry{
 		{
@@ -22,6 +23,28 @@ var legacyMockData = Data{
 	},
 }
 
+var mockDataMultipleEntries = Data{
+	Entries: []*logrus.Entry{
+		{
+			Level:   logrus.InfoLevel,
+			Message: "The situation is under control",
+		},
+		{
+			Level:   logrus.WarnLevel,
+			Message: "All the smoke might be covering up some problems",
+		},
+		{
+			Level:   logrus.ErrorLevel,
+			Message: "Turns out everything is on fire",
+		},
+	},
+}
+
+var mockDataAllFresh = Data{
+	Entries: []*logrus.Entry{},
+	Report:  mocks.CreateMockProgressReport(s.FreshState),
+}
+
 func mockDataFromStates(states ...s.State) Data {
 	return Data{
 		Entries: legacyMockData.Entries,
@@ -35,6 +58,7 @@ var _ = Describe("Shoutrrr", func() {
 	BeforeEach(func() {
 		logBuffer = gbytes.NewBuffer()
 		logrus.SetOutput(logBuffer)
+		logrus.SetLevel(logrus.TraceLevel)
 		logrus.SetFormatter(&logrus.TextFormatter{
 			DisableColors:    true,
 			DisableTimestamp: true,
@@ -56,7 +80,8 @@ var _ = Describe("Shoutrrr", func() {
 					},
 				}
 
-				s := shoutrrr.buildMessage(Data{Entries: entries})
+				s, err := shoutrrr.buildMessage(Data{Entries: entries})
+				Expect(err).NotTo(HaveOccurred())
 
 				Expect(s).To(Equal("foo bar\n"))
 			})
@@ -80,21 +105,34 @@ var _ = Describe("Shoutrrr", func() {
 					},
 				}
 
-				s := shoutrrr.buildMessage(Data{Entries: entries})
+				s, err := shoutrrr.buildMessage(Data{Entries: entries})
+				Expect(err).NotTo(HaveOccurred())
 
 				Expect(s).To(Equal("info: foo bar\n"))
 			})
 		})
 
+		Describe("the default template", func() {
+			When("all containers are fresh", func() {
+				It("should return an empty string", func() {
+					Expect(getTemplatedResult(``, true, mockDataAllFresh)).To(Equal(""))
+				})
+			})
+		})
+
 		When("given an invalid custom template", func() {
 			It("should format the messages using the default template", func() {
 				invNotif, err := createNotifierWithTemplate(`{{ intentionalSyntaxError`, true)
 				Expect(err).To(HaveOccurred())
+				invMsg, err := invNotif.buildMessage(legacyMockData)
+				Expect(err).NotTo(HaveOccurred())
 
 				defNotif, err := createNotifierWithTemplate(``, true)
 				Expect(err).ToNot(HaveOccurred())
+				defMsg, err := defNotif.buildMessage(legacyMockData)
+				Expect(err).ToNot(HaveOccurred())
 
-				Expect(invNotif.buildMessage(legacyMockData)).To(Equal(defNotif.buildMessage(legacyMockData)))
+				Expect(invMsg).To(Equal(defMsg))
 			})
 		})
 
@@ -130,18 +168,63 @@ var _ = Describe("Shoutrrr", func() {
 - updt2 (mock/updt2:latest): 01d120000000 updated to d0a120000000
 - frsh1 (mock/frsh1:latest): Fresh
 - skip1 (mock/skip1:latest): Skipped: unpossible
-- fail1 (mock/fail1:latest): Failed: accidentally the whole container
-`
+- fail1 (mock/fail1:latest): Failed: accidentally the whole container`
 				data := mockDataFromStates(s.UpdatedState, s.FreshState, s.FailedState, s.SkippedState, s.UpdatedState)
 				Expect(getTemplatedResult(``, false, data)).To(Equal(expected))
 			})
 
-			It("should format the messages using the default template", func() {
-				expected := `1 Scanned, 0 Updated, 0 Failed
-- frsh1 (mock/frsh1:latest): Fresh
+		})
+
+		Describe("the default template", func() {
+			When("all containers are fresh", func() {
+				It("should return an empty string", func() {
+					Expect(getTemplatedResult(``, false, mockDataAllFresh)).To(Equal(""))
+				})
+			})
+			When("at least one container was updated", func() {
+				It("should send a report", func() {
+					expected := `1 Scanned, 1 Updated, 0 Failed
+- updt1 (mock/updt1:latest): 01d110000000 updated to d0a110000000`
+					data := mockDataFromStates(s.UpdatedState)
+					Expect(getTemplatedResult(``, false, data)).To(Equal(expected))
+				})
+			})
+			When("at least one container failed to update", func() {
+				It("should send a report", func() {
+					expected := `1 Scanned, 0 Updated, 1 Failed
+- fail1 (mock/fail1:latest): Failed: accidentally the whole container`
+					data := mockDataFromStates(s.FailedState)
+					Expect(getTemplatedResult(``, false, data)).To(Equal(expected))
+				})
+			})
+			When("the report is nil", func() {
+				It("should return the logged entries", func() {
+					expected := `The situation is under control
+All the smoke might be covering up some problems
+Turns out everything is on fire
 `
-				data := mockDataFromStates(s.FreshState)
-				Expect(getTemplatedResult(``, false, data)).To(Equal(expected))
+					Expect(getTemplatedResult(``, false, mockDataMultipleEntries)).To(Equal(expected))
+				})
+			})
+		})
+	})
+
+	When("batching notifications", func() {
+		When("no messages are queued", func() {
+			It("should not send any notification", func() {
+				shoutrrr := newShoutrrrNotifier("", allButTrace, true, "", "logger://")
+				shoutrrr.StartNotification()
+				shoutrrr.SendNotification(nil)
+				Consistently(logBuffer).ShouldNot(gbytes.Say(`Shoutrrr:`))
+			})
+		})
+		When("at least one message is queued", func() {
+			It("should send a notification", func() {
+				shoutrrr := newShoutrrrNotifier("", allButTrace, true, "", "logger://")
+				shoutrrr.StartNotification()
+				logrus.Info("This log message is sponsored by ContainrrrVPN")
+				shoutrrr.SendNotification(nil)
+				Eventually(logBuffer).Should(gbytes.Say(`Shoutrrr: This log message is sponsored by ContainrrrVPN`))
 			})
 		})
 	})
@@ -218,10 +301,10 @@ func createNotifierWithTemplate(tplString string, legacy bool) (*shoutrrrTypeNot
 	}, err
 }
 
-func getTemplatedResult(tplString string, legacy bool, data Data) (string, error) {
+func getTemplatedResult(tplString string, legacy bool, data Data) (msg string) {
 	notifier, err := createNotifierWithTemplate(tplString, legacy)
-	if err != nil {
-		return "", err
-	}
-	return notifier.buildMessage(data), err
+	ExpectWithOffset(1, err).NotTo(HaveOccurred())
+	msg, err = notifier.buildMessage(data)
+	ExpectWithOffset(1, err).NotTo(HaveOccurred())
+	return msg
 }
diff --git a/pkg/registry/digest/digest_test.go b/pkg/registry/digest/digest_test.go
index 0321c1f..70193b8 100644
--- a/pkg/registry/digest/digest_test.go
+++ b/pkg/registry/digest/digest_test.go
@@ -59,6 +59,8 @@ var _ = Describe("Digests", func() {
 		mockCreated,
 		mockDigest)
 
+	mockContainerNoImage := mocks.CreateMockContainerWithImageInfoP(mockId, mockName, mockImage, mockCreated, nil)
+
 	When("a digest comparison is done", func() {
 		It("should return true if digests match",
 			SkipIfCredentialsEmpty(GHCRCredentials, func() {
@@ -75,6 +77,11 @@ var _ = Describe("Digests", func() {
 		It("should return an error if the registry isn't available", func() {
 
 		})
+		It("should return an error when container contains no image info", func() {
+			matches, err := digest.CompareDigest(mockContainerNoImage, `user:pass`)
+			Expect(err).To(HaveOccurred())
+			Expect(matches).To(Equal(false))
+		})
 	})
 	When("using different registries", func() {
 		It("should work with DockerHub",