Add previous image on updates and latest tags

Introduce new field in ImagePolicy status, observedPreviousImage,
to store the previous image. This is needed to show image update
version change in alerts.

Introduce new field in ImageRepository status, latestTags, to store the
10 recent tags. This would help users debug image policy better.

Signed-off-by: Sunny <darkowlzz@protonmail.com>
This commit is contained in:
Sunny 2022-09-14 05:06:07 +05:30
parent 685f7be017
commit 091a00cb2e
10 changed files with 232 additions and 32 deletions

View File

@ -105,6 +105,10 @@ type ImagePolicyStatus struct {
// the image repository, when filtered and ordered according to
// the policy.
LatestImage string `json:"latestImage,omitempty"`
// ObservedPreviousImage is the observed previous LatestImage. It is used
// to keep track of the previous and current images.
// +optional
ObservedPreviousImage string `json:"observedPreviousImage,omitempty"`
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// +optional

View File

@ -94,8 +94,9 @@ type ImageRepositorySpec struct {
}
type ScanResult struct {
TagCount int `json:"tagCount"`
ScanTime metav1.Time `json:"scanTime,omitempty"`
TagCount int `json:"tagCount"`
ScanTime metav1.Time `json:"scanTime,omitempty"`
LatestTags []string `json:"latestTags,omitempty"`
}
// ImageRepositoryStatus defines the observed state of ImageRepository

View File

@ -328,6 +328,11 @@ func (in *NumericalPolicy) DeepCopy() *NumericalPolicy {
func (in *ScanResult) DeepCopyInto(out *ScanResult) {
*out = *in
in.ScanTime.DeepCopyInto(&out.ScanTime)
if in.LatestTags != nil {
in, out := &in.LatestTags, &out.LatestTags
*out = make([]string, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScanResult.

View File

@ -391,6 +391,10 @@ spec:
observedGeneration:
format: int64
type: integer
observedPreviousImage:
description: ObservedPreviousImage is the observed previous LatestImage.
It is used to keep track of the previous and current images.
type: string
type: object
type: object
served: true

View File

@ -432,6 +432,10 @@ spec:
lastScanResult:
description: LastScanResult contains the number of fetched tags.
properties:
latestTags:
items:
type: string
type: array
scanTime:
format: date-time
type: string

View File

@ -22,6 +22,7 @@ import (
"fmt"
"time"
"github.com/google/go-containerregistry/pkg/name"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
@ -192,10 +193,20 @@ func (r *ImagePolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request)
return
}
// composeImagePolicyReadyMessage composes a Ready message for an ImagePolicy
// based on the results of applying the policy.
func composeImagePolicyReadyMessage(previousTag, latestTag, image string) string {
readyMsg := fmt.Sprintf("Latest image tag for '%s' resolved to %s", image, latestTag)
if previousTag != "" && previousTag != latestTag {
readyMsg = fmt.Sprintf("Latest image tag for '%s' updated from %s to %s", image, previousTag, latestTag)
}
return readyMsg
}
func (r *ImagePolicyReconciler) reconcile(ctx context.Context, obj *imagev1.ImagePolicy) (result ctrl.Result, retErr error) {
oldObj := obj.DeepCopy()
var resultImage, resultTag string
var resultImage, resultTag, previousTag string
// If there's no error and no requeue is requested, it's a success. Unlike
// other reconcilers, this reconciler doesn't requeue on its own with a
@ -208,7 +219,8 @@ func (r *ImagePolicyReconciler) reconcile(ctx context.Context, obj *imagev1.Imag
}
defer func() {
readyMsg := fmt.Sprintf("Latest image tag for '%s' resolved to: %s", resultImage, resultTag)
readyMsg := composeImagePolicyReadyMessage(previousTag, resultTag, resultImage)
rs := pkgreconcile.NewResultFinalizer(isSuccess, readyMsg)
retErr = rs.Finalize(obj, result, retErr)
@ -286,6 +298,26 @@ func (r *ImagePolicyReconciler) reconcile(ctx context.Context, obj *imagev1.Imag
// Write the observations on status.
obj.Status.LatestImage = repo.Spec.Image + ":" + latest
// If the old latest image and new latest image don't match, set the old
// image as the observed previous image.
// NOTE: The following allows the previous image to be set empty when
// there's a failure and a subsequent recovery from it. This behavior helps
// avoid creating an update event as there's no previous image to infer
// from. Recovery from a failure shouldn't result in an update event.
if oldObj.Status.LatestImage != obj.Status.LatestImage {
obj.Status.ObservedPreviousImage = oldObj.Status.LatestImage
}
// Parse the observed previous image if any and extract previous tag. This
// is used to determine image tag update path.
if obj.Status.ObservedPreviousImage != "" {
prevRef, err := name.NewTag(obj.Status.ObservedPreviousImage)
if err != nil {
e := fmt.Errorf("failed to parse previous image '%s': %w", obj.Status.ObservedPreviousImage, err)
conditions.MarkFalse(obj, meta.ReadyCondition, meta.FailedReason, e.Error())
result, retErr = ctrl.Result{}, e
}
previousTag = prevRef.TagStr()
}
resultImage = repo.Spec.Image
resultTag = latest

View File

@ -316,3 +316,42 @@ func TestImagePolicyReconciler_applyPolicy(t *testing.T) {
})
}
}
func TestComposeImagePolicyReadyMessage(t *testing.T) {
testImage := "foo/bar"
tests := []struct {
name string
previousTag string
latestTag string
image string
wantMessage string
}{
{
name: "no previous tag",
latestTag: "1.0.0",
wantMessage: "Latest image tag for 'foo/bar' resolved to 1.0.0",
},
{
name: "different previous tag",
previousTag: "1.0.0",
latestTag: "1.1.0",
wantMessage: "Latest image tag for 'foo/bar' updated from 1.0.0 to 1.1.0",
},
{
name: "same previous and latest tags",
previousTag: "1.0.0",
latestTag: "1.0.0",
wantMessage: "Latest image tag for 'foo/bar' resolved to 1.0.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
result := composeImagePolicyReadyMessage(tt.previousTag, tt.latestTag, testImage)
g.Expect(result).To(Equal(tt.wantMessage))
})
}
}

View File

@ -21,6 +21,7 @@ import (
"errors"
"fmt"
"regexp"
"sort"
"strings"
"time"
@ -57,6 +58,9 @@ import (
"github.com/fluxcd/image-reflector-controller/internal/secret"
)
// latestTagsCount is the number of tags to use as latest tags.
const latestTagsCount = 10
// imageRepositoryOwnedConditions is a list of conditions owned by the
// ImageRepositoryReconciler.
var imageRepositoryOwnedConditions = []string{
@ -462,8 +466,9 @@ func (r *ImageRepositoryReconciler) scan(ctx context.Context, obj *imagev1.Image
scanTime := metav1.Now()
obj.Status.LastScanResult = &imagev1.ScanResult{
TagCount: len(filteredTags),
ScanTime: scanTime,
TagCount: len(filteredTags),
ScanTime: scanTime,
LatestTags: getLatestTags(filteredTags),
}
// If the reconcile request annotation was set, consider it
@ -551,6 +556,21 @@ func filterOutTags(tags []string, patterns []string) ([]string, error) {
return filteredTags, nil
}
// getLatestTags takes a slice of tags, sorts them in descending order of their
// values and returns the 10 latest tags.
func getLatestTags(tags []string) []string {
var result []string
sort.SliceStable(tags, func(i, j int) bool { return tags[i] > tags[j] })
if len(tags) >= latestTagsCount {
latestTags := tags[0:latestTagsCount]
result = append(result, latestTags...)
} else {
result = append(result, tags...)
}
return result
}
// isEqualSliceContent compares two string slices to check if they have the same
// content.
func isEqualSliceContent(a, b []string) bool {

View File

@ -440,37 +440,48 @@ func TestImageRepositoryReconciler_scan(t *testing.T) {
defer registryServer.Close()
tests := []struct {
name string
tags []string
exclusionList []string
annotation string
db *mockDatabase
wantErr bool
wantTags []string
name string
tags []string
exclusionList []string
annotation string
db *mockDatabase
wantErr bool
wantTags []string
wantLatestTags []string
}{
{
name: "no tags",
wantErr: true,
},
{
name: "simple tags",
tags: []string{"a", "b", "c", "d"},
db: &mockDatabase{},
wantTags: []string{"a", "b", "c", "d"},
name: "simple tags",
tags: []string{"a", "b", "c", "d"},
db: &mockDatabase{},
wantTags: []string{"a", "b", "c", "d"},
wantLatestTags: []string{"d", "c", "b", "a"},
},
{
name: "with single exclusion pattern",
tags: []string{"a", "b", "c", "d"},
exclusionList: []string{"c"},
db: &mockDatabase{},
wantTags: []string{"a", "b", "d"},
name: "simple tags, 10+",
tags: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"},
db: &mockDatabase{},
wantTags: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"},
wantLatestTags: []string{"k", "j", "i", "h, g, f, e, d, c, b"},
},
{
name: "with multiple exclusion pattern",
tags: []string{"a", "b", "c", "d"},
exclusionList: []string{"c", "a"},
db: &mockDatabase{},
wantTags: []string{"b", "d"},
name: "with single exclusion pattern",
tags: []string{"a", "b", "c", "d"},
exclusionList: []string{"c"},
db: &mockDatabase{},
wantTags: []string{"a", "b", "d"},
wantLatestTags: []string{"d", "b", "a"},
},
{
name: "with multiple exclusion pattern",
tags: []string{"a", "b", "c", "d"},
exclusionList: []string{"c", "a"},
db: &mockDatabase{},
wantTags: []string{"b", "d"},
wantLatestTags: []string{"d", "b"},
},
{
name: "bad exclusion pattern",
@ -485,11 +496,12 @@ func TestImageRepositoryReconciler_scan(t *testing.T) {
wantErr: true,
},
{
name: "with reconcile annotation",
tags: []string{"a", "b"},
annotation: "foo",
db: &mockDatabase{},
wantTags: []string{"a", "b"},
name: "with reconcile annotation",
tags: []string{"a", "b"},
annotation: "foo",
db: &mockDatabase{},
wantTags: []string{"a", "b"},
wantLatestTags: []string{"b", "a"},
},
}
@ -535,6 +547,62 @@ func TestImageRepositoryReconciler_scan(t *testing.T) {
}
}
func TestGetLatestTags(t *testing.T) {
tests := []struct {
name string
tags []string
wantLatestTags []string
}{
{
name: "no tags",
wantLatestTags: nil,
},
{
name: "few semver tags",
tags: []string{"1.0.0", "0.0.8", "1.2.5", "3.0.1", "1.0.1"},
wantLatestTags: []string{"3.0.1", "1.2.5", "1.0.1", "1.0.0", "0.0.8"},
},
{
name: "10 semver tags",
tags: []string{"1.0.0", "0.0.8", "1.2.5", "3.0.1", "1.0.1", "5.1.1", "4.1.0", "4.5.0", "4.0.3", "2.2.2"},
wantLatestTags: []string{"5.1.1", "4.5.0", "4.1.0", "4.0.3", "3.0.1", "2.2.2", "1.2.5", "1.0.1", "1.0.0", "0.0.8"},
},
{
name: "10+ semver tags",
tags: []string{"1.0.0", "0.0.8", "1.2.5", "3.0.1", "1.0.1", "5.1.1", "4.1.0", "4.5.0", "4.0.3", "2.2.2", "0.5.1", "0.1.0"},
wantLatestTags: []string{"5.1.1", "4.5.0", "4.1.0", "4.0.3", "3.0.1", "2.2.2", "1.2.5", "1.0.1", "1.0.0", "0.5.1"},
},
{
name: "few numerical tags",
tags: []string{"-62", "-88", "73", "72", "15"},
wantLatestTags: []string{"73", "72", "15", "-88", "-62"},
},
{
name: "few numerical tags",
tags: []string{"-62", "-88", "73", "72", "15", "16", "15", "29", "-33", "-91", "100", "101"},
wantLatestTags: []string{"73", "72", "29", "16", "15", "15", "101", "100", "-91", "-88"},
},
{
name: "few word tags",
tags: []string{"aaa", "bbb", "ccc"},
wantLatestTags: []string{"ccc", "bbb", "aaa"},
},
{
name: "few word tags",
tags: []string{"aaa", "bbb", "ccc", "ddd", "eee", "fff", "ggg", "hhh", "iii", "jjj", "kkk", "lll"},
wantLatestTags: []string{"lll", "kkk", "jjj", "iii", "hhh", "ggg", "fff", "eee", "ddd", "ccc"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
g.Expect(getLatestTags(tt.tags)).To(Equal(tt.wantLatestTags))
})
}
}
func TestParseImageReference(t *testing.T) {
tests := []struct {
name string

View File

@ -313,6 +313,19 @@ the policy.</p>
</tr>
<tr>
<td>
<code>observedPreviousImage</code><br>
<em>
string
</em>
</td>
<td>
<em>(Optional)</em>
<p>ObservedPreviousImage is the observed previous LatestImage. It is used
to keep track of the previous and current images.</p>
</td>
</tr>
<tr>
<td>
<code>observedGeneration</code><br>
<em>
int64
@ -865,6 +878,16 @@ Kubernetes meta/v1.Time
<td>
</td>
</tr>
<tr>
<td>
<code>latestTags</code><br>
<em>
[]string
</em>
</td>
<td>
</td>
</tr>
</tbody>
</table>
</div>