diff --git a/docker/client.go b/docker/client.go new file mode 100644 index 0000000..2b3082f --- /dev/null +++ b/docker/client.go @@ -0,0 +1,116 @@ +package docker + +import ( + "log" + + "github.com/samalba/dockerclient" +) + +var ( + pullImages bool +) + +func init() { + pullImages = true +} + +type Client interface { + ListContainers() ([]Container, error) + RefreshImage(container *Container) error + Stop(container Container) error + Start(container Container) error +} + +func NewClient() Client { + docker, err := dockerclient.NewDockerClient("unix:///var/run/docker.sock", nil) + + if err != nil { + log.Fatalf("Error instantiating Docker client: %s\n", err) + } + + return DockerClient{api: docker} +} + +type DockerClient struct { + api dockerclient.Client +} + +func (client DockerClient) ListContainers() ([]Container, error) { + cs := []Container{} + + runningContainers, err := client.api.ListContainers(false, false, "") + if err != nil { + return nil, err + } + + for _, runningContainer := range runningContainers { + containerInfo, err := client.api.InspectContainer(runningContainer.Id) + if err != nil { + return nil, err + } + + imageInfo, err := client.api.InspectImage(containerInfo.Image) + if err != nil { + return nil, err + } + + cs = append(cs, Container{containerInfo: containerInfo, imageInfo: imageInfo}) + } + + return cs, nil +} + +func (client DockerClient) RefreshImage(c *Container) error { + containerInfo := c.containerInfo + oldImageInfo := c.imageInfo + imageName := containerInfo.Config.Image + + if pullImages { + log.Printf("Pulling %s for %s\n", imageName, c.Name()) + if err := client.api.PullImage(imageName, nil); err != nil { + return err + } + } + + newImageInfo, err := client.api.InspectImage(imageName) + if err != nil { + return err + } + + if newImageInfo.Id != oldImageInfo.Id { + log.Printf("Found new %s image (%s)\n", imageName, newImageInfo.Id) + c.Stale = true + } + + return nil +} + +func (client DockerClient) Stop(c Container) error { + signal := "SIGTERM" + + if sig, ok := c.containerInfo.Config.Labels["com.centurylinklabs.watchtower.stop-signal"]; ok { + signal = sig + } + + log.Printf("Stopping: %s\n", c.Name()) + + if err := client.api.KillContainer(c.containerInfo.Id, signal); err != nil { + return err + } + + return client.api.RemoveContainer(c.containerInfo.Id, true, false) +} + +func (client DockerClient) Start(c Container) error { + config := c.runtimeConfig() + hostConfig := c.hostConfig() + + log.Printf("Starting: %s\n", c.Name()) + + newContainerId, err := client.api.CreateContainer(config, c.Name()) + if err != nil { + return err + } + + return client.api.StartContainer(newContainerId, hostConfig) +} diff --git a/docker/client_test.go b/docker/client_test.go new file mode 100644 index 0000000..4b5775c --- /dev/null +++ b/docker/client_test.go @@ -0,0 +1,308 @@ +package docker + +import ( + "errors" + "testing" + + "github.com/samalba/dockerclient" + "github.com/samalba/dockerclient/mockclient" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestListContainers_Success(t *testing.T) { + ci := &dockerclient.ContainerInfo{Image: "abc123"} + ii := &dockerclient.ImageInfo{} + api := mockclient.NewMockClient() + api.On("ListContainers", false, false, "").Return([]dockerclient.Container{{Id: "foo"}}, nil) + api.On("InspectContainer", "foo").Return(ci, nil) + api.On("InspectImage", "abc123").Return(ii, nil) + + client := DockerClient{api: api} + cs, err := client.ListContainers() + + assert.NoError(t, err) + assert.Len(t, cs, 1) + assert.Equal(t, ci, cs[0].containerInfo) + assert.Equal(t, ii, cs[0].imageInfo) + api.AssertExpectations(t) +} + +func TestListContainers_ListError(t *testing.T) { + api := mockclient.NewMockClient() + api.On("ListContainers", false, false, "").Return([]dockerclient.Container{}, errors.New("oops")) + + client := DockerClient{api: api} + _, err := client.ListContainers() + + assert.Error(t, err) + assert.EqualError(t, err, "oops") + api.AssertExpectations(t) +} + +func TestListContainers_InspectContainerError(t *testing.T) { + api := mockclient.NewMockClient() + api.On("ListContainers", false, false, "").Return([]dockerclient.Container{{Id: "foo"}}, nil) + api.On("InspectContainer", "foo").Return(&dockerclient.ContainerInfo{}, errors.New("uh-oh")) + + client := DockerClient{api: api} + _, err := client.ListContainers() + + assert.Error(t, err) + assert.EqualError(t, err, "uh-oh") + api.AssertExpectations(t) +} + +func TestListContainers_InspectImageError(t *testing.T) { + ci := &dockerclient.ContainerInfo{Image: "abc123"} + ii := &dockerclient.ImageInfo{} + api := mockclient.NewMockClient() + api.On("ListContainers", false, false, "").Return([]dockerclient.Container{{Id: "foo"}}, nil) + api.On("InspectContainer", "foo").Return(ci, nil) + api.On("InspectImage", "abc123").Return(ii, errors.New("whoops")) + + client := DockerClient{api: api} + _, err := client.ListContainers() + + assert.Error(t, err) + assert.EqualError(t, err, "whoops") + api.AssertExpectations(t) +} + +func TestRefreshImage_NotStaleSuccess(t *testing.T) { + c := &Container{ + containerInfo: &dockerclient.ContainerInfo{ + Name: "foo", + Config: &dockerclient.ContainerConfig{Image: "bar"}, + }, + imageInfo: &dockerclient.ImageInfo{Id: "abc123"}, + } + newImageInfo := &dockerclient.ImageInfo{Id: "abc123"} + + api := mockclient.NewMockClient() + api.On("PullImage", "bar", mock.Anything).Return(nil) + api.On("InspectImage", "bar").Return(newImageInfo, nil) + + client := DockerClient{api: api} + err := client.RefreshImage(c) + + assert.NoError(t, err) + assert.False(t, c.Stale) + api.AssertExpectations(t) +} + +func TestRefreshImage_StaleSuccess(t *testing.T) { + c := &Container{ + containerInfo: &dockerclient.ContainerInfo{ + Name: "foo", + Config: &dockerclient.ContainerConfig{Image: "bar"}, + }, + imageInfo: &dockerclient.ImageInfo{Id: "abc123"}, + } + newImageInfo := &dockerclient.ImageInfo{Id: "xyz789"} + + api := mockclient.NewMockClient() + api.On("PullImage", "bar", mock.Anything).Return(nil) + api.On("InspectImage", "bar").Return(newImageInfo, nil) + + client := DockerClient{api: api} + err := client.RefreshImage(c) + + assert.NoError(t, err) + assert.True(t, c.Stale) + api.AssertExpectations(t) +} + +func TestRefreshImage_PullImageError(t *testing.T) { + c := &Container{ + containerInfo: &dockerclient.ContainerInfo{ + Name: "foo", + Config: &dockerclient.ContainerConfig{Image: "bar"}, + }, + imageInfo: &dockerclient.ImageInfo{Id: "abc123"}, + } + + api := mockclient.NewMockClient() + api.On("PullImage", "bar", mock.Anything).Return(errors.New("oops")) + + client := DockerClient{api: api} + err := client.RefreshImage(c) + + assert.Error(t, err) + assert.EqualError(t, err, "oops") + api.AssertExpectations(t) +} + +func TestRefreshImage_InspectImageError(t *testing.T) { + c := &Container{ + containerInfo: &dockerclient.ContainerInfo{ + Name: "foo", + Config: &dockerclient.ContainerConfig{Image: "bar"}, + }, + imageInfo: &dockerclient.ImageInfo{Id: "abc123"}, + } + newImageInfo := &dockerclient.ImageInfo{} + + api := mockclient.NewMockClient() + api.On("PullImage", "bar", mock.Anything).Return(nil) + api.On("InspectImage", "bar").Return(newImageInfo, errors.New("uh-oh")) + + client := DockerClient{api: api} + err := client.RefreshImage(c) + + assert.Error(t, err) + assert.EqualError(t, err, "uh-oh") + api.AssertExpectations(t) +} + +func TestStop_DefaultSuccess(t *testing.T) { + c := Container{ + containerInfo: &dockerclient.ContainerInfo{ + Name: "foo", + Id: "abc123", + Config: &dockerclient.ContainerConfig{}, + }, + } + + api := mockclient.NewMockClient() + api.On("KillContainer", "abc123", "SIGTERM").Return(nil) + api.On("RemoveContainer", "abc123", true, false).Return(nil) + + client := DockerClient{api: api} + err := client.Stop(c) + + assert.NoError(t, err) + api.AssertExpectations(t) +} + +func TestStop_CustomSignalSuccess(t *testing.T) { + c := Container{ + containerInfo: &dockerclient.ContainerInfo{ + Name: "foo", + Id: "abc123", + Config: &dockerclient.ContainerConfig{ + Labels: map[string]string{"com.centurylinklabs.watchtower.stop-signal": "SIGUSR1"}}, + }, + } + + api := mockclient.NewMockClient() + api.On("KillContainer", "abc123", "SIGUSR1").Return(nil) + api.On("RemoveContainer", "abc123", true, false).Return(nil) + + client := DockerClient{api: api} + err := client.Stop(c) + + assert.NoError(t, err) + api.AssertExpectations(t) +} + +func TestStop_KillContainerError(t *testing.T) { + c := Container{ + containerInfo: &dockerclient.ContainerInfo{ + Name: "foo", + Id: "abc123", + Config: &dockerclient.ContainerConfig{}, + }, + } + + api := mockclient.NewMockClient() + api.On("KillContainer", "abc123", "SIGTERM").Return(errors.New("oops")) + + client := DockerClient{api: api} + err := client.Stop(c) + + assert.Error(t, err) + assert.EqualError(t, err, "oops") + api.AssertExpectations(t) +} + +func TestStop_RemoveContainerError(t *testing.T) { + c := Container{ + containerInfo: &dockerclient.ContainerInfo{ + Name: "foo", + Id: "abc123", + Config: &dockerclient.ContainerConfig{}, + }, + } + + api := mockclient.NewMockClient() + api.On("KillContainer", "abc123", "SIGTERM").Return(nil) + api.On("RemoveContainer", "abc123", true, false).Return(errors.New("whoops")) + + client := DockerClient{api: api} + err := client.Stop(c) + + assert.Error(t, err) + assert.EqualError(t, err, "whoops") + api.AssertExpectations(t) +} + +func TestStart_Success(t *testing.T) { + c := Container{ + containerInfo: &dockerclient.ContainerInfo{ + Name: "foo", + Config: &dockerclient.ContainerConfig{}, + HostConfig: &dockerclient.HostConfig{}, + }, + imageInfo: &dockerclient.ImageInfo{ + Config: &dockerclient.ContainerConfig{}, + }, + } + + api := mockclient.NewMockClient() + api.On("CreateContainer", mock.AnythingOfType("*dockerclient.ContainerConfig"), "foo").Return("def789", nil) + api.On("StartContainer", "def789", mock.AnythingOfType("*dockerclient.HostConfig")).Return(nil) + + client := DockerClient{api: api} + err := client.Start(c) + + assert.NoError(t, err) + api.AssertExpectations(t) +} + +func TestStart_CreateContainerError(t *testing.T) { + c := Container{ + containerInfo: &dockerclient.ContainerInfo{ + Name: "foo", + Config: &dockerclient.ContainerConfig{}, + HostConfig: &dockerclient.HostConfig{}, + }, + imageInfo: &dockerclient.ImageInfo{ + Config: &dockerclient.ContainerConfig{}, + }, + } + + api := mockclient.NewMockClient() + api.On("CreateContainer", mock.Anything, "foo").Return("", errors.New("oops")) + + client := DockerClient{api: api} + err := client.Start(c) + + assert.Error(t, err) + assert.EqualError(t, err, "oops") + api.AssertExpectations(t) +} + +func TestStart_StartContainerError(t *testing.T) { + c := Container{ + containerInfo: &dockerclient.ContainerInfo{ + Name: "foo", + Config: &dockerclient.ContainerConfig{}, + HostConfig: &dockerclient.HostConfig{}, + }, + imageInfo: &dockerclient.ImageInfo{ + Config: &dockerclient.ContainerConfig{}, + }, + } + + api := mockclient.NewMockClient() + api.On("CreateContainer", mock.Anything, "foo").Return("def789", nil) + api.On("StartContainer", "def789", mock.Anything).Return(errors.New("whoops")) + + client := DockerClient{api: api} + err := client.Start(c) + + assert.Error(t, err) + assert.EqualError(t, err, "whoops") + api.AssertExpectations(t) +} diff --git a/docker/container.go b/docker/container.go new file mode 100644 index 0000000..6c48495 --- /dev/null +++ b/docker/container.go @@ -0,0 +1,103 @@ +package docker + +import ( + "fmt" + "strings" + + "github.com/samalba/dockerclient" +) + +type Container struct { + Stale bool + + containerInfo *dockerclient.ContainerInfo + imageInfo *dockerclient.ImageInfo +} + +func (c Container) Name() string { + return c.containerInfo.Name +} + +func (c Container) Links() []string { + links := []string{} + + if (c.containerInfo != nil) && (c.containerInfo.HostConfig != nil) { + for _, link := range c.containerInfo.HostConfig.Links { + name := strings.Split(link, ":")[0] + links = append(links, name) + } + } + + return links +} + +// Ideally, we'd just be able to take the ContainerConfig from the old container +// and use it as the starting point for creating the new container; however, +// the ContainerConfig that comes back from the Inspect call merges the default +// configuration (the stuff specified in the metadata for the image itself) +// with the overridden configuration (the stuff that you might specify as part +// of the "docker run"). In order to avoid unintentionally overriding the +// defaults in the new image we need to separate the override options from the +// default options. To do this we have to compare the ContainerConfig for the +// running container with the ContainerConfig from the image that container was +// started from. This function returns a ContainerConfig which contains just +// the options overridden at runtime. +func (c Container) runtimeConfig() *dockerclient.ContainerConfig { + config := c.containerInfo.Config + imageConfig := c.imageInfo.Config + + if config.WorkingDir == imageConfig.WorkingDir { + config.WorkingDir = "" + } + + if config.User == imageConfig.User { + config.User = "" + } + + if sliceEqual(config.Cmd, imageConfig.Cmd) { + config.Cmd = []string{} + } + + if sliceEqual(config.Entrypoint, imageConfig.Entrypoint) { + config.Entrypoint = []string{} + } + + config.Env = sliceSubtract(config.Env, imageConfig.Env) + + config.Labels = stringMapSubtract(config.Labels, imageConfig.Labels) + + config.Volumes = structMapSubtract(config.Volumes, imageConfig.Volumes) + + config.ExposedPorts = structMapSubtract(config.ExposedPorts, imageConfig.ExposedPorts) + for p, _ := range c.containerInfo.HostConfig.PortBindings { + config.ExposedPorts[p] = struct{}{} + } + + return config +} + +// Any links in the HostConfig need to be re-written before they can be +// re-submitted to the Docker create API. +func (c Container) hostConfig() *dockerclient.HostConfig { + hostConfig := c.containerInfo.HostConfig + + for i, link := range hostConfig.Links { + name := link[0:strings.Index(link, ":")] + alias := link[strings.LastIndex(link, "/"):] + + hostConfig.Links[i] = fmt.Sprintf("%s:%s", name, alias) + } + + return hostConfig +} + +func NewTestContainer(name string, links []string) Container { + return Container{ + containerInfo: &dockerclient.ContainerInfo{ + Name: name, + HostConfig: &dockerclient.HostConfig{ + Links: links, + }, + }, + } +} diff --git a/docker/container_test.go b/docker/container_test.go new file mode 100644 index 0000000..bcaef3a --- /dev/null +++ b/docker/container_test.go @@ -0,0 +1,32 @@ +package docker + +import ( + "testing" + + "github.com/samalba/dockerclient" + "github.com/stretchr/testify/assert" +) + +func TestName(t *testing.T) { + c := Container{ + containerInfo: &dockerclient.ContainerInfo{Name: "foo"}, + } + + name := c.Name() + + assert.Equal(t, "foo", name) +} + +func TestLinks(t *testing.T) { + c := Container{ + containerInfo: &dockerclient.ContainerInfo{ + HostConfig: &dockerclient.HostConfig{ + Links: []string{"foo:foo", "bar:bar"}, + }, + }, + } + + links := c.Links() + + assert.Equal(t, []string{"foo", "bar"}, links) +} diff --git a/docker/util.go b/docker/util.go new file mode 100644 index 0000000..8dbe214 --- /dev/null +++ b/docker/util.go @@ -0,0 +1,64 @@ +package docker + +func sliceEqual(s1, s2 []string) bool { + if len(s1) != len(s2) { + return false + } + + for i := range s1 { + if s1[i] != s2[i] { + return false + } + } + + return true +} + +func sliceSubtract(a1, a2 []string) []string { + a := []string{} + + for _, e1 := range a1 { + found := false + + for _, e2 := range a2 { + if e1 == e2 { + found = true + break + } + } + + if !found { + a = append(a, e1) + } + } + + return a +} + +func stringMapSubtract(m1, m2 map[string]string) map[string]string { + m := map[string]string{} + + for k1, v1 := range m1 { + if v2, ok := m2[k1]; ok { + if v2 != v1 { + m[k1] = v1 + } + } else { + m[k1] = v1 + } + } + + return m +} + +func structMapSubtract(m1, m2 map[string]struct{}) map[string]struct{} { + m := map[string]struct{}{} + + for k1, v1 := range m1 { + if _, ok := m2[k1]; !ok { + m[k1] = v1 + } + } + + return m +} diff --git a/updater/config_test.go b/docker/util_test.go similarity index 62% rename from updater/config_test.go rename to docker/util_test.go index 7cd6111..d82a161 100644 --- a/updater/config_test.go +++ b/docker/util_test.go @@ -1,4 +1,4 @@ -package updater +package docker import ( "testing" @@ -6,6 +6,43 @@ import ( "github.com/stretchr/testify/assert" ) +func TestSliceEqual_True(t *testing.T) { + s1 := []string{"a", "b", "c"} + s2 := []string{"a", "b", "c"} + + result := sliceEqual(s1, s2) + + assert.True(t, result) +} + +func TestSliceEqual_DifferentLengths(t *testing.T) { + s1 := []string{"a", "b", "c"} + s2 := []string{"a", "b", "c", "d"} + + result := sliceEqual(s1, s2) + + assert.False(t, result) +} + +func TestSliceEqual_DifferentContents(t *testing.T) { + s1 := []string{"a", "b", "c"} + s2 := []string{"a", "b", "d"} + + result := sliceEqual(s1, s2) + + assert.False(t, result) +} + +func TestSliceSubtract(t *testing.T) { + a1 := []string{"a", "b", "c"} + a2 := []string{"a", "c"} + + result := sliceSubtract(a1, a2) + assert.Equal(t, []string{"b"}, result) + assert.Equal(t, []string{"a", "b", "c"}, a1) + assert.Equal(t, []string{"a", "c"}, a2) +} + func TestStringMapSubtract(t *testing.T) { m1 := map[string]string{"a": "a", "b": "b", "c": "sea"} m2 := map[string]string{"a": "a", "c": "c"} @@ -26,13 +63,3 @@ func TestStructMapSubtract(t *testing.T) { assert.Equal(t, map[string]struct{}{"a": x, "b": x, "c": x}, m1) assert.Equal(t, map[string]struct{}{"a": x, "c": x}, m2) } - -func TestArraySubtract(t *testing.T) { - a1 := []string{"a", "b", "c"} - a2 := []string{"a", "c"} - - result := arraySubtract(a1, a2) - assert.Equal(t, []string{"b"}, result) - assert.Equal(t, []string{"a", "b", "c"}, a1) - assert.Equal(t, []string{"a", "c"}, a2) -} diff --git a/main.go b/main.go index 4b18d0c..34f7f99 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" "os/signal" "sync" @@ -50,7 +51,9 @@ func start(c *cli.Context) { for { wg.Add(1) - updater.Run() + if err := updater.Run(); err != nil { + fmt.Println(err) + } wg.Done() time.Sleep(secs) diff --git a/updater/config.go b/updater/config.go deleted file mode 100644 index 10e8100..0000000 --- a/updater/config.go +++ /dev/null @@ -1,112 +0,0 @@ -package updater - -import ( - "github.com/samalba/dockerclient" -) - -// Ideally, we'd just be able to take the ContainerConfig from the old container -// and use it as the starting point for creating the new container; however, -// the ContainerConfig that comes back from the Inspect call merges the default -// configuration (the stuff specified in the metadata for the image itself) -// with the overridden configuration (the stuff that you might specify as part -// of the "docker run"). In order to avoid unintentionally overriding the -// defaults in the new image we need to separate the override options from the -// default options. To do this we have to compare the ContainerConfig for the -// running container with the ContainerConfig from the image that container was -// started from. This function returns a ContainerConfig which contains just -// the override options. -func GenerateContainerConfig(oldContainerInfo *dockerclient.ContainerInfo, oldImageConfig *dockerclient.ContainerConfig) *dockerclient.ContainerConfig { - config := oldContainerInfo.Config - - if config.WorkingDir == oldImageConfig.WorkingDir { - config.WorkingDir = "" - } - - if config.User == oldImageConfig.User { - config.User = "" - } - - if sliceEqual(config.Cmd, oldImageConfig.Cmd) { - config.Cmd = []string{} - } - - if sliceEqual(config.Entrypoint, oldImageConfig.Entrypoint) { - config.Entrypoint = []string{} - } - - config.Env = arraySubtract(config.Env, oldImageConfig.Env) - - config.Labels = stringMapSubtract(config.Labels, oldImageConfig.Labels) - - config.Volumes = structMapSubtract(config.Volumes, oldImageConfig.Volumes) - - config.ExposedPorts = structMapSubtract(config.ExposedPorts, oldImageConfig.ExposedPorts) - for p, _ := range oldContainerInfo.HostConfig.PortBindings { - config.ExposedPorts[p] = struct{}{} - } - - return config -} - -func sliceEqual(s1, s2 []string) bool { - if len(s1) != len(s2) { - return false - } - - for i := range s1 { - if s1[i] != s2[i] { - return false - } - } - - return true -} - -func stringMapSubtract(m1, m2 map[string]string) map[string]string { - m := map[string]string{} - - for k1, v1 := range m1 { - if v2, ok := m2[k1]; ok { - if v2 != v1 { - m[k1] = v1 - } - } else { - m[k1] = v1 - } - } - - return m -} - -func structMapSubtract(m1, m2 map[string]struct{}) map[string]struct{} { - m := map[string]struct{}{} - - for k1, v1 := range m1 { - if _, ok := m2[k1]; !ok { - m[k1] = v1 - } - } - - return m -} - -func arraySubtract(a1, a2 []string) []string { - a := []string{} - - for _, e1 := range a1 { - found := false - - for _, e2 := range a2 { - if e1 == e2 { - found = true - break - } - } - - if !found { - a = append(a, e1) - } - } - - return a -} diff --git a/updater/sorter.go b/updater/sorter.go new file mode 100644 index 0000000..3cd099f --- /dev/null +++ b/updater/sorter.go @@ -0,0 +1,74 @@ +package updater + +import ( + "fmt" + + "github.com/CenturyLinkLabs/watchtower/docker" +) + +type ContainerSorter struct { + unvisited []docker.Container + marked map[string]bool + sorted []docker.Container +} + +func (cs *ContainerSorter) Sort(containers []docker.Container) ([]docker.Container, error) { + cs.unvisited = containers + cs.marked = map[string]bool{} + + for len(cs.unvisited) > 0 { + if err := cs.visit(cs.unvisited[0]); err != nil { + return nil, err + } + } + + return cs.sorted, nil +} + +func (cs *ContainerSorter) visit(c docker.Container) error { + + if _, ok := cs.marked[c.Name()]; ok { + return fmt.Errorf("Circular reference to %s", c.Name()) + } + + // Mark any visited node so that circular references can be detected + cs.marked[c.Name()] = true + defer delete(cs.marked, c.Name()) + + // Recursively visit links + for _, linkName := range c.Links() { + if linkedContainer := cs.findUnvisited(linkName); linkedContainer != nil { + if err := cs.visit(*linkedContainer); err != nil { + return err + } + } + } + + // Move container from unvisited to sorted + cs.removeUnvisited(c) + cs.sorted = append(cs.sorted, c) + + return nil +} + +func (cs *ContainerSorter) findUnvisited(name string) *docker.Container { + for _, c := range cs.unvisited { + if c.Name() == name { + return &c + } + } + + return nil +} + +func (cs *ContainerSorter) removeUnvisited(c docker.Container) { + var idx int + for i := range cs.unvisited { + if cs.unvisited[i].Name() == c.Name() { + idx = i + break + } + } + + cs.unvisited = append(cs.unvisited[0:idx], cs.unvisited[idx+1:]...) +} diff --git a/updater/sorter_test.go b/updater/sorter_test.go new file mode 100644 index 0000000..1314b70 --- /dev/null +++ b/updater/sorter_test.go @@ -0,0 +1,37 @@ +package updater + +import ( + "testing" + + "github.com/CenturyLinkLabs/watchtower/docker" + "github.com/stretchr/testify/assert" +) + +func TestContainerSorter_Success(t *testing.T) { + c1 := docker.NewTestContainer("1", []string{}) + c2 := docker.NewTestContainer("2", []string{"1:"}) + c3 := docker.NewTestContainer("3", []string{"2:"}) + c4 := docker.NewTestContainer("4", []string{"3:"}) + c5 := docker.NewTestContainer("5", []string{"4:"}) + c6 := docker.NewTestContainer("6", []string{"5:", "3:"}) + containers := []docker.Container{c6, c2, c4, c1, c3, c5} + + cs := ContainerSorter{} + result, err := cs.Sort(containers) + + assert.NoError(t, err) + assert.Equal(t, []docker.Container{c1, c2, c3, c4, c5, c6}, result) +} + +func TestContainerSorter_Error(t *testing.T) { + c1 := docker.NewTestContainer("1", []string{"3:"}) + c2 := docker.NewTestContainer("2", []string{"1:"}) + c3 := docker.NewTestContainer("3", []string{"2:"}) + containers := []docker.Container{c1, c2, c3} + + cs := ContainerSorter{} + _, err := cs.Sort(containers) + + assert.Error(t, err) + assert.EqualError(t, err, "Circular reference to 1") +} diff --git a/updater/updater.go b/updater/updater.go index 27cee40..c6e66bd 100644 --- a/updater/updater.go +++ b/updater/updater.go @@ -1,81 +1,71 @@ package updater import ( - "log" - - "github.com/samalba/dockerclient" -) - -var ( - client dockerclient.Client + "github.com/CenturyLinkLabs/watchtower/docker" ) -func init() { - docker, err := dockerclient.NewDockerClient("unix:///var/run/docker.sock", nil) +func Run() error { + client := docker.NewClient() + containers, err := client.ListContainers() if err != nil { - log.Fatalf("Error instantiating Docker client: %s\n", err) + return err } - client = docker -} - -func Run() error { - containers, _ := client.ListContainers(false, false, "") - - for _, container := range containers { - - oldContainerInfo, _ := client.InspectContainer(container.Id) - name := oldContainerInfo.Name - oldImageId := oldContainerInfo.Image - log.Printf("Running: %s (%s)\n", container.Image, oldImageId) + for i := range containers { + if err := client.RefreshImage(&containers[i]); err != nil { + return err + } + } - oldImageInfo, _ := client.InspectImage(oldImageId) + containers, err = sortContainers(containers) + if err != nil { + return err + } - // First check to see if a newer image has already been built - newImageInfo, _ := client.InspectImage(container.Image) + checkDependencies(containers) - if newImageInfo.Id == oldImageInfo.Id { - _ = client.PullImage(container.Image, nil) - newImageInfo, _ = client.InspectImage(container.Image) + // Stop stale containers in reverse order + for i := len(containers) - 1; i >= 0; i-- { + container := containers[i] + if container.Stale { + if err := client.Stop(container); err != nil { + return err + } } + } - newImageId := newImageInfo.Id - log.Printf("Latest: %s (%s)\n", container.Image, newImageId) - - if newImageId != oldImageId { - log.Printf("Restarting %s with new image\n", name) - if err := stopContainer(oldContainerInfo); err != nil { + // Restart stale containers in sorted order + for _, container := range containers { + if container.Stale { + if err := client.Start(container); err != nil { + return err } - - config := GenerateContainerConfig(oldContainerInfo, oldImageInfo.Config) - - hostConfig := oldContainerInfo.HostConfig - _ = startContainer(name, config, hostConfig) } } return nil } -func stopContainer(container *dockerclient.ContainerInfo) error { - signal := "SIGTERM" +func sortContainers(containers []docker.Container) ([]docker.Container, error) { + sorter := ContainerSorter{} + return sorter.Sort(containers) +} - if sig, ok := container.Config.Labels["com.centurylinklabs.watchtower.stop-signal"]; ok { - signal = sig - } +func checkDependencies(containers []docker.Container) { - if err := client.KillContainer(container.Id, signal); err != nil { - return err - } - - return client.RemoveContainer(container.Id, true, false) -} + for i, parent := range containers { + if parent.Stale { + continue + } -func startContainer(name string, config *dockerclient.ContainerConfig, hostConfig *dockerclient.HostConfig) error { - newContainerId, err := client.CreateContainer(config, name) - if err != nil { - return err + LinkLoop: + for _, linkName := range parent.Links() { + for _, child := range containers { + if child.Name() == linkName && child.Stale { + containers[i].Stale = true + break LinkLoop + } + } + } } - - return client.StartContainer(newContainerId, hostConfig) } diff --git a/updater/updater_test.go b/updater/updater_test.go new file mode 100644 index 0000000..b8c1f4f --- /dev/null +++ b/updater/updater_test.go @@ -0,0 +1,29 @@ +package updater + +import ( + "testing" + + "github.com/CenturyLinkLabs/watchtower/docker" + "github.com/stretchr/testify/assert" +) + +func TestCheckDependencies(t *testing.T) { + cs := []docker.Container{ + docker.NewTestContainer("1", []string{}), + docker.NewTestContainer("2", []string{"1:"}), + docker.NewTestContainer("3", []string{"2:"}), + docker.NewTestContainer("4", []string{"3:"}), + docker.NewTestContainer("5", []string{"4:"}), + docker.NewTestContainer("6", []string{"5:"}), + } + cs[3].Stale = true + + checkDependencies(cs) + + assert.False(t, cs[0].Stale) + assert.False(t, cs[1].Stale) + assert.False(t, cs[2].Stale) + assert.True(t, cs[3].Stale) + assert.True(t, cs[4].Stale) + assert.True(t, cs[5].Stale) +}