Repository: containrrr/watchtower
Pull request #489 (2020-03-30 20:27:16)

Add ability to overrider depending containers with special label
Hey!

I decided to introduce new option as discussed on https://github.com/containrrr/watchtower/issues/204.

Assigning new label `com.centurylinklabs.watchtower.depends-on` will override depended containers taken from `HostConfig.Links`, which seems not being used at all these days.

What do you think about that? 

simskij: Thank you for providing a pr! I really like this idea. Good job! It's still in draft mode, so let me know when you feel it's ready for a proper review and I'll have a final look before approving it. 👍 👏 
Saicheg: @simskij 

Sure, to be honest - it's my first code on go, so I was not sure about that.

Also, I would love to get some guidance about what else should I include in this PR. Documentation maybe? Anything else?

And if you can test it yourself to see it working - it would be nice.

Patch:
diff --git a/.all-contributorsrc b/.all-contributorsrc
index 41f5863..e67f57e 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -474,7 +474,9 @@
       "avatar_url": "https://avatars1.githubusercontent.com/u/26290053?v=4",
       "profile": "https://github.com/victorcmoura",
       "contributions": [
-        "test"
+        "test",
+        "code",
+        "doc"
       ]
     },
     {
@@ -523,6 +525,15 @@
         "test",
         "review"
       ]
+    },
+    {
+      "login": "patski123",
+      "name": "PatSki123",
+      "avatar_url": "https://avatars1.githubusercontent.com/u/19295295?v=4",
+      "profile": "https://github.com/patski123",
+      "contributions": [
+        "doc"
+      ]
     }
   ],
   "contributorsPerLine": 7,
diff --git a/README.md b/README.md
index 33773bb..ee1f468 100644
--- a/README.md
+++ b/README.md
@@ -135,13 +135,16 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
   </tr>
   <tr>
     <td align="center"><a href="https://github.com/auanasgheps"><img src="https://avatars2.githubusercontent.com/u/20586878?v=4" width="100px;" alt=""/><br /><sub><b>Oliver Cervera</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=auanasgheps" title="Documentation">📖</a></td>
-    <td align="center"><a href="https://github.com/victorcmoura"><img src="https://avatars1.githubusercontent.com/u/26290053?v=4" width="100px;" alt=""/><br /><sub><b>Victor Moura</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=victorcmoura" title="Tests">⚠️</a></td>
+    <td align="center"><a href="https://github.com/victorcmoura"><img src="https://avatars1.githubusercontent.com/u/26290053?v=4" width="100px;" alt=""/><br /><sub><b>Victor Moura</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=victorcmoura" title="Tests">⚠️</a> <a href="https://github.com/containrrr/watchtower/commits?author=victorcmoura" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/commits?author=victorcmoura" title="Documentation">📖</a></td>
     <td align="center"><a href="https://github.com/mbrandau"><img src="https://avatars3.githubusercontent.com/u/12972798?v=4" width="100px;" alt=""/><br /><sub><b>Maximilian Brandau</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=mbrandau" title="Code">💻</a> <a href="https://github.com/containrrr/watchtower/commits?author=mbrandau" title="Tests">⚠️</a></td>
     <td align="center"><a href="https://github.com/aneisch"><img src="https://avatars1.githubusercontent.com/u/6991461?v=4" width="100px;" alt=""/><br /><sub><b>Andrew</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=aneisch" title="Documentation">📖</a></td>
     <td align="center"><a href="https://github.com/sixcorners"><img src="https://avatars0.githubusercontent.com/u/585501?v=4" width="100px;" alt=""/><br /><sub><b>sixcorners</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=sixcorners" title="Documentation">📖</a></td>
     <td align="center"><a href="https://piksel.se"><img src="https://avatars2.githubusercontent.com/u/807383?v=4" width="100px;" alt=""/><br /><sub><b>nils måsén</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=piksel" title="Documentation">📖</a></td>
     <td align="center"><a href="https://arnested.dk"><img src="https://avatars2.githubusercontent.com/u/190005?v=4" width="100px;" alt=""/><br /><sub><b>Arne Jørgensen</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=arnested" title="Tests">⚠️</a> <a href="https://github.com/containrrr/watchtower/pulls?q=is%3Apr+reviewed-by%3Aarnested" title="Reviewed Pull Requests">👀</a></td>
   </tr>
+  <tr>
+    <td align="center"><a href="https://github.com/patski123"><img src="https://avatars1.githubusercontent.com/u/19295295?v=4" width="100px;" alt=""/><br /><sub><b>PatSki123</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=patski123" title="Documentation">📖</a></td>
+  </tr>
 </table>
 
 <!-- markdownlint-enable -->
diff --git a/cmd/root.go b/cmd/root.go
index cf2d5c6..2a87c1d 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/containrrr/watchtower/internal/actions"
 	"github.com/containrrr/watchtower/internal/flags"
+	"github.com/containrrr/watchtower/pkg/api"
 	"github.com/containrrr/watchtower/pkg/container"
 	"github.com/containrrr/watchtower/pkg/filters"
 	"github.com/containrrr/watchtower/pkg/notifications"
@@ -111,6 +112,18 @@ func PreRun(cmd *cobra.Command, args []string) {
 func Run(c *cobra.Command, names []string) {
 	filter := filters.BuildFilter(names, enableLabel)
 	runOnce, _ := c.PersistentFlags().GetBool("run-once")
+	httpAPI, _ := c.PersistentFlags().GetBool("http-api")
+
+	if httpAPI {
+		apiToken, _ := c.PersistentFlags().GetString("http-api-token")
+
+		if err := api.SetupHTTPUpdates(apiToken, func() { runUpdatesWithNotifications(filter) }); err != nil {
+			log.Fatal(err)
+			os.Exit(1)
+		}
+
+		api.WaitForHTTPUpdates()
+	}
 
 	if runOnce {
 		if noStartupMessage, _ := c.PersistentFlags().GetBool("no-startup-message"); !noStartupMessage {
diff --git a/dockerfiles/Dockerfile b/dockerfiles/Dockerfile
index f792d32..7e28eb2 100644
--- a/dockerfiles/Dockerfile
+++ b/dockerfiles/Dockerfile
@@ -14,5 +14,7 @@ COPY --from=alpine \
     /usr/share/zoneinfo \
     /usr/share/zoneinfo
 
+EXPOSE 8080
+
 COPY watchtower /
 ENTRYPOINT ["/watchtower"]
diff --git a/docs/arguments.md b/docs/arguments.md
index 29ac8ca..1100725 100644
--- a/docs/arguments.md
+++ b/docs/arguments.md
@@ -193,6 +193,26 @@ Run an update attempt against a container name list one time immediately and exi
 Environment Variable: WATCHTOWER_RUN_ONCE
                 Type: Boolean
              Default: false
+```
+
+## HTTP API Mode
+Runs Watchtower in HTTP API mode, only allowing image updates to be triggered by an HTTP request.
+
+```
+            Argument: --http-api
+Environment Variable: WATCHTOWER_HTTP_API
+                Type: Boolean
+             Default: false
+``` 
+
+## HTTP API Token
+Sets an authentication token to HTTP API requests.
+
+```
+            Argument: --http-api-token
+Environment Variable: WATCHTOWER_HTTP_API_TOKEN
+                Type: String
+             Default: -
 ``` 
 
 ## Scheduling
diff --git a/docs/http-api-mode.md b/docs/http-api-mode.md
new file mode 100644
index 0000000..7d14d09
--- /dev/null
+++ b/docs/http-api-mode.md
@@ -0,0 +1,35 @@
+Watchtower provides an HTTP API mode that enables an HTTP endpoint that can be requested to trigger container updating. The current available endpoint list is:
+
+- `/v1/update` - triggers an update for all of the containers monitored by this Watchtower instance.
+
+---
+
+To enable this mode, use the flag `--http-api`. For example, in a Docker Compose config file:
+
+```json
+version: '3'
+
+services:
+  app-monitored-by-watchtower:
+    image: myapps/monitored-by-watchtower
+    labels:
+      - "com.centurylinklabs.watchtower.enable=true"
+
+  watchtower:
+    image: containrrr/watchtower
+    volumes:
+      - /var/run/docker.sock:/var/run/docker.sock
+    command: --debug --http-api
+    environment:
+      - WATCHTOWER_HTTP_API_TOKEN=mytoken
+    labels:
+      - "com.centurylinklabs.watchtower.enable=false"
+    ports:
+      - 8080:8080
+```
+
+Notice that there is an environment variable named WATCHTOWER_HTTP_API_TOKEN. To prevent external services from accidentally triggering image updates, all of the requests have to contain a "Token" field, valued as the token defined in WATCHTOWER_HTTP_API_TOKEN, in their headers. In this case, there is a port bind to the host machine, allowing to request localhost:8080 to reach Watchtower. The following `curl` command would trigger an image update:
+
+```bash
+curl -H "Token: mytoken" localhost:8080/v1/update
+```
\ No newline at end of file
diff --git a/docs/linked-containers.md b/docs/linked-containers.md
index 6960b5b..240fb97 100644
--- a/docs/linked-containers.md
+++ b/docs/linked-containers.md
@@ -1,3 +1,5 @@
 Watchtower will detect if there are links between any of the running containers and ensures that things are stopped/started in a way that won't break any of the links. If an update is detected for one of the dependencies in a group of linked containers, watchtower will stop and start all of the containers in the correct order so that the application comes back up correctly.
 
-For example, imagine you were running a _mysql_ container and a _wordpress_ container which had been linked to the _mysql_ container. If watchtower were to detect that the _mysql_ container required an update, it would first shut down the linked _wordpress_ container followed by the _mysql_ container. When restarting the containers it would handle _mysql_ first and then _wordpress_ to ensure that the link continued to work.
\ No newline at end of file
+For example, imagine you were running a _mysql_ container and a _wordpress_ container which had been linked to the _mysql_ container. If watchtower were to detect that the _mysql_ container required an update, it would first shut down the linked _wordpress_ container followed by the _mysql_ container. When restarting the containers it would handle _mysql_ first and then _wordpress_ to ensure that the link continued to work.
+
+If you want to override existing links you can use special `com.centurylinklabs.watchtower.depends-on` label with dependent container names, separated by a comma.
diff --git a/internal/flags/flags.go b/internal/flags/flags.go
index d8c7dff..74c7bb4 100644
--- a/internal/flags/flags.go
+++ b/internal/flags/flags.go
@@ -113,6 +113,18 @@ func RegisterSystemFlags(rootCmd *cobra.Command) {
 		"",
 		viper.GetBool("WATCHTOWER_LIFECYCLE_HOOKS"),
 		"Enable the execution of commands triggered by pre- and post-update lifecycle hooks")
+
+	flags.BoolP(
+		"http-api",
+		"",
+		viper.GetBool("WATCHTOWER_HTTP_API"),
+		"Runs Watchtower in HTTP API mode, so that image updates must to be triggered by a request")
+
+	flags.StringP(
+		"http-api-token",
+		"",
+		viper.GetString("WATCHTOWER_HTTP_API_TOKEN"),
+		"Sets an authentication token to HTTP API requests.")
 }
 
 // RegisterNotificationFlags that are used by watchtower to send notifications
diff --git a/pkg/api/api.go b/pkg/api/api.go
new file mode 100644
index 0000000..f0e4b4e
--- /dev/null
+++ b/pkg/api/api.go
@@ -0,0 +1,61 @@
+package api
+
+import (
+	"os"
+	"net/http"
+	"errors"
+	log "github.com/sirupsen/logrus"
+	"io"
+)
+
+var (
+	lock         chan bool
+)
+
+func init() {
+	lock = make(chan bool, 1)
+	lock <- true
+}
+
+func SetupHTTPUpdates(apiToken string, updateFunction func()) error {
+	if apiToken == "" {
+		return errors.New("API token is empty or has not been set. Not starting API.")
+	}
+	
+	log.Println("Watchtower HTTP API started.")
+
+	http.HandleFunc("/v1/update", func(w http.ResponseWriter, r *http.Request){
+		log.Info("Updates triggered by HTTP API request.")
+		
+		_, err := io.Copy(os.Stdout, r.Body)
+		if err != nil {
+			log.Println(err)
+			return
+		}
+
+		if r.Header.Get("Token") != apiToken {
+			log.Println("Invalid token. Not updating.")
+			return
+		}
+
+		log.Println("Valid token found. Attempting to update.")
+		
+		select {
+			case chanValue := <- lock:
+				defer func() { lock <- chanValue }()
+				updateFunction()
+			default:
+				log.Debug("Skipped. Another update already running.")
+		}
+
+		
+	})
+	
+	return nil
+}
+
+func WaitForHTTPUpdates() error {
+	log.Fatal(http.ListenAndServe(":8080", nil))
+	os.Exit(0)
+	return nil
+}
\ No newline at end of file
diff --git a/pkg/container/container.go b/pkg/container/container.go
index fb495fe..bc2f600 100644
--- a/pkg/container/container.go
+++ b/pkg/container/container.go
@@ -95,6 +95,13 @@ func (c Container) Enabled() (bool, bool) {
 func (c Container) Links() []string {
 	var links []string
 
+	dependsOnLabelValue := c.getLabelValueOrEmpty(dependsOnLabel)
+
+	if dependsOnLabelValue != "" {
+		links := strings.Split(dependsOnLabelValue, ",")
+		return links
+	}
+
 	if (c.containerInfo != nil) && (c.containerInfo.HostConfig != nil) {
 		for _, link := range c.containerInfo.HostConfig.Links {
 			name := strings.Split(link, ":")[0]
diff --git a/pkg/container/metadata.go b/pkg/container/metadata.go
index fe5a055..2c1b933 100644
--- a/pkg/container/metadata.go
+++ b/pkg/container/metadata.go
@@ -4,6 +4,7 @@ const (
 	watchtowerLabel       = "com.centurylinklabs.watchtower"
 	signalLabel           = "com.centurylinklabs.watchtower.stop-signal"
 	enableLabel           = "com.centurylinklabs.watchtower.enable"
+	dependsOnLabel        = "com.centurylinklabs.watchtower.depends-on"
 	zodiacLabel           = "com.centurylinklabs.zodiac.original-image"
 	preCheckLabel         = "com.centurylinklabs.watchtower.lifecycle.pre-check"
 	postCheckLabel        = "com.centurylinklabs.watchtower.lifecycle.post-check"


Test patch:
diff --git a/pkg/container/container_test.go b/pkg/container/container_test.go
index fe838f7..4f0f544 100644
--- a/pkg/container/container_test.go
+++ b/pkg/container/container_test.go
@@ -181,6 +181,42 @@ var _ = Describe("the container", func() {
 				Expect(imageName).To(Equal(name + ":latest"))
 			})
 		})
+
+		When("fetching container links", func() {
+			When("the depends on label is present", func() {
+				It("should fetch depending containers from it", func() {
+					c = mockContainerWithLabels(map[string]string{
+						"com.centurylinklabs.watchtower.depends-on": "postgres",
+					})
+					links := c.Links()
+					Expect(links).To(SatisfyAll(ContainElement("postgres"), HaveLen(1)))
+				})
+				It("should fetch depending containers if there are many", func() {
+					c = mockContainerWithLabels(map[string]string{
+						"com.centurylinklabs.watchtower.depends-on": "postgres,redis",
+					})
+					links := c.Links()
+					Expect(links).To(SatisfyAll(ContainElement("postgres"), ContainElement("redis"), HaveLen(2)))
+				})
+				It("should fetch depending containers if label is blank", func() {
+					c = mockContainerWithLabels(map[string]string{
+						"com.centurylinklabs.watchtower.depends-on": "",
+					})
+					links := c.Links()
+					Expect(links).To(HaveLen(0))
+				})
+			})
+			When("the depends on label is not present", func() {
+				It("should fetch depending containers from host config links", func() {
+					c = mockContainerWithLinks([]string{
+						"redis:test-containrrr",
+						"postgres:test-containrrr",
+					})
+					links := c.Links()
+					Expect(links).To(SatisfyAll(ContainElement("redis"), ContainElement("postgres"), HaveLen(2)))
+				})
+			})
+		})
 	})
 })
 
@@ -190,6 +226,23 @@ func mockContainerWithImageName(name string) *Container {
 	return container
 }
 
+func mockContainerWithLinks(links []string) *Container {
+	content := types.ContainerJSON{
+		ContainerJSONBase: &types.ContainerJSONBase{
+			ID:    "container_id",
+			Image: "image",
+			Name:  "test-containrrr",
+			HostConfig: &container.HostConfig{
+				Links: links,
+			},
+		},
+		Config: &container.Config{
+			Labels: map[string]string{},
+		},
+	}
+	return NewContainer(&content, nil)
+}
+
 func mockContainerWithLabels(labels map[string]string) *Container {
 	content := types.ContainerJSON{
 		ContainerJSONBase: &types.ContainerJSONBase{