From e584f8bfcfd1be713afe7c1bbc52f902d2163900 Mon Sep 17 00:00:00 2001 From: Simon Aronsson Date: Sun, 12 May 2019 09:29:52 +0200 Subject: [PATCH] Make it possible to use watchtower to update exited or created containers as well (#289) * feature/112: add additional tests that verify include-stopped * feature/112: implement include-stopped * feature/112: update readme and cli help * feature/112: fix linting issues * remove superfluous logging --- README.md | 1 + app/app.go | 10 +++- container/client.go | 69 +++++++++++++++++++++------- container/container.go | 7 +++ container/container_test.go | 33 +++++++++---- container/mocks/ApiServer.go | 7 ++- container/mocks/data/containers.json | 2 +- main.go | 5 +- 8 files changed, 101 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 2827bfe..59100e3 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,7 @@ docker run --rm containrrr/watchtower --help - `--tlsverify` Use TLS when connecting to the Docker socket and verify the server's certificate. - `--debug` Enable debug mode. When this option is specified you'll see more verbose logging in the watchtower log file. - `--monitor-only` Will only monitor for new images, not update the containers. +- `--include-stopped` Will also include created and exited containers. - `--help` Show documentation about the supported flags. See below for options used to configure notifications. diff --git a/app/app.go b/app/app.go index 5218d5d..6a20e4f 100644 --- a/app/app.go +++ b/app/app.go @@ -155,8 +155,14 @@ func SetupCliFlags(app *cli.App) { EnvVar: "WATCHTOWER_MONITOR_ONLY", }, cli.BoolFlag{ - Name: "run-once", - Usage: "Run once now and exit", + Name: "run-once", + Usage: "Run once now and exit", + EnvVar: "WATCHTOWER_RUN_ONCE", + }, + cli.BoolFlag{ + Name: "include-stopped", + Usage: "Will also include created and exited containers", + EnvVar: "WATCHTOWER_INCLUDE_STOPPED", }, } } diff --git a/container/client.go b/container/client.go index 94f790e..70a6fb1 100644 --- a/container/client.go +++ b/container/client.go @@ -2,6 +2,8 @@ package container import ( "fmt" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" "io/ioutil" "time" @@ -33,36 +35,48 @@ type Client interface { // * DOCKER_HOST the docker-engine host to send api requests to // * DOCKER_TLS_VERIFY whether to verify tls certificates // * DOCKER_API_VERSION the minimum docker api version to work with -func NewClient(pullImages bool) Client { +func NewClient(pullImages bool, includeStopped bool) Client { cli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv) if err != nil { log.Fatalf("Error instantiating Docker client: %s", err) } - return dockerClient{api: cli, pullImages: pullImages} + return dockerClient{ + api: cli, + pullImages: pullImages, + includeStopped: includeStopped, + } } type dockerClient struct { - api dockerclient.CommonAPIClient - pullImages bool + api dockerclient.CommonAPIClient + pullImages bool + includeStopped bool } func (client dockerClient) ListContainers(fn Filter) ([]Container, error) { cs := []Container{} bg := context.Background() - log.Debug("Retrieving running containers") + if client.includeStopped { + log.Debug("Retrieving containers including stopped and exited") + } else { + log.Debug("Retrieving running containers") + } - runningContainers, err := client.api.ContainerList( + filter := client.createListFilter() + containers, err := client.api.ContainerList( bg, - types.ContainerListOptions{}) - + types.ContainerListOptions{ + Filters: filter, + }) + if err != nil { return nil, err } - for _, runningContainer := range runningContainers { + for _, runningContainer := range containers { containerInfo, err := client.api.ContainerInspect(bg, runningContainer.ID) if err != nil { return nil, err @@ -83,6 +97,18 @@ func (client dockerClient) ListContainers(fn Filter) ([]Container, error) { return cs, nil } +func (client dockerClient) createListFilter() filters.Args { + filterArgs := filters.NewArgs() + filterArgs.Add("status", "running") + + if client.includeStopped { + filterArgs.Add("status", "created") + filterArgs.Add("status", "exited") + } + + return filterArgs +} + func (client dockerClient) StopContainer(c Container, timeout time.Duration) error { bg := context.Background() signal := c.StopSignal() @@ -90,10 +116,11 @@ func (client dockerClient) StopContainer(c Container, timeout time.Duration) err signal = defaultStopSignal } - log.Infof("Stopping %s (%s) with %s", c.Name(), c.ID(), signal) - - if err := client.api.ContainerKill(bg, c.ID(), signal); err != nil { - return err + if c.IsRunning() { + log.Infof("Stopping %s (%s) with %s", c.Name(), c.ID(), signal) + if err := client.api.ContainerKill(bg, c.ID(), signal); err != nil { + return err + } } // Wait for container to exit, but proceed anyway after the timeout elapses @@ -160,15 +187,23 @@ func (client dockerClient) StartContainer(c Container) error { } - log.Debugf("Starting container %s (%s)", name, creation.ID) + return client.startContainerIfPreviouslyRunning(bg, c, creation) + +} + +func (client dockerClient) startContainerIfPreviouslyRunning(bg context.Context, c Container, creation container.ContainerCreateCreatedBody) error { + name := c.Name() - err = client.api.ContainerStart(bg, creation.ID, types.ContainerStartOptions{}) + 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 { return err } - return nil - } func (client dockerClient) RenameContainer(c Container, newName string) error { diff --git a/container/container.go b/container/container.go index b32d4aa..66ae505 100644 --- a/container/container.go +++ b/container/container.go @@ -38,6 +38,13 @@ func (c Container) ID() string { return c.containerInfo.ID } +// IsRunning returns a boolean flag indicating whether or not the current +// container is running. The status is determined by the value of the +// container's "State.Running" property. +func (c Container) IsRunning() bool { + return c.containerInfo.State.Running +} + // Name returns the Docker container name. func (c Container) Name() string { return c.containerInfo.Name diff --git a/container/container_test.go b/container/container_test.go index 2543976..f9dd540 100644 --- a/container/container_test.go +++ b/container/container_test.go @@ -17,15 +17,16 @@ func TestContainer(t *testing.T) { var _ = Describe("the container", func() { Describe("the client", func() { + var docker *cli.Client var client Client BeforeSuite(func() { server := mocks.NewMockAPIServer() - c, _ := cli.NewClientWithOpts( + docker, _ = cli.NewClientWithOpts( cli.WithHost(server.URL), cli.WithHTTPClient(server.Client(), )) client = dockerClient{ - api: c, + api: docker, pullImages: false, } }) @@ -41,7 +42,7 @@ var _ = Describe("the container", func() { }) When("listing containers with a filter matching nothing", func() { It("should return an empty array", func() { - filter := filterByNames([]string { "lollercoaster"}, noFilter) + filter := filterByNames([]string{"lollercoaster"}, noFilter) containers, err := client.ListContainers(filter) Expect(err).NotTo(HaveOccurred()) Expect(len(containers) == 0).To(BeTrue()) @@ -55,13 +56,25 @@ var _ = Describe("the container", func() { Expect(containers[0].ImageName()).To(Equal("containrrr/watchtower:latest")) }) }) + When(`listing containers with the "include stopped" option`, func() { + It("should return both stopped and running containers", func() { + client = dockerClient{ + api: docker, + pullImages: false, + includeStopped: true, + } + containers, err := client.ListContainers(noFilter) + Expect(err).NotTo(HaveOccurred()) + Expect(len(containers) > 0).To(BeTrue()) + }) + }) }) When("asked for metadata", func() { var c *Container BeforeEach(func() { - c = mockContainerWithLabels(map[string]string { + c = mockContainerWithLabels(map[string]string{ "com.centurylinklabs.watchtower.enable": "true", - "com.centurylinklabs.watchtower": "true", + "com.centurylinklabs.watchtower": "true", }) }) It("should return its name on calls to .Name()", func() { @@ -84,7 +97,7 @@ var _ = Describe("the container", func() { Expect(exists).NotTo(BeFalse()) }) It("should return false, true if present but not true on calls to .Enabled()", func() { - c = mockContainerWithLabels(map[string]string{ "com.centurylinklabs.watchtower.enable": "false" }) + c = mockContainerWithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "false"}) enabled, exists := c.Enabled() Expect(enabled).To(BeFalse()) @@ -93,7 +106,7 @@ var _ = Describe("the container", func() { Expect(exists).NotTo(BeFalse()) }) It("should return false, false if not present on calls to .Enabled()", func() { - c = mockContainerWithLabels(map[string]string{ "lol": "false" }) + c = mockContainerWithLabels(map[string]string{"lol": "false"}) enabled, exists := c.Enabled() Expect(enabled).To(BeFalse()) @@ -102,7 +115,7 @@ var _ = Describe("the container", func() { Expect(exists).NotTo(BeTrue()) }) It("should return false, false if present but not parsable .Enabled()", func() { - c = mockContainerWithLabels(map[string]string{ "com.centurylinklabs.watchtower.enable": "falsy" }) + c = mockContainerWithLabels(map[string]string{"com.centurylinklabs.watchtower.enable": "falsy"}) enabled, exists := c.Enabled() Expect(enabled).To(BeFalse()) @@ -116,12 +129,12 @@ var _ = Describe("the container", func() { Expect(isWatchtower).To(BeTrue()) }) It("should return false if the label is present but set to false", func() { - c = mockContainerWithLabels(map[string]string{ "com.centurylinklabs.watchtower": "false" }) + c = mockContainerWithLabels(map[string]string{"com.centurylinklabs.watchtower": "false"}) isWatchtower := c.IsWatchtower() Expect(isWatchtower).To(BeFalse()) }) It("should return false if the label is not present", func() { - c = mockContainerWithLabels(map[string]string{ "funny.label": "false" }) + c = mockContainerWithLabels(map[string]string{"funny.label": "false"}) isWatchtower := c.IsWatchtower() Expect(isWatchtower).To(BeFalse()) }) diff --git a/container/mocks/ApiServer.go b/container/mocks/ApiServer.go index 79290a2..82e05de 100644 --- a/container/mocks/ApiServer.go +++ b/container/mocks/ApiServer.go @@ -18,7 +18,11 @@ func NewMockAPIServer() *httptest.Server { logrus.Debug("Mock server has received a HTTP call on ", r.URL) var response = "" - if isRequestFor("containers/json?limit=0", r) { + if isRequestFor("filters=%7B%22status%22%3A%7B%22running%22%3Atrue%7D%7D&limit=0", r) { + response = getMockJSONFromDisk("./mocks/data/containers.json") + } else if isRequestFor("filters=%7B%22status%22%3A%7B%22created%22%3Atrue%2C%22exited%22%3Atrue%2C%22running%22%3Atrue%7D%7D&limit=0", r) { + response = getMockJSONFromDisk("./mocks/data/containers.json") + } else if isRequestFor("containers/json?limit=0", r) { response = getMockJSONFromDisk("./mocks/data/containers.json") } else if isRequestFor("ae8964ba86c7cd7522cf84e09781343d88e0e3543281c747d88b27e246578b65", r) { response = getMockJSONFromDisk("./mocks/data/container_stopped.json") @@ -48,4 +52,3 @@ func getMockJSONFromDisk(relPath string) string { } return string(buf) } - diff --git a/container/mocks/data/containers.json b/container/mocks/data/containers.json index a40cbf3..e2507bf 100644 --- a/container/mocks/data/containers.json +++ b/container/mocks/data/containers.json @@ -12,7 +12,7 @@ "Labels": { "com.centurylinklabs.watchtower": "true" }, - "State": "exited", + "State": "running", "Status": "Exited (1) 6 days ago", "HostConfig": { "NetworkMode": "default" diff --git a/main.go b/main.go index a9b35c7..faf35b6 100644 --- a/main.go +++ b/main.go @@ -89,7 +89,10 @@ func before(c *cli.Context) error { return err } - client = container.NewClient(!c.GlobalBool("no-pull")) + client = container.NewClient( + !c.GlobalBool("no-pull"), + c.GlobalBool("include-stopped"), + ) notifier = notifications.NewNotifier(c) return nil