mirror of
https://github.com/containrrr/watchtower.git
synced 2025-09-21 21:30:48 +02:00
feat(clean): log removed/untagged images (#1466)
This commit is contained in:
parent
dd1ec09668
commit
0a5bd54fb7
5 changed files with 143 additions and 9 deletions
24
internal/util/rand_sha256.go
Normal file
24
internal/util/rand_sha256.go
Normal file
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue