Repository: containrrr/watchtower (go)

Issue:
Notification configuration (including credentials) written to stdout upon startup
**Describe the bug**
Since an update a few weeks ago it seems, that the output of the log is a little corrupted or weird.
There is also the password for the smtp account visible in there. Not good.

**To Reproduce**
Start watchtower (version 1.2.1) in docker, then the output is the following:

```
smtp://FROM_MAIL_USER:MAIL_PASSWORD@MAIL_SERVER:587/?auth=Plain&encryption=Auto&fromaddress=FROM_MAIL_ADDRESS&fromname=Watchtower&starttls=Yes&subject=%5BMailcow%5D+Watchtower+updates+on+a6d00da12cee&toaddresses=TO_MAIL_USER&usehtml=No
time="2021-04-16T19:32:15+02:00" level=info msg="Watchtower v0.0.0-unknown\nUsing notifications: smtp\nChecking all containers (except explicitly disabled with label)\nScheduling first run: 2021-04-17 05:00:00 +0200 CEST\nNote that the first check will be performed in 9 hours, 27 minutes, 44 seconds"
```

I have censored the output a little and removed the mail server, the mail addresses and the mail password.
The output version in the logs is very weird. I have pulled the 1.2.1 image, multiple times. Same output.

Here is my docker compose file - obviously also censored:

```
version: '3'

services:
  watchtower:
    image: containrrr/watchtower:1.2.1
    container_name: watchtower
    restart: unless-stopped
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - /var/run/docker.sock:/var/run/docker.sock
    command: --schedule "0 0 5 * * *" --cleanup
    environment:
      - WATCHTOWER_NOTIFICATIONS=email
      - WATCHTOWER_NOTIFICATION_EMAIL_FROM=FROM_MAIL_ADDRESS
      - WATCHTOWER_NOTIFICATION_EMAIL_TO=TO_MAIL_ADDRESS
      - WATCHTOWER_NOTIFICATION_EMAIL_SERVER=MAIL_SERVER
      - WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT=587
      - WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER=FROM_MAIL_USER
      - WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD=MAIL_PASSWORD
      - WATCHTOWER_NOTIFICATION_EMAIL_DELAY=30
      - WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG=[Mailcow]
    networks:
      - proxy

networks:
  proxy:
    external: true
```

**Expected behavior**
The log output before was simply the time at which watchtower is running next - no further output.
Also the line breaks worked as expected.

**Environment**
 - Platform: Ubuntu 20.04.02 LTS
 - Architecture: x86_64
 - Docker version: 20.10.6, build 370c289

Pull request:
fix: move notify URL to trace log
When converting legacy notifier configuration to shoutrrr URLs the generated URL is written to stdout (which was probably a development artifact). 
This moves that output to `trace` logging, as it can be useful when explicitly enabled (through `--trace`).

Fixes #906 

Patch:
diff --git a/.github/workflows/release-dev.yaml b/.github/workflows/release-dev.yaml
index 5005b2e..7928e45 100644
--- a/.github/workflows/release-dev.yaml
+++ b/.github/workflows/release-dev.yaml
@@ -1,23 +1,47 @@
-name: Release (Develop)
+name: Push to main
 
 on:
+  workflow_dispatch: {}
   push:
     branches:
       - main
 
 jobs:
-
   build:
-
     runs-on: ubuntu-latest
-
     steps:
-    - uses: actions/checkout@v2
-
-    - uses: jerray/publish-docker-action@master
-      with:
-        username: ${{ secrets.DOCKERHUB_USERNAME }}
-        password: ${{ secrets.DOCKERHUB_PASSWORD }}
-        file: dockerfiles/Dockerfile.self-contained
-        repository: containrrr/watchtower
-        tags: latest-dev
+      - uses: actions/checkout@v2
+      - name: Set up Go
+        uses: actions/setup-go@v2
+        with:
+          go-version: 1.15
+      - name: Build
+        run: go build -v ./...
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Go
+        uses: actions/setup-go@v2
+        with:
+          go-version: 1.15
+      - name: Test
+        run: go test -v -coverprofile coverage.out -covermode atomic ./... 
+      - name: Publish coverage
+        uses: codecov/codecov-action@v1
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
+  publish:
+    needs:
+      - build
+      - test
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - uses: jerray/publish-docker-action@master
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_PASSWORD }}
+          file: dockerfiles/Dockerfile.self-contained
+          repository: containrrr/watchtower
+          tags: latest-dev
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3083abb..de72102 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,6 +1,7 @@
 name: Release (Production)
 
 on:
+  workflow_dispatch: {}
   release:
     types:
       - created
diff --git a/docs/arguments.md b/docs/arguments.md
index 01de345..70efc21 100644
--- a/docs/arguments.md
+++ b/docs/arguments.md
@@ -39,7 +39,7 @@ Environment Variable: N/A
 
 ## Time Zone
 Sets the time zone to be used by WatchTower's logs and the optional Cron scheduling argument (--schedule). If this environment variable is not set, Watchtower will use the default time zone: UTC.
-To find out the right value, see [this list](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones), find your location and use the value in _TZ Database Name_, e.g _Europe/Rome_. The timezone can alternatively be set by volume mounting your hosts /etc/timezone file. `-v /etc/timezone:/etc/timezone:ro`
+To find out the right value, see [this list](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones), find your location and use the value in _TZ Database Name_, e.g _Europe/Rome_. The timezone can alternatively be set by volume mounting your hosts /etc/localtime file. `-v /etc/localtime:/etc/localtime:ro`
 
 ```
             Argument: N/A
diff --git a/pkg/container/client.go b/pkg/container/client.go
index 635aa3e..b125ed6 100644
--- a/pkg/container/client.go
+++ b/pkg/container/client.go
@@ -147,8 +147,10 @@ func (client dockerClient) StopContainer(c Container, timeout time.Duration) err
 		signal = defaultStopSignal
 	}
 
+	shortID := ShortID(c.ID())
+
 	if c.IsRunning() {
-		log.Infof("Stopping %s (%s) with %s", c.Name(), c.ID(), signal)
+		log.Infof("Stopping %s (%s) with %s", c.Name(), shortID, signal)
 		if err := client.api.ContainerKill(bg, c.ID(), signal); err != nil {
 			return err
 		}
@@ -158,9 +160,9 @@ func (client dockerClient) StopContainer(c Container, timeout time.Duration) err
 	_ = client.waitForStopOrTimeout(c, timeout)
 
 	if c.containerInfo.HostConfig.AutoRemove {
-		log.Debugf("AutoRemove container %s, skipping ContainerRemove call.", c.ID())
+		log.Debugf("AutoRemove container %s, skipping ContainerRemove call.", shortID)
 	} else {
-		log.Debugf("Removing container %s", c.ID())
+		log.Debugf("Removing container %s", shortID)
 
 		if err := client.api.ContainerRemove(bg, c.ID(), types.ContainerRemoveOptions{Force: true, RemoveVolumes: client.removeVolumes}); err != nil {
 			return err
@@ -169,7 +171,7 @@ func (client dockerClient) StopContainer(c Container, timeout time.Duration) err
 
 	// Wait for container to be removed. In this case an error is a good thing
 	if err := client.waitForStopOrTimeout(c, timeout); err == nil {
-		return fmt.Errorf("container %s (%s) could not be removed", c.Name(), c.ID())
+		return fmt.Errorf("container %s (%s) could not be removed", c.Name(), shortID)
 	}
 
 	return nil
@@ -229,7 +231,7 @@ func (client dockerClient) StartContainer(c Container) (string, error) {
 func (client dockerClient) doStartContainer(bg context.Context, c Container, creation container.ContainerCreateCreatedBody) error {
 	name := c.Name()
 
-	log.Debugf("Starting container %s (%s)", name, creation.ID)
+	log.Debugf("Starting container %s (%s)", name, ShortID(creation.ID))
 	err := client.api.ContainerStart(bg, creation.ID, types.ContainerStartOptions{})
 	if err != nil {
 		return err
@@ -239,7 +241,7 @@ func (client dockerClient) doStartContainer(bg context.Context, c Container, cre
 
 func (client dockerClient) RenameContainer(c Container, newName string) error {
 	bg := context.Background()
-	log.Debugf("Renaming container %s (%s) to %s", c.Name(), c.ID(), newName)
+	log.Debugf("Renaming container %s (%s) to %s", c.Name(), ShortID(c.ID()), newName)
 	return client.api.ContainerRename(bg, c.ID(), newName)
 }
 
@@ -269,7 +271,7 @@ func (client dockerClient) HasNewImage(ctx context.Context, container Container)
 		return false, nil
 	}
 
-	log.Infof("Found new %s image (%s)", imageName, newImageInfo.ID)
+	log.Infof("Found new %s image (%s)", imageName, ShortID(newImageInfo.ID))
 	return true, nil
 }
 
@@ -284,13 +286,13 @@ func (client dockerClient) PullImage(ctx context.Context, container Container) e
 
 	log.WithFields(fields).Debugf("Trying to load authentication credentials.")
 	opts, err := registry.GetPullOptions(imageName)
-	if opts.RegistryAuth != "" {
-		log.Debug("Credentials loaded")
-	}
 	if err != nil {
 		log.Debugf("Error loading authentication credentials %s", err)
 		return err
 	}
+	if opts.RegistryAuth != "" {
+		log.Debug("Credentials loaded")
+	}
 
 	log.WithFields(fields).Debugf("Checking if pull is needed")
 
@@ -326,7 +328,7 @@ func (client dockerClient) PullImage(ctx context.Context, container Container) e
 }
 
 func (client dockerClient) RemoveImageByID(id string) error {
-	log.Infof("Removing image %s", id)
+	log.Infof("Removing image %s", ShortID(id))
 
 	_, err := client.api.ImageRemove(
 		context.Background(),
@@ -404,6 +406,7 @@ func (client dockerClient) waitForExecOrTimeout(bg context.Context, ID string, e
 	for {
 		execInspect, err := client.api.ContainerExecInspect(ctx, ID)
 
+		//goland:noinspection GoNilness
 		log.WithFields(log.Fields{
 			"exit-code": execInspect.ExitCode,
 			"exec-id":   execInspect.ExecID,
diff --git a/pkg/container/util.go b/pkg/container/util.go
new file mode 100644
index 0000000..261316f
--- /dev/null
+++ b/pkg/container/util.go
@@ -0,0 +1,23 @@
+package container
+
+import "strings"
+
+// ShortID returns the 12-character (hex) short version of an image ID hash, removing any "sha256:" prefix if present
+func ShortID(imageID string) (short string) {
+	prefixSep := strings.IndexRune(imageID, ':')
+	offset := 0
+	length := 12
+	if prefixSep >= 0 {
+		if imageID[0:prefixSep] == "sha256" {
+			offset = prefixSep + 1
+		} else {
+			length += prefixSep + 1
+		}
+	}
+
+	if len(imageID) >= offset+length {
+		return imageID[offset : offset+length]
+	}
+
+	return imageID
+}
diff --git a/pkg/notifications/notifier.go b/pkg/notifications/notifier.go
index b9e322e..c4e962f 100644
--- a/pkg/notifications/notifier.go
+++ b/pkg/notifications/notifier.go
@@ -55,6 +55,12 @@ func (n *Notifier) String() string {
 			sb.WriteString(", ")
 		}
 	}
+
+	if sb.Len() < 2 {
+		// No notification services are configured, return early as the separator strip is not applicable
+		return "none"
+	}
+
 	names := sb.String()
 
 	// remove the last separator
@@ -97,7 +103,7 @@ func (n *Notifier) getNotificationTypes(cmd *cobra.Command, levels []log.Level,
 			log.Fatal("failed to create notification config:", err)
 		}
 
-		println(shoutrrrURL)
+		log.WithField("URL", shoutrrrURL).Trace("created Shoutrrr URL from legacy notifier")
 
 		notifier := newShoutrrrNotifierFromURL(
 			cmd,


Test patch:
diff --git a/pkg/container/util_test.go b/pkg/container/util_test.go
new file mode 100644
index 0000000..8cb0328
--- /dev/null
+++ b/pkg/container/util_test.go
@@ -0,0 +1,46 @@
+package container_test
+
+import (
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+
+	. "github.com/containrrr/watchtower/pkg/container"
+)
+
+var _ = Describe("container utils", func() {
+	Describe("ShortID", func() {
+		When("given a normal image ID", func() {
+			When("it contains a sha256 prefix", func() {
+				It("should return that ID in short version", func() {
+					actual := ShortID("sha256:0123456789abcd00000000001111111111222222222233333333334444444444")
+					Expect(actual).To(Equal("0123456789ab"))
+				})
+			})
+			When("it doesn't contain a prefix", func() {
+				It("should return that ID in short version", func() {
+					actual := ShortID("0123456789abcd00000000001111111111222222222233333333334444444444")
+					Expect(actual).To(Equal("0123456789ab"))
+				})
+			})
+		})
+		When("given a short image ID", func() {
+			When("it contains no prefix", func() {
+				It("should return the same string", func() {
+					Expect(ShortID("0123456789ab")).To(Equal("0123456789ab"))
+				})
+			})
+			When("it contains a the sha256 prefix", func() {
+				It("should return the ID without the prefix", func() {
+					Expect(ShortID("sha256:0123456789ab")).To(Equal("0123456789ab"))
+				})
+			})
+		})
+		When("given an ID with an unknown prefix", func() {
+			It("should return a short version of that ID including the prefix", func() {
+				Expect(ShortID("md5:0123456789ab")).To(Equal("md5:0123456789ab"))
+				Expect(ShortID("md5:0123456789abcdefg")).To(Equal("md5:0123456789ab"))
+				Expect(ShortID("md5:01")).To(Equal("md5:01"))
+			})
+		})
+	})
+})
diff --git a/pkg/notifications/notifier_test.go b/pkg/notifications/notifier_test.go
index 5ef75a0..ecd228f 100644
--- a/pkg/notifications/notifier_test.go
+++ b/pkg/notifications/notifier_test.go
@@ -23,6 +23,22 @@ func TestActions(t *testing.T) {
 }
 
 var _ = Describe("notifications", func() {
+	Describe("the notifier", func() {
+		When("only empty notifier types are provided", func() {
+
+			command := cmd.NewRootCommand()
+			flags.RegisterNotificationFlags(command)
+
+			err := command.ParseFlags([]string{
+				"--notifications",
+				"shoutrrr",
+			})
+			Expect(err).NotTo(HaveOccurred())
+			notif := notifications.NewNotifier(command)
+
+			Expect(notif.String()).To(Equal("none"))
+		})
+	})
 	Describe("the slack notifier", func() {
 		builderFn := notifications.NewSlackNotifier