mirror of
https://github.com/containrrr/watchtower.git
synced 2025-12-13 21:56:38 +01: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
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stretchr/testify/assert"
|
"regexp"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSliceEqual_True(t *testing.T) {
|
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, "b": x, "c": x}, m1)
|
||||||
assert.Equal(t, map[string]struct{}{"a": x, "c": x}, m2)
|
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
|
// NewClient returns a new Client instance which can be used to interact with
|
||||||
// the Docker API.
|
// the Docker API.
|
||||||
// The client reads its configuration from the following environment variables:
|
// The client reads its configuration from the following environment variables:
|
||||||
// * DOCKER_HOST the docker-engine host to send api requests to
|
// - DOCKER_HOST the docker-engine host to send api requests to
|
||||||
// * DOCKER_TLS_VERIFY whether to verify tls certificates
|
// - DOCKER_TLS_VERIFY whether to verify tls certificates
|
||||||
// * DOCKER_API_VERSION the minimum docker api version to work with
|
// - DOCKER_API_VERSION the minimum docker api version to work with
|
||||||
func NewClient(opts ClientOptions) Client {
|
func NewClient(opts ClientOptions) Client {
|
||||||
cli, err := sdkClient.NewClientWithOpts(sdkClient.FromEnv)
|
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 {
|
func (client dockerClient) RemoveImageByID(id t.ImageID) error {
|
||||||
log.Infof("Removing image %s", id.ShortID())
|
log.Infof("Removing image %s", id.ShortID())
|
||||||
|
|
||||||
_, err := client.api.ImageRemove(
|
items, err := client.api.ImageRemove(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
string(id),
|
string(id),
|
||||||
types.ImageRemoveOptions{
|
types.ImageRemoveOptions{
|
||||||
Force: true,
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package container
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/containrrr/watchtower/internal/util"
|
||||||
"github.com/containrrr/watchtower/pkg/container/mocks"
|
"github.com/containrrr/watchtower/pkg/container/mocks"
|
||||||
"github.com/containrrr/watchtower/pkg/filters"
|
"github.com/containrrr/watchtower/pkg/filters"
|
||||||
t "github.com/containrrr/watchtower/pkg/types"
|
t "github.com/containrrr/watchtower/pkg/types"
|
||||||
|
|
@ -10,6 +11,7 @@ import (
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/backend"
|
"github.com/docker/docker/api/types/backend"
|
||||||
cli "github.com/docker/docker/client"
|
cli "github.com/docker/docker/client"
|
||||||
|
"github.com/docker/docker/errdefs"
|
||||||
"github.com/onsi/gomega/gbytes"
|
"github.com/onsi/gomega/gbytes"
|
||||||
"github.com/onsi/gomega/ghttp"
|
"github.com/onsi/gomega/ghttp"
|
||||||
"github.com/sirupsen/logrus"
|
"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("listing containers", func() {
|
||||||
When("no filter is provided", func() {
|
When("no filter is provided", func() {
|
||||||
It("should return all available containers", func() {
|
It("should return all available containers", func() {
|
||||||
|
|
@ -193,10 +226,8 @@ var _ = Describe("the client", func() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture logrus output in buffer
|
// Capture logrus output in buffer
|
||||||
logbuf := gbytes.NewBuffer()
|
resetLogrus, logbuf := captureLogrus(logrus.DebugLevel)
|
||||||
origOut := logrus.StandardLogger().Out
|
defer resetLogrus()
|
||||||
defer logrus.SetOutput(origOut)
|
|
||||||
logrus.SetOutput(logbuf)
|
|
||||||
|
|
||||||
user := ""
|
user := ""
|
||||||
containerID := t.ContainerID("ex-cont-id")
|
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
|
// Gomega matcher helpers
|
||||||
|
|
||||||
func withContainerImageName(matcher gt.GomegaMatcher) gt.GomegaMatcher {
|
func withContainerImageName(matcher gt.GomegaMatcher) gt.GomegaMatcher {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
|
@ -190,3 +191,29 @@ const (
|
||||||
Found FoundStatus = true
|
Found FoundStatus = true
|
||||||
Missing FoundStatus = false
|
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