From 0a5bd54fb7f38f1e3eb281f4735a2b2fb1881a86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nils=20m=C3=A5s=C3=A9n?= Date: Sat, 15 Apr 2023 12:56:51 +0200 Subject: [PATCH] feat(clean): log removed/untagged images (#1466) --- internal/util/rand_sha256.go | 24 ++++++++++++++ internal/util/util_test.go | 16 ++++++++- pkg/container/client.go | 29 ++++++++++++++--- pkg/container/client_test.go | 56 +++++++++++++++++++++++++++++--- pkg/container/mocks/ApiServer.go | 27 +++++++++++++++ 5 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 internal/util/rand_sha256.go diff --git a/internal/util/rand_sha256.go b/internal/util/rand_sha256.go new file mode 100644 index 000000000..e28e537e1 --- /dev/null +++ b/internal/util/rand_sha256.go @@ -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() +} diff --git a/internal/util/util_test.go b/internal/util/util_test.go index a6dd65784..0b2c36c8a 100644 --- a/internal/util/util_test.go +++ b/internal/util/util_test.go @@ -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) { @@ -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) +} diff --git a/pkg/container/client.go b/pkg/container/client.go index 5f393e753..052d1a256 100644 --- a/pkg/container/client.go +++ b/pkg/container/client.go @@ -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) @@ -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 } diff --git a/pkg/container/client_test.go b/pkg/container/client_test.go index 645102e52..24c32225e 100644 --- a/pkg/container/client_test.go +++ b/pkg/container/client_test.go @@ -3,6 +3,7 @@ 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" @@ -10,6 +11,7 @@ import ( "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" @@ -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() { @@ -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") @@ -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 { diff --git a/pkg/container/mocks/ApiServer.go b/pkg/container/mocks/ApiServer.go index a879ede55..652bafb00 100644 --- a/pkg/container/mocks/ApiServer.go +++ b/pkg/container/mocks/ApiServer.go @@ -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" @@ -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) + } + }, + ) +}