Repository: containrrr/watchtower
Pull request #908 (2021-04-17 18:58:39)

fix depends on behavior and simplify some of its logic
The logic for updating containers in order based on their dependencies had some problems that have been accumulated from various changes, where the full logic wasn't greatly understood.
This PR solves two main reasons why updating linked containers have not been working correctly. It also cleans up the logic and adds more descriptive names to prevent this to happen again.

One scenario that just doesn't make sense is the combination of `rolling-updates` and linked containers, they are now explicitly made mutually exclusive as using them together just causes undefined behaviour,
There could be some additional logic added to do a semi-rolling update instead (which is probably what anyone uses these together would expect to happen), but since that logic is not in place, using them together causes a fatal error for now.

Fatal errors should now also correctly be sent through notifications, since watchtower now waits for pending notifications to be sent before exiting.

codecov[bot]: # [Codecov](https://codecov.io/gh/containrrr/watchtower/pull/908?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr) Report
> :exclamation: No coverage uploaded for pull request base (`main@3b60afe`). [Click here to learn what that means](https://docs.codecov.io/docs/error-reference?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr#section-missing-base-commit).
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/containrrr/watchtower/pull/908/graphs/tree.svg?width=650&height=150&src=pr&token=8pxWgB380Y&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr)](https://codecov.io/gh/containrrr/watchtower/pull/908?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr)

```diff
@@           Coverage Diff           @@
##             main     #908   +/-   ##
=======================================
  Coverage        ?   42.57%           
=======================================
  Files           ?       24           
  Lines           ?     1388           
  Branches        ?        0           
=======================================
  Hits            ?      591           
  Misses          ?      736           
  Partials        ?       61           
```



------

[Continue to review full report at Codecov](https://codecov.io/gh/containrrr/watchtower/pull/908?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/containrrr/watchtower/pull/908?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr). Last update [3b60afe...ffc7bff](https://codecov.io/gh/containrrr/watchtower/pull/908?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr).


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/cmd/root.go b/cmd/root.go
index 3a597d0..df14ab6 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -154,9 +154,18 @@ func Run(c *cobra.Command, names []string) {
 	runOnce, _ := c.PersistentFlags().GetBool("run-once")
 	enableUpdateAPI, _ := c.PersistentFlags().GetBool("http-api-update")
 	enableMetricsAPI, _ := c.PersistentFlags().GetBool("http-api-metrics")
-
 	apiToken, _ := c.PersistentFlags().GetString("http-api-token")
 
+	if rollingRestart && monitorOnly {
+		log.Fatal("Rolling restarts is not compatible with the global monitor only flag")
+	}
+
+	awaitDockerClient()
+
+	if err := actions.CheckForSanity(client, filter, rollingRestart); err != nil {
+		logNotifyExit(err)
+	}
+
 	if runOnce {
 		writeStartupMessage(c, time.Time{}, filterDesc)
 		runUpdatesWithNotifications(filter)
@@ -166,7 +175,7 @@ func Run(c *cobra.Command, names []string) {
 	}
 
 	if err := actions.CheckForMultipleWatchtowerInstances(client, cleanup, scope); err != nil {
-		log.Fatal(err)
+		logNotifyExit(err)
 	}
 
 	httpAPI := api.New(apiToken)
@@ -192,6 +201,17 @@ func Run(c *cobra.Command, names []string) {
 	os.Exit(1)
 }
 
+func logNotifyExit(err error) {
+	log.Error(err)
+	notifier.Close()
+	os.Exit(1)
+}
+
+func awaitDockerClient() {
+	log.Debug("Sleeping for a second to ensure the docker api client has been properly initialized.")
+	time.Sleep(1 * time.Second)
+}
+
 func formatDuration(d time.Duration) string {
 	sb := strings.Builder{}
 
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/internal/actions/check.go b/internal/actions/check.go
index 87133fc..436931f 100644
--- a/internal/actions/check.go
+++ b/internal/actions/check.go
@@ -2,28 +2,47 @@ package actions
 
 import (
 	"fmt"
+	"github.com/containrrr/watchtower/pkg/types"
 	"sort"
 	"time"
 
 	"github.com/containrrr/watchtower/pkg/filters"
 	"github.com/containrrr/watchtower/pkg/sorter"
-	"github.com/sirupsen/logrus"
 
 	log "github.com/sirupsen/logrus"
 
 	"github.com/containrrr/watchtower/pkg/container"
 )
 
+// CheckForSanity makes sure everything is sane before starting
+func CheckForSanity(client container.Client, filter types.Filter, rollingRestarts bool) error {
+	log.Debug("Making sure everything is sane before starting")
+
+	if rollingRestarts {
+		containers, err := client.ListContainers(filter)
+		if err != nil {
+			return err
+		}
+		for _, c := range containers {
+			if len(c.Links()) > 0 {
+				return fmt.Errorf(
+					"%q is depending on at least one other container. This is not compatible with rolling restarts",
+					c.Name(),
+				)
+			}
+		}
+	}
+	return nil
+}
+
 // CheckForMultipleWatchtowerInstances will ensure that there are not multiple instances of the
 // watchtower running simultaneously. If multiple watchtower containers are detected, this function
 // will stop and remove all but the most recently started container. This behaviour can be bypassed
 // if a scope UID is defined.
 func CheckForMultipleWatchtowerInstances(client container.Client, cleanup bool, scope string) error {
-	awaitDockerClient()
 	containers, err := client.ListContainers(filters.FilterByScope(scope, filters.WatchtowerContainersFilter))
 
 	if err != nil {
-		log.Fatal(err)
 		return err
 	}
 
@@ -45,14 +64,14 @@ func cleanupExcessWatchtowers(containers []container.Container, client container
 	for _, c := range allContainersExceptLast {
 		if err := client.StopContainer(c, 10*time.Minute); err != nil {
 			// logging the original here as we're just returning a count
-			logrus.WithError(err).Error("Could not stop a previous watchtower instance.")
+			log.WithError(err).Error("Could not stop a previous watchtower instance.")
 			stopErrors++
 			continue
 		}
 
 		if cleanup {
 			if err := client.RemoveImageByID(c.ImageID()); err != nil {
-				logrus.WithError(err).Warning("Could not cleanup watchtower images, possibly because of other watchtowers instances in other scopes.")
+				log.WithError(err).Warning("Could not cleanup watchtower images, possibly because of other watchtowers instances in other scopes.")
 			}
 		}
 	}
@@ -63,8 +82,3 @@ func cleanupExcessWatchtowers(containers []container.Container, client container
 
 	return nil
 }
-
-func awaitDockerClient() {
-	log.Debug("Sleeping for a second to ensure the docker api client has been properly initialized.")
-	time.Sleep(1 * time.Second)
-}
diff --git a/internal/actions/update.go b/internal/actions/update.go
index 9320d6a..06bb345 100644
--- a/internal/actions/update.go
+++ b/internal/actions/update.go
@@ -50,6 +50,7 @@ func Update(client container.Client, params types.UpdateParams) (*metrics2.Metri
 	}
 
 	containers, err = sorter.SortByDependencies(containers)
+
 	metric.Scanned = len(containers)
 	if err != nil {
 		return nil, err
@@ -57,11 +58,11 @@ func Update(client container.Client, params types.UpdateParams) (*metrics2.Metri
 
 	checkDependencies(containers)
 
-	containersToUpdate := []container.Container{}
+	var containersToUpdate []container.Container
 	if !params.MonitorOnly {
-		for i := len(containers) - 1; i >= 0; i-- {
-			if !containers[i].IsMonitorOnly() {
-				containersToUpdate = append(containersToUpdate, containers[i])
+		for _, c := range containers {
+			if !c.IsMonitorOnly() {
+				containersToUpdate = append(containersToUpdate, c)
 			}
 		}
 	}
@@ -86,7 +87,7 @@ func performRollingRestart(containers []container.Container, client container.Cl
 	failed := 0
 
 	for i := len(containers) - 1; i >= 0; i-- {
-		if containers[i].Stale {
+		if containers[i].ToRestart() {
 			if err := stopStaleContainer(containers[i], client, params); err != nil {
 				failed++
 			}
@@ -119,7 +120,7 @@ func stopStaleContainer(container container.Container, client container.Client,
 		return nil
 	}
 
-	if !container.Stale {
+	if !container.ToRestart() {
 		return nil
 	}
 	if params.LifecycleHooks {
@@ -143,7 +144,7 @@ func restartContainersInSortedOrder(containers []container.Container, client con
 	failed := 0
 
 	for _, c := range containers {
-		if !c.Stale {
+		if !c.ToRestart() {
 			continue
 		}
 		if err := restartStaleContainer(c, client, params); err != nil {
@@ -183,7 +184,7 @@ func restartStaleContainer(container container.Container, client container.Clien
 		if newContainerID, err := client.StartContainer(container); err != nil {
 			log.Error(err)
 			return err
-		} else if container.Stale && params.LifecycleHooks {
+		} else if container.ToRestart() && params.LifecycleHooks {
 			lifecycle.ExecutePostUpdateCommand(client, newContainerID)
 		}
 	}
@@ -192,16 +193,19 @@ func restartStaleContainer(container container.Container, client container.Clien
 
 func checkDependencies(containers []container.Container) {
 
-	for i, parent := range containers {
-		if parent.ToRestart() {
+	for _, c := range containers {
+		if c.ToRestart() {
 			continue
 		}
 
 	LinkLoop:
-		for _, linkName := range parent.Links() {
-			for _, child := range containers {
-				if child.Name() == linkName && child.ToRestart() {
-					containers[i].Linked = true
+		for _, linkName := range c.Links() {
+			for _, candidate := range containers {
+				if candidate.Name() != linkName {
+					continue
+				}
+				if candidate.ToRestart() {
+					c.LinkedToRestarting = true
 					break LinkLoop
 				}
 			}
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/container.go b/pkg/container/container.go
index 8a9d39e..7631b5e 100644
--- a/pkg/container/container.go
+++ b/pkg/container/container.go
@@ -22,8 +22,8 @@ func NewContainer(containerInfo *types.ContainerJSON, imageInfo *types.ImageInsp
 
 // Container represents a running Docker container.
 type Container struct {
-	Linked bool
-	Stale  bool
+	LinkedToRestarting bool
+	Stale              bool
 
 	containerInfo *types.ContainerJSON
 	imageInfo     *types.ImageInspect
@@ -142,7 +142,7 @@ func (c Container) Links() []string {
 // 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
+	return c.Stale || c.LinkedToRestarting
 }
 
 // IsWatchtower returns a boolean flag indicating whether or not the current
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
 
diff --git a/scripts/dependency-test.sh b/scripts/dependency-test.sh
new file mode 100755
index 0000000..0da0110
--- /dev/null
+++ b/scripts/dependency-test.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+
+# Simulates a container that will always be updated, checking whether it shuts down it's dependencies correctly.
+
+docker rm -f parent || true
+docker rm -f depending || true
+
+CHANGE=redis:latest
+KEEP=tutum/hello-world
+
+docker tag tutum/hello-world:latest redis:latest
+
+docker run -d --name parent $CHANGE
+docker run -d --name depending --link parent $KEEP
+
+go run . --run-once --debug $@