Repository: containrrr/watchtower (go)

Issue:
Non-label method for excluding containers
### Is your feature request related to a problem? Please describe.

On Synology (and I assume other environments - UniFi comes to mind), there are often "private" containers that we don't control the creation of. As a consequence, watchtower tries to update those but fails.

Since there's no way to update labels for those containers after they were created, and we don't create them, there's no good way to exclude them, short of

### Describe the solution you'd like

I'd be happy with a `--exclude` or similar flag (possibly taking a regex?) to tell watchtower what to exclude - that's easy to update.

### Describe alternatives you've considered

* Using `--label-enable` is harder to manage (requires managing labels for all the other containers)
* Creating derived containers just to add the label is not really an option for a lot of these private containers, especially as some of them have their own update mechanism.
* On Synology specifically, updating environment variables is easier (doesn't require recreating the container), so that's definitely a good option there
* There could simply be a flag to silently ignore any containers that aren't on the registry (would solve the Synology docviewer case, might not solve the UniFi case where they create common containers too)
* A file with the list of exclusions might also work

### Additional context

Related to https://github.com/containrrr/watchtower/issues/923 .

Pull request:
feat(filters): Add a flag/env to explicitly exclude containers by name
This adds a way to exclude containers by name, rather than having to set labels.

Resolves #1566


Patch:
diff --git a/cmd/root.go b/cmd/root.go
index 90c338c..48961d2 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -29,18 +29,19 @@ import (
 )
 
 var (
-	client          container.Client
-	scheduleSpec    string
-	cleanup         bool
-	noRestart       bool
-	monitorOnly     bool
-	enableLabel     bool
-	notifier        t.Notifier
-	timeout         time.Duration
-	lifecycleHooks  bool
-	rollingRestart  bool
-	scope           string
-	labelPrecedence bool
+	client            container.Client
+	scheduleSpec      string
+	cleanup           bool
+	noRestart         bool
+	monitorOnly       bool
+	enableLabel       bool
+	disableContainers []string
+	notifier          t.Notifier
+	timeout           time.Duration
+	lifecycleHooks    bool
+	rollingRestart    bool
+	scope             string
+	labelPrecedence   bool
 )
 
 var rootCmd = NewRootCommand()
@@ -93,6 +94,7 @@ func PreRun(cmd *cobra.Command, _ []string) {
 	}
 
 	enableLabel, _ = f.GetBool("label-enable")
+	disableContainers, _ = f.GetStringSlice("disable-containers")
 	lifecycleHooks, _ = f.GetBool("enable-lifecycle-hooks")
 	rollingRestart, _ = f.GetBool("rolling-restart")
 	scope, _ = f.GetString("scope")
@@ -134,7 +136,7 @@ func PreRun(cmd *cobra.Command, _ []string) {
 
 // Run is the main execution flow of the command
 func Run(c *cobra.Command, names []string) {
-	filter, filterDesc := filters.BuildFilter(names, enableLabel, scope)
+	filter, filterDesc := filters.BuildFilter(names, disableContainers, enableLabel, scope)
 	runOnce, _ := c.PersistentFlags().GetBool("run-once")
 	enableUpdateAPI, _ := c.PersistentFlags().GetBool("http-api-update")
 	enableMetricsAPI, _ := c.PersistentFlags().GetBool("http-api-metrics")
diff --git a/docs/arguments.md b/docs/arguments.md
index a467538..8b00de9 100644
--- a/docs/arguments.md
+++ b/docs/arguments.md
@@ -230,6 +230,19 @@ __Do not__ Monitor and update containers that have `com.centurylinklabs.watchtow
 no `--label-enable` argument is passed. Note that only one or the other (targeting by enable label) can be 
 used at the same time to target containers.
 
+## Filter by disabling specific container names
+Monitor and update containers whose names are not in a given set of names.
+
+This can be used to exclude specific containers, when setting labels is not an option.
+The listed containers will be excluded even if they have the enable filter set to true.
+
+```text
+            Argument: --disable-containers, -x
+Environment Variable: WATCHTOWER_DISABLE_CONTAINERS
+                Type: Comma- or space-separated string list
+             Default: ""
+```
+
 ## Without updating containers
 Will only monitor for new images, send notifications and invoke
 the [pre-check/post-check hooks](https://containrrr.dev/watchtower/lifecycle-hooks/), but will __not__ update the
diff --git a/internal/flags/flags.go b/internal/flags/flags.go
index 819f6f3..c11cdae 100644
--- a/internal/flags/flags.go
+++ b/internal/flags/flags.go
@@ -5,6 +5,7 @@ import (
 	"errors"
 	"fmt"
 	"os"
+	"regexp"
 	"strings"
 	"time"
 
@@ -85,6 +86,13 @@ func RegisterSystemFlags(rootCmd *cobra.Command) {
 		envBool("WATCHTOWER_LABEL_ENABLE"),
 		"Watch containers where the com.centurylinklabs.watchtower.enable label is true")
 
+	flags.StringSliceP(
+		"disable-containers",
+		"x",
+		// Due to issue spf13/viper#380, can't use viper.GetStringSlice:
+		regexp.MustCompile("[, ]+").Split(envString("WATCHTOWER_DISABLE_CONTAINERS"), -1),
+		"Comma-separated list of containers to explicitly exclude from watching.")
+
 	flags.StringP(
 		"log-format",
 		"l",
@@ -197,8 +205,8 @@ func RegisterSystemFlags(rootCmd *cobra.Command) {
 		"",
 		false,
 		"Do health check and exit")
-  
-  flags.BoolP(
+
+	flags.BoolP(
 		"label-take-precedence",
 		"",
 		envBool("WATCHTOWER_LABEL_TAKE_PRECEDENCE"),
diff --git a/pkg/filters/filters.go b/pkg/filters/filters.go
index fa5ed2a..04520cc 100644
--- a/pkg/filters/filters.go
+++ b/pkg/filters/filters.go
@@ -13,7 +13,7 @@ func WatchtowerContainersFilter(c t.FilterableContainer) bool { return c.IsWatch
 // NoFilter will not filter out any containers
 func NoFilter(t.FilterableContainer) bool { return true }
 
-// FilterByNames returns all containers that match the specified name
+// FilterByNames returns all containers that match one of the specified names
 func FilterByNames(names []string, baseFilter t.Filter) t.Filter {
 	if len(names) == 0 {
 		return baseFilter
@@ -41,6 +41,22 @@ func FilterByNames(names []string, baseFilter t.Filter) t.Filter {
 	}
 }
 
+// FilterByDisableNames returns all containers that don't match any of the specified names
+func FilterByDisableNames(disableNames []string, baseFilter t.Filter) t.Filter {
+	if len(disableNames) == 0 {
+		return baseFilter
+	}
+
+	return func(c t.FilterableContainer) bool {
+		for _, name := range disableNames {
+			if name == c.Name() || name == c.Name()[1:] {
+				return false
+			}
+		}
+		return baseFilter(c)
+	}
+}
+
 // FilterByEnableLabel returns all containers that have the enabled label set
 func FilterByEnableLabel(baseFilter t.Filter) t.Filter {
 	return func(c t.FilterableContainer) bool {
@@ -103,10 +119,11 @@ func FilterByImage(images []string, baseFilter t.Filter) t.Filter {
 }
 
 // BuildFilter creates the needed filter of containers
-func BuildFilter(names []string, enableLabel bool, scope string) (t.Filter, string) {
+func BuildFilter(names []string, disableNames []string, enableLabel bool, scope string) (t.Filter, string) {
 	sb := strings.Builder{}
 	filter := NoFilter
 	filter = FilterByNames(names, filter)
+	filter = FilterByDisableNames(disableNames, filter)
 
 	if len(names) > 0 {
 		sb.WriteString("which name matches \"")
@@ -118,6 +135,16 @@ func BuildFilter(names []string, enableLabel bool, scope string) (t.Filter, stri
 		}
 		sb.WriteString(`", `)
 	}
+	if len(disableNames) > 0 {
+		sb.WriteString("not named one of \"")
+		for i, n := range disableNames {
+			sb.WriteString(n)
+			if i < len(disableNames)-1 {
+				sb.WriteString(`" or "`)
+			}
+		}
+		sb.WriteString(`", `)
+	}
 
 	if enableLabel {
 		// If label filtering is enabled, containers should only be considered


Test patch:
diff --git a/pkg/filters/filters_test.go b/pkg/filters/filters_test.go
index 951e7ca..3d33cdf 100644
--- a/pkg/filters/filters_test.go
+++ b/pkg/filters/filters_test.go
@@ -171,7 +171,7 @@ func TestFilterByImage(t *testing.T) {
 func TestBuildFilter(t *testing.T) {
 	names := []string{"test", "valid"}
 
-	filter, desc := BuildFilter(names, false, "")
+	filter, desc := BuildFilter(names, []string{}, false, "")
 	assert.Contains(t, desc, "test")
 	assert.Contains(t, desc, "or")
 	assert.Contains(t, desc, "valid")
@@ -210,7 +210,7 @@ func TestBuildFilterEnableLabel(t *testing.T) {
 	var names []string
 	names = append(names, "test")
 
-	filter, desc := BuildFilter(names, true, "")
+	filter, desc := BuildFilter(names, []string{}, true, "")
 	assert.Contains(t, desc, "using enable label")
 
 	container := new(mocks.FilterableContainer)
@@ -235,3 +235,52 @@ func TestBuildFilterEnableLabel(t *testing.T) {
 	assert.False(t, filter(container))
 	container.AssertExpectations(t)
 }
+
+func TestBuildFilterDisableContainer(t *testing.T) {
+	filter, desc := BuildFilter([]string{}, []string{"excluded", "notfound"}, false, "")
+	assert.Contains(t, desc, "not named")
+	assert.Contains(t, desc, "excluded")
+	assert.Contains(t, desc, "or")
+	assert.Contains(t, desc, "notfound")
+
+	container := new(mocks.FilterableContainer)
+	container.On("Name").Return("Another")
+	container.On("Enabled").Return(false, false)
+	assert.True(t, filter(container))
+	container.AssertExpectations(t)
+
+	container = new(mocks.FilterableContainer)
+	container.On("Name").Return("AnotherOne")
+	container.On("Enabled").Return(true, true)
+	assert.True(t, filter(container))
+	container.AssertExpectations(t)
+
+	container = new(mocks.FilterableContainer)
+	container.On("Name").Return("test")
+	container.On("Enabled").Return(false, false)
+	assert.True(t, filter(container))
+	container.AssertExpectations(t)
+
+	container = new(mocks.FilterableContainer)
+	container.On("Name").Return("excluded")
+	container.On("Enabled").Return(true, true)
+	assert.False(t, filter(container))
+	container.AssertExpectations(t)
+
+	container = new(mocks.FilterableContainer)
+	container.On("Name").Return("excludedAsSubstring")
+	container.On("Enabled").Return(true, true)
+	assert.True(t, filter(container))
+	container.AssertExpectations(t)
+
+	container = new(mocks.FilterableContainer)
+	container.On("Name").Return("notfound")
+	container.On("Enabled").Return(true, true)
+	assert.False(t, filter(container))
+	container.AssertExpectations(t)
+
+	container = new(mocks.FilterableContainer)
+	container.On("Enabled").Return(false, true)
+	assert.False(t, filter(container))
+	container.AssertExpectations(t)
+}