Skip to content

Commit

Permalink
feat(clean): log removed/untagged images (#1466)
Browse files Browse the repository at this point in the history
  • Loading branch information
piksel committed Apr 15, 2023
1 parent dd1ec09 commit 0a5bd54
Show file tree
Hide file tree
Showing 5 changed files with 143 additions and 9 deletions.
24 changes: 24 additions & 0 deletions internal/util/rand_sha256.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package util

import (
"bytes"
"fmt"
"math/rand"
)

// GenerateRandomSHA256 generates a random 64 character SHA 256 hash string
func GenerateRandomSHA256() string {
return GenerateRandomPrefixedSHA256()[7:]
}

// GenerateRandomPrefixedSHA256 generates a random 64 character SHA 256 hash string, prefixed with `sha256:`
func GenerateRandomPrefixedSHA256() string {
hash := make([]byte, 32)
_, _ = rand.Read(hash)
sb := bytes.NewBufferString("sha256:")
sb.Grow(64)
for _, h := range hash {
_, _ = fmt.Fprintf(sb, "%02x", h)
}
return sb.String()
}
16 changes: 15 additions & 1 deletion internal/util/util_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package util

import (
"github.com/stretchr/testify/assert"
"regexp"
"testing"

"github.com/stretchr/testify/assert"
)

func TestSliceEqual_True(t *testing.T) {
Expand Down Expand Up @@ -62,3 +64,15 @@ 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)
}

// GenerateRandomSHA256 generates a random 64 character SHA 256 hash string
func TestGenerateRandomSHA256(t *testing.T) {
res := GenerateRandomSHA256()
assert.Len(t, res, 64)
assert.NotContains(t, res, "sha256:")
}

func TestGenerateRandomPrefixedSHA256(t *testing.T) {
res := GenerateRandomPrefixedSHA256()
assert.Regexp(t, regexp.MustCompile("sha256:[0-9|a-f]{64}"), res)
}
29 changes: 25 additions & 4 deletions pkg/container/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ type Client interface {
// NewClient returns a new Client instance which can be used to interact with
// the Docker API.
// The client reads its configuration from the following environment variables:
// * 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
// - 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(opts ClientOptions) Client {
cli, err := sdkClient.NewClientWithOpts(sdkClient.FromEnv)

Expand Down Expand Up @@ -369,13 +369,34 @@ func (client dockerClient) PullImage(ctx context.Context, container t.Container)
func (client dockerClient) RemoveImageByID(id t.ImageID) error {
log.Infof("Removing image %s", id.ShortID())

_, err := client.api.ImageRemove(
items, err := client.api.ImageRemove(
context.Background(),
string(id),
types.ImageRemoveOptions{
Force: true,
})

if log.IsLevelEnabled(log.DebugLevel) {
deleted := strings.Builder{}
untagged := strings.Builder{}
for _, item := range items {
if item.Deleted != "" {
if deleted.Len() > 0 {
deleted.WriteString(`, `)
}
deleted.WriteString(t.ImageID(item.Deleted).ShortID())
}
if item.Untagged != "" {
if untagged.Len() > 0 {
untagged.WriteString(`, `)
}
untagged.WriteString(t.ImageID(item.Untagged).ShortID())
}
}
fields := log.Fields{`deleted`: deleted.String(), `untagged`: untagged.String()}
log.WithFields(fields).Debug("Image removal completed")
}

return err
}

Expand Down
56 changes: 52 additions & 4 deletions pkg/container/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ package container
import (
"time"

"github.com/containrrr/watchtower/internal/util"
"github.com/containrrr/watchtower/pkg/container/mocks"
"github.com/containrrr/watchtower/pkg/filters"
t "github.com/containrrr/watchtower/pkg/types"

"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/backend"
cli "github.com/docker/docker/client"
"github.com/docker/docker/errdefs"
"github.com/onsi/gomega/gbytes"
"github.com/onsi/gomega/ghttp"
"github.com/sirupsen/logrus"
Expand Down Expand Up @@ -103,6 +105,37 @@ var _ = Describe("the client", func() {
})
})
})
When("removing a image", func() {
When("debug logging is enabled", func() {
It("should log removed and untagged images", func() {
imageA := util.GenerateRandomSHA256()
imageAParent := util.GenerateRandomSHA256()
images := map[string][]string{imageA: {imageAParent}}
mockServer.AppendHandlers(mocks.RemoveImageHandler(images))
c := dockerClient{api: docker}

resetLogrus, logbuf := captureLogrus(logrus.DebugLevel)
defer resetLogrus()

Expect(c.RemoveImageByID(t.ImageID(imageA))).To(Succeed())

shortA := t.ImageID(imageA).ShortID()
shortAParent := t.ImageID(imageAParent).ShortID()

Eventually(logbuf).Should(gbytes.Say(`deleted="%v, %v" untagged="?%v"?`, shortA, shortAParent, shortA))
})
})
When("image is not found", func() {
It("should return an error", func() {
image := util.GenerateRandomSHA256()
mockServer.AppendHandlers(mocks.RemoveImageHandler(nil))
c := dockerClient{api: docker}

err := c.RemoveImageByID(t.ImageID(image))
Expect(errdefs.IsNotFound(err)).To(BeTrue())
})
})
})
When("listing containers", func() {
When("no filter is provided", func() {
It("should return all available containers", func() {
Expand Down Expand Up @@ -193,10 +226,8 @@ var _ = Describe("the client", func() {
}

// Capture logrus output in buffer
logbuf := gbytes.NewBuffer()
origOut := logrus.StandardLogger().Out
defer logrus.SetOutput(origOut)
logrus.SetOutput(logbuf)
resetLogrus, logbuf := captureLogrus(logrus.DebugLevel)
defer resetLogrus()

user := ""
containerID := t.ContainerID("ex-cont-id")
Expand Down Expand Up @@ -255,6 +286,23 @@ var _ = Describe("the client", func() {
})
})

// Capture logrus output in buffer
func captureLogrus(level logrus.Level) (func(), *gbytes.Buffer) {

logbuf := gbytes.NewBuffer()

origOut := logrus.StandardLogger().Out
logrus.SetOutput(logbuf)

origLev := logrus.StandardLogger().Level
logrus.SetLevel(level)

return func() {
logrus.SetOutput(origOut)
logrus.SetLevel(origLev)
}, logbuf
}

// Gomega matcher helpers

func withContainerImageName(matcher gt.GomegaMatcher) gt.GomegaMatcher {
Expand Down
27 changes: 27 additions & 0 deletions pkg/container/mocks/ApiServer.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"path/filepath"
"strings"

"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
Expand Down Expand Up @@ -190,3 +191,29 @@ const (
Found FoundStatus = true
Missing FoundStatus = false
)

// RemoveImageHandler mocks the DELETE images/ID endpoint, simulating removal of the given imagesWithParents
func RemoveImageHandler(imagesWithParents map[string][]string) http.HandlerFunc {
return ghttp.CombineHandlers(
ghttp.VerifyRequest("DELETE", O.MatchRegexp("/images/.*")),
func(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(r.URL.Path, `/`)
image := parts[len(parts)-1]

if parents, found := imagesWithParents[image]; found {
items := []types.ImageDeleteResponseItem{
{Untagged: image},
{Deleted: image},
}
for _, parent := range parents {
items = append(items, types.ImageDeleteResponseItem{Deleted: parent})
}
ghttp.RespondWithJSONEncoded(http.StatusOK, items)(w, r)
} else {
ghttp.RespondWithJSONEncoded(http.StatusNotFound, struct{ message string }{
message: "Something went wrong.",
})(w, r)
}
},
)
}

0 comments on commit 0a5bd54

Please sign in to comment.