Repository: containrrr/watchtower
Pull request #809 (2021-01-30 14:10:29)

feat: include additional info in startup
I am investigating alternative startup messages for Watchtower, based on the common issues users seem to have. This is the proposed output from run once:
```
$ ./watchtower --run-once --notifications gotify,shoutrrr --scope 15xPMII foo bar --debug true --label-enable
INFO[0000] Watchtower v1.1.6
Using notifications: gotify, smtp
Only checking containers with name "foo" or "bar" or "true", using enable label, in scope "15xPMII"
Running a one time update.
Waiting for the notification goroutine to finish
Waiting for the notification goroutine to finish
```
And this is the corresponding one for interval/schedule/--not-run-once:
```
./watchtower --notifications gotify,shoutrrr foo bar --label-enable
INFO[0001] Watchtower v1.1.6
Using notifications: gotify, smtp
Only checking containers with name "foo" or "bar", using enable label
Scheduling first run: 2021-01-31 13:53:08 +0100 CET
Note that the first check will be performed in 23 hours, 59 minutes, 59 seconds
INFO[0039] Waiting for running update to be finished...
```

codecov[bot]: # [Codecov](https://codecov.io/gh/containrrr/watchtower/pull/809?src=pr&el=h1) Report
> :exclamation: No coverage uploaded for pull request base (`main@40ab6fd`). [Click here to learn what that means](https://docs.codecov.io/docs/error-reference#section-missing-base-commit).
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/containrrr/watchtower/pull/809/graphs/tree.svg?width=650&height=150&src=pr&token=8pxWgB380Y)](https://codecov.io/gh/containrrr/watchtower/pull/809?src=pr&el=tree)

```diff
@@           Coverage Diff           @@
##             main     #809   +/-   ##
=======================================
  Coverage        ?   42.82%           
=======================================
  Files           ?       24           
  Lines           ?     1387           
  Branches        ?        0           
=======================================
  Hits            ?      594           
  Misses          ?      733           
  Partials        ?       60           
```



------

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

simskij: Anything more you'd like to do here, @piksel, or is it ready for review?
piksel: Well, it's currently outputting what was presented in the team discussion (I'll update the PR with discussion message).
It's marked as a draft because it's in more of a "suggestion" state (one might call it a... draft 😁), since I'm not sure what we actually want to display/notify on startup. 
So it's ready for review, but not of code quality, but more of functionality, if that makes sense...
simskij: > Well, it's currently outputting what was presented in the team discussion (I'll update the PR with discussion message).
> It's marked as a draft because it's in more of a "suggestion" state (one might call it a... draft 😁), since I'm not sure what we actually want to display/notify on startup.
> So it's ready for review, but not of code quality, but more of functionality, if that makes sense...

From a functional perspective, I'm satisfied with using what you suggest as a first released iteration. 👍🏼 

Patch:
diff --git a/.all-contributorsrc b/.all-contributorsrc
index 661ab63..1973f66 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -739,6 +739,15 @@
       "contributions": [
         "doc"
       ]
+    },
+    {
+      "login": "reinout",
+      "name": "Reinout van Rees",
+      "avatar_url": "https://avatars.githubusercontent.com/u/121433?v=4",
+      "profile": "https://reinout.vanrees.org",
+      "contributions": [
+        "doc"
+      ]
     }
   ],
   "contributorsPerLine": 7,
diff --git a/README.md b/README.md
index d534ac0..4c2b805 100644
--- a/README.md
+++ b/README.md
@@ -143,6 +143,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
   <tr>
     <td align="center"><a href="https://nymous.io"><img src="https://avatars1.githubusercontent.com/u/4216559?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Thomas Gaudin</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=nymous" title="Documentation">📖</a></td>
     <td align="center"><a href="https://indigo.re/"><img src="https://avatars.githubusercontent.com/u/2804645?v=4?s=100" width="100px;" alt=""/><br /><sub><b>hydrargyrum</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=hydrargyrum" title="Documentation">📖</a></td>
+    <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>
   </tr>
 </table>
 
diff --git a/build.sh b/build.sh
new file mode 100644
index 0000000..363dc74
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+VERSION=$(git describe)
+echo "Building $VERSION..."
+go build -o watchtower -ldflags "-X github.com/containrrr/watchtower/cmd.version=$VERSION"
\ No newline at end of file
diff --git a/cmd/root.go b/cmd/root.go
index 0aeeac6..3a597d0 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -1,14 +1,15 @@
 package cmd
 
 import (
-	metrics2 "github.com/containrrr/watchtower/pkg/metrics"
+	"math"
 	"os"
 	"os/signal"
 	"strconv"
+	"strings"
 	"syscall"
 	"time"
 
-	"github.com/containrrr/watchtower/pkg/api/metrics"
+	apiMetrics "github.com/containrrr/watchtower/pkg/api/metrics"
 	"github.com/containrrr/watchtower/pkg/api/update"
 
 	"github.com/containrrr/watchtower/internal/actions"
@@ -16,6 +17,7 @@ import (
 	"github.com/containrrr/watchtower/pkg/api"
 	"github.com/containrrr/watchtower/pkg/container"
 	"github.com/containrrr/watchtower/pkg/filters"
+	"github.com/containrrr/watchtower/pkg/metrics"
 	"github.com/containrrr/watchtower/pkg/notifications"
 	t "github.com/containrrr/watchtower/pkg/types"
 	"github.com/robfig/cron"
@@ -36,6 +38,8 @@ var (
 	lifecycleHooks bool
 	rollingRestart bool
 	scope          string
+	// Set on build using ldflags
+	version = "v0.0.0-unknown"
 )
 
 var rootCmd = NewRootCommand()
@@ -69,7 +73,7 @@ func Execute() {
 }
 
 // PreRun is a lifecycle hook that runs before the command is executed.
-func PreRun(cmd *cobra.Command, args []string) {
+func PreRun(cmd *cobra.Command, _ []string) {
 	f := cmd.PersistentFlags()
 
 	if enabled, _ := f.GetBool("no-color"); enabled {
@@ -146,7 +150,7 @@ func PreRun(cmd *cobra.Command, args []string) {
 
 // Run is the main execution flow of the command
 func Run(c *cobra.Command, names []string) {
-	filter := filters.BuildFilter(names, enableLabel, scope)
+	filter, filterDesc := filters.BuildFilter(names, enableLabel, scope)
 	runOnce, _ := c.PersistentFlags().GetBool("run-once")
 	enableUpdateAPI, _ := c.PersistentFlags().GetBool("http-api-update")
 	enableMetricsAPI, _ := c.PersistentFlags().GetBool("http-api-metrics")
@@ -154,9 +158,7 @@ func Run(c *cobra.Command, names []string) {
 	apiToken, _ := c.PersistentFlags().GetString("http-api-token")
 
 	if runOnce {
-		if noStartupMessage, _ := c.PersistentFlags().GetBool("no-startup-message"); !noStartupMessage {
-			log.Info("Running a one time update.")
-		}
+		writeStartupMessage(c, time.Time{}, filterDesc)
 		runUpdatesWithNotifications(filter)
 		notifier.Close()
 		os.Exit(0)
@@ -175,39 +177,99 @@ func Run(c *cobra.Command, names []string) {
 	}
 
 	if enableMetricsAPI {
-		metricsHandler := metrics.New()
+		metricsHandler := apiMetrics.New()
 		httpAPI.RegisterHandler(metricsHandler.Path, metricsHandler.Handle)
 	}
 
-	httpAPI.Start(enableUpdateAPI)
+	if err := httpAPI.Start(enableUpdateAPI); err != nil {
+		log.Error("failed to start API", err)
+	}
 
-	if err := runUpgradesOnSchedule(c, filter); err != nil {
+	if err := runUpgradesOnSchedule(c, filter, filterDesc); err != nil {
 		log.Error(err)
 	}
 
 	os.Exit(1)
 }
 
-func runUpgradesOnSchedule(c *cobra.Command, filter t.Filter) error {
+func formatDuration(d time.Duration) string {
+	sb := strings.Builder{}
+
+	hours := int64(d.Hours())
+	minutes := int64(math.Mod(d.Minutes(), 60))
+	seconds := int64(math.Mod(d.Seconds(), 60))
+
+	if hours == 1 {
+		sb.WriteString("1 hour")
+	} else if hours != 0 {
+		sb.WriteString(strconv.FormatInt(hours, 10))
+		sb.WriteString(" hours")
+	}
+
+	if hours != 0 && (seconds != 0 || minutes != 0) {
+		sb.WriteString(", ")
+	}
+
+	if minutes == 1 {
+		sb.WriteString("1 minute")
+	} else if minutes != 0 {
+		sb.WriteString(strconv.FormatInt(minutes, 10))
+		sb.WriteString(" minutes")
+	}
+
+	if minutes != 0 && (seconds != 0) {
+		sb.WriteString(", ")
+	}
+
+	if seconds == 1 {
+		sb.WriteString("1 second")
+	} else if seconds != 0 || (hours == 0 && minutes == 0) {
+		sb.WriteString(strconv.FormatInt(seconds, 10))
+		sb.WriteString(" seconds")
+	}
+
+	return sb.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"
+		notifList := notifier.String()
+		if len(notifList) > 0 {
+			notifs = "Using notifications: " + notifList
+		}
+
+		log.Info("Watchtower ", version, "\n", notifs, "\n", filtering, "\n", schedMessage)
+	}
+}
+
+func runUpgradesOnSchedule(c *cobra.Command, filter t.Filter, filtering string) error {
 	tryLockSem := make(chan bool, 1)
 	tryLockSem <- true
 
-	cron := cron.New()
-	err := cron.AddFunc(
+	scheduler := cron.New()
+	err := scheduler.AddFunc(
 		scheduleSpec,
 		func() {
 			select {
 			case v := <-tryLockSem:
 				defer func() { tryLockSem <- v }()
 				metric := runUpdatesWithNotifications(filter)
-				metrics2.RegisterScan(metric)
+				metrics.RegisterScan(metric)
 			default:
 				// Update was skipped
-				metrics2.RegisterScan(nil)
+				metrics.RegisterScan(nil)
 				log.Debug("Skipped another update already running.")
 			}
 
-			nextRuns := cron.Entries()
+			nextRuns := scheduler.Entries()
 			if len(nextRuns) > 0 {
 				log.Debug("Scheduled next run: " + nextRuns[0].Next.String())
 			}
@@ -217,11 +279,9 @@ func runUpgradesOnSchedule(c *cobra.Command, filter t.Filter) error {
 		return err
 	}
 
-	if noStartupMessage, _ := c.PersistentFlags().GetBool("no-startup-message"); !noStartupMessage {
-		log.Info("Starting Watchtower and scheduling first run: " + cron.Entries()[0].Schedule.Next(time.Now()).String())
-	}
+	writeStartupMessage(c, scheduler.Entries()[0].Schedule.Next(time.Now()), filtering)
 
-	cron.Start()
+	scheduler.Start()
 
 	// Graceful shut-down on SIGINT/SIGTERM
 	interrupt := make(chan os.Signal, 1)
@@ -229,14 +289,13 @@ func runUpgradesOnSchedule(c *cobra.Command, filter t.Filter) error {
 	signal.Notify(interrupt, syscall.SIGTERM)
 
 	<-interrupt
-	cron.Stop()
+	scheduler.Stop()
 	log.Info("Waiting for running update to be finished...")
 	<-tryLockSem
 	return nil
 }
 
-func runUpdatesWithNotifications(filter t.Filter) *metrics2.Metric {
-
+func runUpdatesWithNotifications(filter t.Filter) *metrics.Metric {
 	notifier.StartNotification()
 	updateParams := t.UpdateParams{
 		Filter:         filter,
@@ -247,10 +306,10 @@ func runUpdatesWithNotifications(filter t.Filter) *metrics2.Metric {
 		LifecycleHooks: lifecycleHooks,
 		RollingRestart: rollingRestart,
 	}
-	metrics, err := actions.Update(client, updateParams)
+	metricResults, err := actions.Update(client, updateParams)
 	if err != nil {
 		log.Println(err)
 	}
 	notifier.SendNotification()
-	return metrics
+	return metricResults
 }
diff --git a/docs/usage-overview.md b/docs/usage-overview.md
index b5737c3..04178a4 100644
--- a/docs/usage-overview.md
+++ b/docs/usage-overview.md
@@ -39,7 +39,7 @@ docker run -d \
 
 > NOTE: if you mount `config.json` in the manner above, changes from the host system will (generally) not be propagated to the running container. Mounting files into the Docker daemon uses bind mounts, which are based on inodes. Most applications (including `docker login` and `vim`) will not directly edit the file, but instead make a copy and replace the original file, which results in a new inode which in turn *breaks* the bind mount. **As a workaround**, you can create a symlink to your `config.json` file and then mount the symlink in the container. The symlinked file will always have the same inode, which keeps the bind mount intact and will ensure changes to the original file are propagated to the running container (regardless of the inode of the source file!).
 
-If you mount the config file as described above, be sure to also prepend the URL for the registry when starting up your watched image (you can omit the https://). Here is a complete docker-compose.yml file that starts up a docker container from a private repo at Docker Hub and monitors it with watchtower. Note the command argument changing the interval to 30s rather than the default 5 minutes.
+If you mount the config file as described above, be sure to also prepend the URL for the registry when starting up your watched image (you can omit the https://). Here is a complete docker-compose.yml file that starts up a docker container from a private repo at Docker Hub and monitors it with watchtower. Note the command argument changing the interval to 30s rather than the default 24 hours.
 
 ```yaml
 version: "3"
@@ -55,4 +55,4 @@ services:
       - /var/run/docker.sock:/var/run/docker.sock
       - /root/.docker/config.json:/config.json
     command: --interval 30
-```
\ No newline at end of file
+```
diff --git a/pkg/filters/filters.go b/pkg/filters/filters.go
index 0e37885..18f39c2 100644
--- a/pkg/filters/filters.go
+++ b/pkg/filters/filters.go
@@ -1,6 +1,9 @@
 package filters
 
-import t "github.com/containrrr/watchtower/pkg/types"
+import (
+	t "github.com/containrrr/watchtower/pkg/types"
+	"strings"
+)
 
 // WatchtowerContainersFilter filters only watchtower containers
 func WatchtowerContainersFilter(c t.FilterableContainer) bool { return c.IsWatchtower() }
@@ -68,19 +71,45 @@ func FilterByScope(scope string, baseFilter t.Filter) t.Filter {
 }
 
 // BuildFilter creates the needed filter of containers
-func BuildFilter(names []string, enableLabel bool, scope string) t.Filter {
+func BuildFilter(names []string, enableLabel bool, scope string) (t.Filter, string) {
+	sb := strings.Builder{}
 	filter := NoFilter
 	filter = FilterByNames(names, filter)
+
+	if len(names) > 0 {
+		sb.WriteString("with name \"")
+		for i, n := range names {
+			sb.WriteString(n)
+			if i < len(names)-1 {
+				sb.WriteString(`" or "`)
+			}
+		}
+		sb.WriteString(`", `)
+	}
+
 	if enableLabel {
 		// If label filtering is enabled, containers should only be considered
 		// if the label is specifically set.
 		filter = FilterByEnableLabel(filter)
+		sb.WriteString("using enable label, ")
 	}
 	if scope != "" {
 		// If a scope has been defined, containers should only be considered
 		// if the scope is specifically set.
 		filter = FilterByScope(scope, filter)
+		sb.WriteString(`in scope "`)
+		sb.WriteString(scope)
+		sb.WriteString(`", `)
 	}
 	filter = FilterByDisabledLabel(filter)
-	return filter
+
+	filterDesc := "Checking all containers (except explicitly disabled with label)"
+	if sb.Len() > 0 {
+		filterDesc = "Only checking containers " + sb.String()
+
+		// Remove the last ", "
+		filterDesc = filterDesc[:len(filterDesc)-2]
+	}
+
+	return filter, filterDesc
 }
diff --git a/pkg/notifications/notifier.go b/pkg/notifications/notifier.go
index 938bb9e..b9e322e 100644
--- a/pkg/notifications/notifier.go
+++ b/pkg/notifications/notifier.go
@@ -6,6 +6,7 @@ import (
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 	"os"
+	"strings"
 )
 
 // Notifier can send log output as notification to admins, with optional batching.
@@ -42,6 +43,26 @@ func NewNotifier(c *cobra.Command) *Notifier {
 	return n
 }
 
+func (n *Notifier) String() string {
+	if len(n.types) < 1 {
+		return ""
+	}
+
+	sb := strings.Builder{}
+	for _, notif := range n.types {
+		for _, name := range notif.GetNames() {
+			sb.WriteString(name)
+			sb.WriteString(", ")
+		}
+	}
+	names := sb.String()
+
+	// remove the last separator
+	names = names[:len(names)-2]
+
+	return names
+}
+
 // getNotificationTypes produces an array of notifiers from a list of types
 func (n *Notifier) getNotificationTypes(cmd *cobra.Command, levels []log.Level, types []string) []ty.Notifier {
 	output := make([]ty.Notifier, 0)
diff --git a/pkg/notifications/shoutrrr.go b/pkg/notifications/shoutrrr.go
index 2715711..8376c91 100644
--- a/pkg/notifications/shoutrrr.go
+++ b/pkg/notifications/shoutrrr.go
@@ -33,16 +33,29 @@ type shoutrrrTypeNotifier struct {
 	done      chan bool
 }
 
+func (n *shoutrrrTypeNotifier) GetNames() []string {
+	names := make([]string, len(n.Urls))
+	for i, u := range n.Urls {
+		schemeEnd := strings.Index(u, ":")
+		if schemeEnd <= 0 {
+			names[i] = "invalid"
+			continue
+		}
+		names[i] = u[:schemeEnd]
+	}
+	return names
+}
+
 func newShoutrrrNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier {
 	flags := c.PersistentFlags()
 	urls, _ := flags.GetStringArray("notification-url")
-	template := getShoutrrrTemplate(c)
-	return createSender(urls, acceptedLogLevels, template)
+	tpl := getShoutrrrTemplate(c)
+	return createSender(urls, acceptedLogLevels, tpl)
 }
 
 func newShoutrrrNotifierFromURL(c *cobra.Command, url string, levels []log.Level) t.Notifier {
-	template := getShoutrrrTemplate(c)
-	return createSender([]string{url}, levels, template)
+	tpl := getShoutrrrTemplate(c)
+	return createSender([]string{url}, levels, tpl)
 }
 
 func createSender(urls []string, levels []log.Level, template *template.Template) t.Notifier {
@@ -83,54 +96,54 @@ func sendNotifications(n *shoutrrrTypeNotifier) {
 	n.done <- true
 }
 
-func (e *shoutrrrTypeNotifier) buildMessage(entries []*log.Entry) string {
+func (n *shoutrrrTypeNotifier) buildMessage(entries []*log.Entry) string {
 	var body bytes.Buffer
-	if err := e.template.Execute(&body, entries); err != nil {
+	if err := n.template.Execute(&body, entries); err != nil {
 		fmt.Printf("Failed to execute Shoutrrrr template: %s\n", err.Error())
 	}
 
 	return body.String()
 }
 
-func (e *shoutrrrTypeNotifier) sendEntries(entries []*log.Entry) {
-	msg := e.buildMessage(entries)
-	e.messages <- msg
+func (n *shoutrrrTypeNotifier) sendEntries(entries []*log.Entry) {
+	msg := n.buildMessage(entries)
+	n.messages <- msg
 }
 
-func (e *shoutrrrTypeNotifier) StartNotification() {
-	if e.entries == nil {
-		e.entries = make([]*log.Entry, 0, 10)
+func (n *shoutrrrTypeNotifier) StartNotification() {
+	if n.entries == nil {
+		n.entries = make([]*log.Entry, 0, 10)
 	}
 }
 
-func (e *shoutrrrTypeNotifier) SendNotification() {
-	if e.entries == nil || len(e.entries) <= 0 {
+func (n *shoutrrrTypeNotifier) SendNotification() {
+	if n.entries == nil || len(n.entries) <= 0 {
 		return
 	}
 
-	e.sendEntries(e.entries)
-	e.entries = nil
+	n.sendEntries(n.entries)
+	n.entries = nil
 }
 
-func (e *shoutrrrTypeNotifier) Close() {
-	close(e.messages)
+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")
 
-	_ = <-e.done
+	_ = <-n.done
 }
 
-func (e *shoutrrrTypeNotifier) Levels() []log.Level {
-	return e.logLevels
+func (n *shoutrrrTypeNotifier) Levels() []log.Level {
+	return n.logLevels
 }
 
-func (e *shoutrrrTypeNotifier) Fire(entry *log.Entry) error {
-	if e.entries != nil {
-		e.entries = append(e.entries, entry)
+func (n *shoutrrrTypeNotifier) Fire(entry *log.Entry) error {
+	if n.entries != nil {
+		n.entries = append(n.entries, entry)
 	} else {
 		// Log output generated outside a cycle is sent immediately.
-		e.sendEntries([]*log.Entry{entry})
+		n.sendEntries([]*log.Entry{entry})
 	}
 	return nil
 }
diff --git a/pkg/types/notifier.go b/pkg/types/notifier.go
index 27dc483..f72f980 100644
--- a/pkg/types/notifier.go
+++ b/pkg/types/notifier.go
@@ -4,5 +4,6 @@ package types
 type Notifier interface {
 	StartNotification()
 	SendNotification()
+	GetNames() []string
 	Close()
 }


Test patch:
diff --git a/pkg/filters/filters_test.go b/pkg/filters/filters_test.go
index 5766b64..3b52b5e 100644
--- a/pkg/filters/filters_test.go
+++ b/pkg/filters/filters_test.go
@@ -114,7 +114,8 @@ func TestBuildFilter(t *testing.T) {
 	var names []string
 	names = append(names, "test")
 
-	filter := BuildFilter(names, false, "")
+	filter, desc := BuildFilter(names, false, "")
+	assert.Contains(t, desc, "test")
 
 	container := new(mocks.FilterableContainer)
 	container.On("Name").Return("Invalid")
@@ -150,7 +151,8 @@ func TestBuildFilterEnableLabel(t *testing.T) {
 	var names []string
 	names = append(names, "test")
 
-	filter := BuildFilter(names, true, "")
+	filter, desc := BuildFilter(names, true, "")
+	assert.Contains(t, desc, "using enable label")
 
 	container := new(mocks.FilterableContainer)
 	container.On("Enabled").Return(false, false)