Repository: containrrr/watchtower
Pull request #351 (2019-07-26 16:59:43)

Feat/lifecycle hooks
This is based on the work of @alexandremenif in #197 

As that code was pretty old and watchtower has undergone multiple layout changes and refactorings since, I decided to integrate it manually rather than trying to rebase the code from the old repo. I also took the opportunity to remove some of the defensive code in order to make the code a bit more clear and direct.

This feature allows you to enable lifecycle hooks to watchtowers update action. Having this option enabled in turn lets you configure pre- and post-update commands for each container that watchtower monitors using labels. 

Do note that these commands will be run **inside the container being updated** rather than on the host or in the watchtower container.

The solution closes #197.

simskij: @all-contributors add @alexandremenif for code
allcontributors[bot]: @simskij 

This project's configuration file has malformed JSON: .all-contributorsrc. Error:: Unexpected token { in JSON at position 7893

Patch:
diff --git a/.all-contributorsrc b/.all-contributorsrc
index ebd7824..d41fdf2 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -302,7 +302,7 @@
         "code",
         "doc"
       ]
-    }
+    },
     {
       "login": "zoispag",
       "name": "Zois Pagoulatos",
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 6b35d35..c6b93d1 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -36,9 +36,18 @@ workflows:
               only: /.*/
             tags:
               only: /.*/
+    #  - integration_testing:
+    #      requires:
+    #        - checkout
+    #      filters:
+    #        branches:
+    #          only: /.*/
+    #        tags:
+    #          only: /.*/
       - build:
           requires:
             - testing
+    #        - integration_testing
             - linting
           filters:
             branches:
@@ -90,6 +99,14 @@ jobs:
       - run: go get -u github.com/haya14busa/goverage
       - run: goverage -v -coverprofile=coverage.out ./...
       - run: godacov -t $CODACY_TOKEN -r ./coverage.out -c $CIRCLE_SHA1
+  #integration_testing:
+  #  executor: go
+  #  steps:
+  #    - attach_workspace:
+  #        at: .
+  #    - run: go build .
+  #    - setup_remote_docker
+  #    - run: ./scripts/lifecycle-tests.sh
   build:
     executor: go
     steps:
diff --git a/cmd/root.go b/cmd/root.go
index a162c6e..b18ba06 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -23,14 +23,15 @@ import (
 const DockerAPIMinVersion string = "1.24"
 
 var (
-	client       container.Client
-	scheduleSpec string
-	cleanup      bool
-	noRestart    bool
-	monitorOnly  bool
-	enableLabel  bool
-	notifier     *notifications.Notifier
-	timeout      time.Duration
+	client         container.Client
+	scheduleSpec   string
+	cleanup        bool
+	noRestart      bool
+	monitorOnly    bool
+	enableLabel    bool
+	notifier       *notifications.Notifier
+	timeout        time.Duration
+	lifecycleHooks bool
 )
 
 var rootCmd = &cobra.Command{
@@ -84,7 +85,9 @@ func PreRun(cmd *cobra.Command, args []string) {
 	if timeout < 0 {
 		log.Fatal("Please specify a positive value for timeout value.")
 	}
+
 	enableLabel, _ = f.GetBool("label-enable")
+	lifecycleHooks, _ = f.GetBool("enable-lifecycle-hooks")
 
 	// configure environment vars for client
 	err := flags.EnvConfig(cmd, DockerAPIMinVersion)
@@ -95,6 +98,7 @@ func PreRun(cmd *cobra.Command, args []string) {
 	noPull, _ := f.GetBool("no-pull")
 	includeStopped, _ := f.GetBool("include-stopped")
 	removeVolumes, _ := f.GetBool("remove-volumes")
+
 	client = container.NewClient(
 		!noPull,
 		includeStopped,
@@ -171,11 +175,12 @@ func runUpgradesOnSchedule(filter t.Filter) error {
 func runUpdatesWithNotifications(filter t.Filter) {
 	notifier.StartNotification()
 	updateParams := actions.UpdateParams{
-		Filter:      filter,
-		Cleanup:     cleanup,
-		NoRestart:   noRestart,
-		Timeout:     timeout,
-		MonitorOnly: monitorOnly,
+		Filter:         filter,
+		Cleanup:        cleanup,
+		NoRestart:      noRestart,
+		Timeout:        timeout,
+		MonitorOnly:    monitorOnly,
+		LifecycleHooks: lifecycleHooks,
 	}
 	err := actions.Update(client, updateParams)
 	if err != nil {
diff --git a/docs/lifecycle-hooks.md b/docs/lifecycle-hooks.md
new file mode 100644
index 0000000..bbf9d21
--- /dev/null
+++ b/docs/lifecycle-hooks.md
@@ -0,0 +1,45 @@
+
+## Executing commands before and after updating
+
+> **DO NOTE**: Both commands are shell commands executed with `sh`, and therefore require the 
+> container to provide the `sh` executable.
+
+It is possible to execute a *pre-update* command and a *post-update* command 
+**inside** every container updated by watchtower. The *pre-update* command is 
+executed before stopping the container, and the *post-update* command is 
+executed after restarting the container.
+
+This feature is disabled by default. To enable it, you need to set the option
+`--enable-lifecycle-hooks` on the command line, or set the environment variable
+`WATCHTOWER_LIFECYCLE_HOOKS` to true.
+
+ 
+
+### Specifying update commands
+
+The commands are specified using docker container labels, with 
+`com.centurylinklabs.watchtower.pre-update-command` for the *pre-update* 
+command and `com.centurylinklabs.watchtower.lifecycle.post-update` for the
+*post-update* command.
+
+These labels can be declared as instructions in a Dockerfile:
+
+```docker
+LABEL com.centurylinklabs.watchtower.lifecycle.pre-update="/dump-data.sh"
+LABEL com.centurylinklabs.watchtower.lifecycle.post-update="/restore-data.sh"
+```
+
+Or be specified as part of the `docker run` command line:
+
+```bash
+docker run -d \
+  --label=com.centurylinklabs.watchtower.lifecycle.pre-update="/dump-data.sh" \
+  --label=com.centurylinklabs.watchtower.lifecycle.post-update="/restore-data.sh" \
+  someimage
+```
+
+### Execution failure
+
+The failure of a command to execute, identified by an exit code different than 
+0, will not prevent watchtower from updating the container. Only an error
+log statement containing the exit code will be reported.
\ No newline at end of file
diff --git a/internal/actions/update.go b/internal/actions/update.go
index e3be205..5763017 100644
--- a/internal/actions/update.go
+++ b/internal/actions/update.go
@@ -61,8 +61,9 @@ func stopStaleContainer(container container.Container, client container.Client,
 		return
 	}
 
-	err := client.StopContainer(container, params.Timeout)
-	if err != nil {
+	executePreUpdateCommand(client, container)
+
+	if err := client.StopContainer(container, params.Timeout); err != nil {
 		log.Error(err)
 	}
 }
@@ -89,8 +90,10 @@ func restartStaleContainer(container container.Container, client container.Clien
 	}
 
 	if !params.NoRestart {
-		if err := client.StartContainer(container); err != nil {
+		if newContainerID, err := client.StartContainer(container); err != nil {
 			log.Error(err)
+		} else if container.Stale && params.LifecycleHooks {
+			executePostUpdateCommand(client, newContainerID)
 		}
 	}
 
@@ -104,18 +107,49 @@ func restartStaleContainer(container container.Container, client container.Clien
 func checkDependencies(containers []container.Container) {
 
 	for i, parent := range containers {
-		if parent.Stale {
+		if parent.ToRestart() {
 			continue
 		}
 
 	LinkLoop:
 		for _, linkName := range parent.Links() {
 			for _, child := range containers {
-				if child.Name() == linkName && child.Stale {
-					containers[i].Stale = true
+				if child.Name() == linkName && child.ToRestart() {
+					containers[i].Linked = true
 					break LinkLoop
 				}
 			}
 		}
 	}
 }
+
+func executePreUpdateCommand(client container.Client, container container.Container) {
+
+	command := container.GetLifecyclePreUpdateCommand()
+	if len(command) == 0 {
+		log.Debug("No pre-update command supplied. Skipping")
+	}
+
+	log.Info("Executing pre-update command.")
+	if err := client.ExecuteCommand(container.ID(), command); err != nil {
+		log.Error(err)
+	}
+}
+
+func executePostUpdateCommand(client container.Client, newContainerID string) {
+	newContainer, err := client.GetContainer(newContainerID)
+	if err != nil {
+		log.Error(err)
+		return
+	}
+
+	command := newContainer.GetLifecyclePostUpdateCommand()
+	if len(command) == 0 {
+		log.Debug("No post-update command supplied. Skipping")
+	}
+
+	log.Info("Executing post-update command.")
+	if err := client.ExecuteCommand(newContainerID, command); err != nil {
+		log.Error(err)
+	}
+}
diff --git a/internal/actions/update_params.go b/internal/actions/update_params.go
index 851f23e..ff586c6 100644
--- a/internal/actions/update_params.go
+++ b/internal/actions/update_params.go
@@ -7,9 +7,10 @@ import (
 
 // UpdateParams contains all different options available to alter the behavior of the Update func
 type UpdateParams struct {
-	Filter      t.Filter
-	Cleanup     bool
-	NoRestart   bool
-	Timeout     time.Duration
-	MonitorOnly bool
+	Filter         t.Filter
+	Cleanup        bool
+	NoRestart      bool
+	Timeout        time.Duration
+	MonitorOnly    bool
+	LifecycleHooks bool
 }
diff --git a/internal/flags/flags.go b/internal/flags/flags.go
index ae786cd..d416243 100644
--- a/internal/flags/flags.go
+++ b/internal/flags/flags.go
@@ -88,6 +88,12 @@ func RegisterSystemFlags(rootCmd *cobra.Command) {
 		"S",
 		viper.GetBool("WATCHTOWER_INCLUDE_STOPPED"),
 		"Will also include created and exited containers")
+
+	flags.BoolP(
+		"enable-lifecycle-hooks",
+		"",
+		viper.GetBool("WATCHTOWER_LIFECYCLE_HOOKS"),
+		"Enable the execution of commands triggered by pre- and post-update lifecycle hooks")
 }
 
 // RegisterNotificationFlags that are used by watchtower to send notifications
diff --git a/pkg/container/client.go b/pkg/container/client.go
index 124e889..0dc22db 100644
--- a/pkg/container/client.go
+++ b/pkg/container/client.go
@@ -1,8 +1,10 @@
 package container
 
 import (
+	"bytes"
 	"fmt"
 	"io/ioutil"
+	"strings"
 	"time"
 
 	t "github.com/containrrr/watchtower/pkg/types"
@@ -15,18 +17,18 @@ import (
 	"golang.org/x/net/context"
 )
 
-const (
-	defaultStopSignal = "SIGTERM"
-)
+const defaultStopSignal = "SIGTERM"
 
 // A Client is the interface through which watchtower interacts with the
 // Docker API.
 type Client interface {
 	ListContainers(t.Filter) ([]Container, error)
+	GetContainer(containerID string) (Container, error)
 	StopContainer(Container, time.Duration) error
-	StartContainer(Container) error
+	StartContainer(Container) (string, error)
 	RenameContainer(Container, string) error
 	IsContainerStale(Container) (bool, error)
+	ExecuteCommand(containerID string, command string) error
 	RemoveImage(Container) error
 }
 
@@ -80,18 +82,12 @@ func (client dockerClient) ListContainers(fn t.Filter) ([]Container, error) {
 	}
 
 	for _, runningContainer := range containers {
-		containerInfo, err := client.api.ContainerInspect(bg, runningContainer.ID)
-		if err != nil {
-			return nil, err
-		}
 
-		imageInfo, _, err := client.api.ImageInspectWithRaw(bg, containerInfo.Image)
+		c, err := client.GetContainer(runningContainer.ID)
 		if err != nil {
 			return nil, err
 		}
 
-		c := Container{containerInfo: &containerInfo, imageInfo: &imageInfo}
-
 		if fn(c) {
 			cs = append(cs, c)
 		}
@@ -112,6 +108,23 @@ func (client dockerClient) createListFilter() filters.Args {
 	return filterArgs
 }
 
+func (client dockerClient) GetContainer(containerID string) (Container, error) {
+	bg := context.Background()
+
+	containerInfo, err := client.api.ContainerInspect(bg, containerID)
+	if err != nil {
+		return Container{}, err
+	}
+
+	imageInfo, _, err := client.api.ImageInspectWithRaw(bg, containerInfo.Image)
+	if err != nil {
+		return Container{}, err
+	}
+
+	container := Container{containerInfo: &containerInfo, imageInfo: &imageInfo}
+	return container, nil
+}
+
 func (client dockerClient) StopContainer(c Container, timeout time.Duration) error {
 	bg := context.Background()
 	signal := c.StopSignal()
@@ -147,7 +160,7 @@ func (client dockerClient) StopContainer(c Container, timeout time.Duration) err
 	return nil
 }
 
-func (client dockerClient) StartContainer(c Container) error {
+func (client dockerClient) StartContainer(c Container) (string, error) {
 	bg := context.Background()
 	config := c.runtimeConfig()
 	hostConfig := c.hostConfig()
@@ -167,40 +180,40 @@ func (client dockerClient) StartContainer(c Container) error {
 	name := c.Name()
 
 	log.Infof("Creating %s", name)
-	creation, err := client.api.ContainerCreate(bg, config, hostConfig, simpleNetworkConfig, name)
+	createdContainer, err := client.api.ContainerCreate(bg, config, hostConfig, simpleNetworkConfig, name)
 	if err != nil {
-		return err
+		return "", err
 	}
 
 	if !(hostConfig.NetworkMode.IsHost()) {
 
 		for k := range simpleNetworkConfig.EndpointsConfig {
-			err = client.api.NetworkDisconnect(bg, k, creation.ID, true)
+			err = client.api.NetworkDisconnect(bg, k, createdContainer.ID, true)
 			if err != nil {
-				return err
+				return "", err
 			}
 		}
 
 		for k, v := range networkConfig.EndpointsConfig {
-			err = client.api.NetworkConnect(bg, k, creation.ID, v)
+			err = client.api.NetworkConnect(bg, k, createdContainer.ID, v)
 			if err != nil {
-				return err
+				return "", err
 			}
 		}
 
 	}
 
-	return client.startContainerIfPreviouslyRunning(bg, c, creation)
+	if !c.IsRunning() {
+		return createdContainer.ID, nil
+	}
+
+	return createdContainer.ID, client.doStartContainer(bg, c, createdContainer)
 
 }
 
-func (client dockerClient) startContainerIfPreviouslyRunning(bg context.Context, c Container, creation container.ContainerCreateCreatedBody) error {
+func (client dockerClient) doStartContainer(bg context.Context, c Container, creation container.ContainerCreateCreatedBody) error {
 	name := c.Name()
 
-	if !c.IsRunning() {
-		return nil
-	}
-
 	log.Debugf("Starting container %s (%s)", name, creation.ID)
 	err := client.api.ContainerStart(bg, creation.ID, types.ContainerStartOptions{})
 	if err != nil {
@@ -271,6 +284,67 @@ func (client dockerClient) RemoveImage(c Container) error {
 	return err
 }
 
+func (client dockerClient) ExecuteCommand(containerID string, command string) error {
+	bg := context.Background()
+
+	// Create the exec
+	execConfig := types.ExecConfig{
+		Tty:    true,
+		Detach: false,
+		Cmd:    []string{"sh", "-c", command},
+	}
+
+	exec, err := client.api.ContainerExecCreate(bg, containerID, execConfig)
+	if err != nil {
+		return err
+	}
+
+	response, attachErr := client.api.ContainerExecAttach(bg, exec.ID, types.ExecStartCheck{
+		Tty:    true,
+		Detach: false,
+	})
+	if attachErr != nil {
+		log.Errorf("Failed to extract command exec logs: %v", attachErr)
+	}
+
+	// Run the exec
+	execStartCheck := types.ExecStartCheck{Detach: false, Tty: true}
+	err = client.api.ContainerExecStart(bg, exec.ID, execStartCheck)
+	if err != nil {
+		return err
+	}
+
+	var execOutput string
+	if attachErr == nil {
+		defer response.Close()
+		var writer bytes.Buffer
+		written, err := writer.ReadFrom(response.Reader)
+		if err != nil {
+			log.Error(err)
+		} else if written > 0 {
+			execOutput = strings.TrimSpace(writer.String())
+		}
+	}
+
+	// Inspect the exec to get the exit code and print a message if the
+	// exit code is not success.
+	execInspect, err := client.api.ContainerExecInspect(bg, exec.ID)
+	if err != nil {
+		return err
+	}
+
+	if execInspect.ExitCode > 0 {
+		log.Errorf("Command exited with code %v.", execInspect.ExitCode)
+		log.Error(execOutput)
+	} else {
+		if len(execOutput) > 0 {
+			log.Infof("Command output:\n%v", execOutput)
+		}
+	}
+
+	return nil
+}
+
 func (client dockerClient) waitForStopOrTimeout(c Container, waitTime time.Duration) error {
 	bg := context.Background()
 	timeout := time.After(waitTime)
diff --git a/pkg/container/container.go b/pkg/container/container.go
index 14b0e86..09e4225 100644
--- a/pkg/container/container.go
+++ b/pkg/container/container.go
@@ -10,13 +10,6 @@ import (
 	dockercontainer "github.com/docker/docker/api/types/container"
 )
 
-const (
-	watchtowerLabel = "com.centurylinklabs.watchtower"
-	signalLabel     = "com.centurylinklabs.watchtower.stop-signal"
-	enableLabel     = "com.centurylinklabs.watchtower.enable"
-	zodiacLabel     = "com.centurylinklabs.zodiac.original-image"
-)
-
 // NewContainer returns a new Container instance instantiated with the
 // specified ContainerInfo and ImageInfo structs.
 func NewContainer(containerInfo *types.ContainerJSON, imageInfo *types.ImageInspect) *Container {
@@ -28,7 +21,8 @@ func NewContainer(containerInfo *types.ContainerJSON, imageInfo *types.ImageInsp
 
 // Container represents a running Docker container.
 type Container struct {
-	Stale bool
+	Linked bool
+	Stale  bool
 
 	containerInfo *types.ContainerJSON
 	imageInfo     *types.ImageInspect
@@ -62,7 +56,7 @@ func (c Container) ImageID() string {
 // "latest" tag is assumed.
 func (c Container) ImageName() string {
 	// Compatibility w/ Zodiac deployments
-	imageName, ok := c.containerInfo.Config.Labels[zodiacLabel]
+	imageName, ok := c.getLabelValue(zodiacLabel)
 	if !ok {
 		imageName = c.containerInfo.Config.Image
 	}
@@ -77,7 +71,7 @@ func (c Container) ImageName() string {
 // Enabled returns the value of the container enabled label and if the label
 // was set.
 func (c Container) Enabled() (bool, bool) {
-	rawBool, ok := c.containerInfo.Config.Labels[enableLabel]
+	rawBool, ok := c.getLabelValue(enableLabel)
 	if !ok {
 		return false, false
 	}
@@ -105,6 +99,12 @@ func (c Container) Links() []string {
 	return links
 }
 
+// ToRestart return whether the container should be restarted, either because
+// is stale or linked to another stale container.
+func (c Container) ToRestart() bool {
+	return c.Stale || c.Linked
+}
+
 // IsWatchtower returns a boolean flag indicating whether or not the current
 // container is the watchtower container itself. The watchtower container is
 // identified by the presence of the "com.centurylinklabs.watchtower" label in
@@ -117,11 +117,7 @@ func (c Container) IsWatchtower() bool {
 // container's metadata. If the container has not specified a custom stop
 // signal, the empty string "" is returned.
 func (c Container) StopSignal() string {
-	if val, ok := c.containerInfo.Config.Labels[signalLabel]; ok {
-		return val
-	}
-
-	return ""
+	return c.getLabelValueOrEmpty(signalLabel)
 }
 
 // Ideally, we'd just be able to take the ContainerConfig from the old container
@@ -189,10 +185,3 @@ func (c Container) hostConfig() *dockercontainer.HostConfig {
 
 	return hostConfig
 }
-
-// ContainsWatchtowerLabel takes a map of labels and values and tells
-// the consumer whether it contains a valid watchtower instance label
-func ContainsWatchtowerLabel(labels map[string]string) bool {
-	val, ok := labels[watchtowerLabel]
-	return ok && val == "true"
-}
diff --git a/pkg/container/metadata.go b/pkg/container/metadata.go
new file mode 100644
index 0000000..3ab9ec2
--- /dev/null
+++ b/pkg/container/metadata.go
@@ -0,0 +1,39 @@
+package container
+
+const (
+	watchtowerLabel = "com.centurylinklabs.watchtower"
+	signalLabel     = "com.centurylinklabs.watchtower.stop-signal"
+	enableLabel     = "com.centurylinklabs.watchtower.enable"
+	zodiacLabel     = "com.centurylinklabs.zodiac.original-image"
+	preUpdateLabel  = "com.centurylinklabs.watchtower.lifecycle.pre-update"
+	postUpdateLabel = "com.centurylinklabs.watchtower.lifecycle.post-update"
+)
+
+// GetLifecyclePreUpdateCommand returns the pre-update command set in the container metadata or an empty string
+func (c Container) GetLifecyclePreUpdateCommand() string {
+	return c.getLabelValueOrEmpty(preUpdateLabel)
+}
+
+// GetLifecyclePostUpdateCommand returns the post-update command set in the container metadata or an empty string
+func (c Container) GetLifecyclePostUpdateCommand() string {
+	return c.getLabelValueOrEmpty(postUpdateLabel)
+}
+
+// ContainsWatchtowerLabel takes a map of labels and values and tells
+// the consumer whether it contains a valid watchtower instance label
+func ContainsWatchtowerLabel(labels map[string]string) bool {
+	val, ok := labels[watchtowerLabel]
+	return ok && val == "true"
+}
+
+func (c Container) getLabelValueOrEmpty(label string) string {
+	if val, ok := c.containerInfo.Config.Labels[label]; ok {
+		return val
+	}
+	return ""
+}
+
+func (c Container) getLabelValue(label string) (string, bool) {
+	val, ok := c.containerInfo.Config.Labels[label]
+	return val, ok
+}


Test patch:
diff --git a/internal/actions/actions_suite_test.go b/internal/actions/actions_suite_test.go
index 031f54c..76d2be5 100644
--- a/internal/actions/actions_suite_test.go
+++ b/internal/actions/actions_suite_test.go
@@ -160,7 +160,7 @@ func (client mockClient) StopContainer(c container.Container, d time.Duration) e
 	}
 	return nil
 }
-func (client mockClient) StartContainer(c container.Container) error {
+func (client mockClient) StartContainer(c container.Container) (string, error) {
 	panic("Not implemented")
 }
 
@@ -173,6 +173,14 @@ func (client mockClient) RemoveImage(c container.Container) error {
 	return nil
 }
 
+func (client mockClient) GetContainer(containerID string) (container.Container, error) {
+	return container.Container{}, nil
+}
+
+func (client mockClient) ExecuteCommand(containerID string, command string) error {
+	return nil
+}
+
 func (client mockClient) IsContainerStale(c container.Container) (bool, error) {
 	panic("Not implemented")
 }
diff --git a/scripts/lifecycle-tests.sh b/scripts/lifecycle-tests.sh
new file mode 100755
index 0000000..dd41823
--- /dev/null
+++ b/scripts/lifecycle-tests.sh
@@ -0,0 +1,208 @@
+#!/usr/bin/env bash
+
+set -e
+
+IMAGE=server
+CONTAINER=server
+LINKED_IMAGE=linked
+LINKED_CONTAINER=linked
+WATCHTOWER_INTERVAL=2
+
+function remove_container {
+	docker kill $1 >> /dev/null || true && docker rm -v $1 >> /dev/null || true
+}
+
+function cleanup {
+  # Do cleanup on exit or error
+  echo "Final cleanup"
+  sleep 2
+  remove_container $CONTAINER
+  remove_container $LINKED_CONTAINER
+  pkill -9 -f watchtower >> /dev/null || true
+}
+trap cleanup EXIT
+
+DEFAULT_WATCHTOWER="$(dirname "${BASH_SOURCE[0]}")/../watchtower"
+WATCHTOWER=$1
+WATCHTOWER=${WATCHTOWER:-$DEFAULT_WATCHTOWER}
+echo "watchtower path is $WATCHTOWER"
+
+##################################################################################
+##### PREPARATION ################################################################
+##################################################################################
+
+#  Create Dockerfile template
+DOCKERFILE=$(cat << EOF
+FROM node:alpine
+
+LABEL com.centurylinklabs.watchtower.lifecycle.pre-update="cat /opt/test/value.txt"
+LABEL com.centurylinklabs.watchtower.lifecycle.post-update="echo image > /opt/test/value.txt"
+
+ENV IMAGE_TIMESTAMP=TIMESTAMP
+
+WORKDIR /opt/test
+ENTRYPOINT ["/usr/local/bin/node", "/opt/test/server.js"]
+
+EXPOSE 8888
+
+RUN mkdir -p /opt/test && echo "default" > /opt/test/value.txt
+COPY server.js /opt/test/server.js
+EOF
+)
+
+# Create temporary directory to build docker image
+TMP_DIR="/tmp/watchtower-commands-test"
+mkdir -p $TMP_DIR
+
+# Create simple http server
+cat > $TMP_DIR/server.js << EOF
+const http = require("http");
+const fs = require("fs");
+
+http.createServer(function(request, response) {
+	const fileContent = fs.readFileSync("/opt/test/value.txt");
+	response.writeHead(200, {"Content-Type": "text/plain"});
+	response.write(fileContent);
+	response.end();
+}).listen(8888, () => { console.log('server is listening on 8888'); });
+EOF
+
+function builddocker {
+	TIMESTAMP=$(date +%s)
+	echo "Building image $TIMESTAMP"
+	echo "${DOCKERFILE/TIMESTAMP/$TIMESTAMP}" > $TMP_DIR/Dockerfile
+	docker build $TMP_DIR -t $IMAGE >> /dev/null
+}
+
+# Start watchtower
+echo "Starting watchtower"
+$WATCHTOWER -i $WATCHTOWER_INTERVAL --no-pull --stop-timeout 2s --enable-lifecycle-hooks $CONTAINER $LINKED_CONTAINER &
+sleep 3
+
+echo "#################################################################"
+echo "##### TEST CASE 1: Execute commands from base image"
+echo "#################################################################"
+
+# Build base image
+builddocker
+
+# Run container
+docker run -d -p 0.0.0.0:8888:8888 --name $CONTAINER $IMAGE:latest >> /dev/null
+sleep 1
+echo "Container $CONTAINER is runnning"
+
+# Test default value
+RESP=$(curl -s http://localhost:8888)
+if [ $RESP != "default" ]; then
+	echo "Default value of container response is invalid" 1>&2
+	exit 1
+fi
+
+# Build updated image to trigger watchtower update
+builddocker
+
+WAIT_AMOUNT=$(($WATCHTOWER_INTERVAL * 3))
+echo "Wait for $WAIT_AMOUNT seconds"
+sleep $WAIT_AMOUNT
+
+# Test value after post-update-command
+RESP=$(curl -s http://localhost:8888)
+if [[ $RESP != "image" ]]; then
+	echo "Value of container response is invalid. Expected: image. Actual: $RESP"
+	exit 1
+fi
+
+remove_container $CONTAINER
+
+echo "#################################################################"
+echo "##### TEST CASE 2: Execute commands from container and base image"
+echo "#################################################################"
+
+# Build base image
+builddocker
+
+# Run container
+docker run -d -p 0.0.0.0:8888:8888 \
+	--label=com.centurylinklabs.watchtower.lifecycle.post-update="echo container > /opt/test/value.txt" \
+	--name $CONTAINER $IMAGE:latest >> /dev/null
+sleep 1
+echo "Container $CONTAINER is runnning"
+
+# Test default value
+RESP=$(curl -s http://localhost:8888)
+if [ $RESP != "default" ]; then
+	echo "Default value of container response is invalid" 1>&2
+	exit 1
+fi
+
+# Build updated image to trigger watchtower update
+builddocker
+
+WAIT_AMOUNT=$(($WATCHTOWER_INTERVAL * 3))
+echo "Wait for $WAIT_AMOUNT seconds"
+sleep $WAIT_AMOUNT
+
+# Test value after post-update-command
+RESP=$(curl -s http://localhost:8888)
+if [[ $RESP != "container" ]]; then
+	echo "Value of container response is invalid. Expected: container. Actual: $RESP"
+	exit 1
+fi
+
+remove_container $CONTAINER
+
+echo "#################################################################"
+echo "##### TEST CASE 3: Execute commands with a linked container"
+echo "#################################################################"
+
+# Tag the current image to keep a version for the linked container
+docker tag $IMAGE:latest $LINKED_IMAGE:latest
+
+# Build base image
+builddocker
+
+# Run container
+docker run -d -p 0.0.0.0:8888:8888 \
+	--label=com.centurylinklabs.watchtower.lifecycle.post-update="echo container > /opt/test/value.txt" \
+	--name $CONTAINER $IMAGE:latest >> /dev/null
+docker run -d -p 0.0.0.0:8989:8888 \
+	--label=com.centurylinklabs.watchtower.lifecycle.post-update="echo container > /opt/test/value.txt" \
+	--link $CONTAINER \
+	--name $LINKED_CONTAINER $LINKED_IMAGE:latest >> /dev/null
+sleep 1
+echo "Container $CONTAINER and $LINKED_CONTAINER are runnning"
+
+# Test default value
+RESP=$(curl -s http://localhost:8888)
+if [ $RESP != "default" ]; then
+	echo "Default value of container response is invalid" 1>&2
+	exit 1
+fi
+
+# Test default value for linked container
+RESP=$(curl -s http://localhost:8989)
+if [ $RESP != "default" ]; then
+	echo "Default value of linked container response is invalid" 1>&2
+	exit 1
+fi
+
+# Build updated image to trigger watchtower update
+builddocker
+
+WAIT_AMOUNT=$(($WATCHTOWER_INTERVAL * 3))
+echo "Wait for $WAIT_AMOUNT seconds"
+sleep $WAIT_AMOUNT
+
+# Test value after post-update-command
+RESP=$(curl -s http://localhost:8888)
+if [[ $RESP != "container" ]]; then
+	echo "Value of container response is invalid. Expected: container. Actual: $RESP"
+	exit 1
+fi
+
+# Test that linked container did not execute pre/post-update-command
+RESP=$(curl -s http://localhost:8989)
+if [[ $RESP != "default" ]]; then
+	echo "Value of linked container response is invalid. Expected: default. Actual: $RESP"
+	exit 1
+fi
\ No newline at end of file