Repository: fluxcd/image-reflector-controller (go)

Issue:
Add `.status.revision` field to ImageRepository
To track changes in upstream tags, the `ImageRepository` reconciler should compute a checksum from the array of tags before updating the database. The checksum should be persisted in the `.status.revision` field of the `ImageRepository`.  The  `ImageRepository.status.revision` will be used by the `ImagePolicy` watcher to reliably determine when it should query the database to evaluate the policies.

Pull request:
Store checksum of ImageRepository tags and trigger ImagePolicy watch only when it changes
Closes #778 

There's already a field called `.spec.lastScanResult` with other information about the scan that is pretty close to `revision` (e.g. `tagCount`, `latestTags`), so I figured it would make more sense to put the revision inside it as well.

Patch:
diff --git a/api/v1beta2/imagerepository_types.go b/api/v1beta2/imagerepository_types.go
index ca00c1e..d26544e 100644
--- a/api/v1beta2/imagerepository_types.go
+++ b/api/v1beta2/imagerepository_types.go
@@ -28,6 +28,7 @@ import (
 const ImageRepositoryKind = "ImageRepository"
 
 // Deprecated: Use ImageFinalizer.
+// TODO: Remove in v1.
 const ImageRepositoryFinalizer = ImageFinalizer
 
 // ImageRepositorySpec defines the parameters for scanning an image
@@ -115,10 +116,26 @@ type ImageRepositorySpec struct {
 	Insecure bool `json:"insecure,omitempty"`
 }
 
+// ScanResult contains information about the last scan of the image repository.
+// TODO: Make all fields except for LatestTags required in v1.
 type ScanResult struct {
-	TagCount   int         `json:"tagCount"`
-	ScanTime   metav1.Time `json:"scanTime,omitempty"`
-	LatestTags []string    `json:"latestTags,omitempty"`
+	// Revision is a stable hash of the scanned tags.
+	// +optional
+	Revision string `json:"revision"`
+
+	// TagCount is the number of tags found in the last scan.
+	// +required
+	TagCount int `json:"tagCount"`
+
+	// ScanTime is the time when the last scan was performed.
+	// +optional
+	ScanTime metav1.Time `json:"scanTime"`
+
+	// LatestTags is a small sample of the tags found in the last scan.
+	// It's the first 10 tags when sorting all the tags in descending
+	// alphabetical order.
+	// +optional
+	LatestTags []string `json:"latestTags,omitempty"`
 }
 
 // ImageRepositoryStatus defines the observed state of ImageRepository
diff --git a/config/crd/bases/image.toolkit.fluxcd.io_imagerepositories.yaml b/config/crd/bases/image.toolkit.fluxcd.io_imagerepositories.yaml
index 5c062f2..84d1ca0 100644
--- a/config/crd/bases/image.toolkit.fluxcd.io_imagerepositories.yaml
+++ b/config/crd/bases/image.toolkit.fluxcd.io_imagerepositories.yaml
@@ -483,13 +483,23 @@ spec:
                 description: LastScanResult contains the number of fetched tags.
                 properties:
                   latestTags:
+                    description: |-
+                      LatestTags is a small sample of the tags found in the last scan.
+                      It's the first 10 tags when sorting all the tags in descending
+                      alphabetical order.
                     items:
                       type: string
                     type: array
+                  revision:
+                    description: Revision is a stable hash of the scanned tags.
+                    type: string
                   scanTime:
+                    description: ScanTime is the time when the last scan was performed.
                     format: date-time
                     type: string
                   tagCount:
+                    description: TagCount is the number of tags found in the last
+                      scan.
                     type: integer
                 required:
                 - tagCount
diff --git a/docs/api/v1beta2/image-reflector.md b/docs/api/v1beta2/image-reflector.md
index 9170f28..a54fa2c 100644
--- a/docs/api/v1beta2/image-reflector.md
+++ b/docs/api/v1beta2/image-reflector.md
@@ -1098,6 +1098,8 @@ would select 0.</p>
 (<em>Appears on:</em>
 <a href="#image.toolkit.fluxcd.io/v1beta2.ImageRepositoryStatus">ImageRepositoryStatus</a>)
 </p>
+<p>ScanResult contains information about the last scan of the image repository.
+TODO: Make all fields except for LatestTags required in v1.</p>
 <div class="md-typeset__scrollwrap">
 <div class="md-typeset__table">
 <table>
@@ -1110,12 +1112,25 @@ would select 0.</p>
 <tbody>
 <tr>
 <td>
+<code>revision</code><br>
+<em>
+string
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>Revision is a stable hash of the scanned tags.</p>
+</td>
+</tr>
+<tr>
+<td>
 <code>tagCount</code><br>
 <em>
 int
 </em>
 </td>
 <td>
+<p>TagCount is the number of tags found in the last scan.</p>
 </td>
 </tr>
 <tr>
@@ -1128,6 +1143,8 @@ Kubernetes meta/v1.Time
 </em>
 </td>
 <td>
+<em>(Optional)</em>
+<p>ScanTime is the time when the last scan was performed.</p>
 </td>
 </tr>
 <tr>
@@ -1138,6 +1155,10 @@ Kubernetes meta/v1.Time
 </em>
 </td>
 <td>
+<em>(Optional)</em>
+<p>LatestTags is a small sample of the tags found in the last scan.
+It&rsquo;s the first 10 tags when sorting all the tags in descending
+alphabetical order.</p>
 </td>
 </tr>
 </tbody>
diff --git a/internal/controller/database.go b/internal/controller/database.go
index 3a6c2fc..de74b50 100644
--- a/internal/controller/database.go
+++ b/internal/controller/database.go
@@ -18,7 +18,7 @@ package controller
 
 // DatabaseWriter implementations record the tags for an image repository.
 type DatabaseWriter interface {
-	SetTags(repo string, tags []string) error
+	SetTags(repo string, tags []string) (string, error)
 }
 
 // DatabaseReader implementations get the stored set of tags for an image
diff --git a/internal/controller/imagepolicy_controller.go b/internal/controller/imagepolicy_controller.go
index 7cc3049..3bf9bff 100644
--- a/internal/controller/imagepolicy_controller.go
+++ b/internal/controller/imagepolicy_controller.go
@@ -164,15 +164,18 @@ func (imageRepositoryPredicate) Update(e event.UpdateEvent) bool {
 		return false
 	}
 
-	// This is a temporary workaround to avoid reconciling ImagePolicy
-	// when the ImageRepository is not ready. In the near future, we
-	// will implement a digest for the scanned tags in the ImageRepository
-	// and will only return true here if the digest has changed, which
-	// covers not only skipping the reconciliation when the ImageRepository
-	// is not ready, but also when the tags have not changed.
-	repo := e.ObjectNew.(*imagev1.ImageRepository)
-	return conditions.IsReady(repo) &&
-		conditions.GetObservedGeneration(repo, meta.ReadyCondition) == repo.Generation
+	newRepo := e.ObjectNew.(*imagev1.ImageRepository)
+	if newRepo.Status.LastScanResult == nil {
+		return false
+	}
+
+	oldRepo := e.ObjectOld.(*imagev1.ImageRepository)
+	if oldRepo.Status.LastScanResult == nil ||
+		oldRepo.Status.LastScanResult.Revision != newRepo.Status.LastScanResult.Revision {
+		return true
+	}
+
+	return false
 }
 
 func (r *ImagePolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, retErr error) {
diff --git a/internal/controller/imagerepository_controller.go b/internal/controller/imagerepository_controller.go
index 44ad0c5..4314bbe 100644
--- a/internal/controller/imagerepository_controller.go
+++ b/internal/controller/imagerepository_controller.go
@@ -21,7 +21,8 @@ import (
 	"errors"
 	"fmt"
 	"regexp"
-	"sort"
+	"slices"
+	"strings"
 	"time"
 
 	"github.com/google/go-containerregistry/pkg/name"
@@ -190,7 +191,8 @@ func (r *ImageRepositoryReconciler) reconcile(ctx context.Context, sp *patch.Ser
 	obj *imagev1.ImageRepository, startTime time.Time) (result ctrl.Result, retErr error) {
 	oldObj := obj.DeepCopy()
 
-	var foundTags int
+	var tagsChecksum string
+	var numFoundTags int
 	// Store a message about current reconciliation and next scan.
 	var nextScanMsg string
 	// Set a default next scan time before processing the object.
@@ -205,7 +207,7 @@ func (r *ImageRepositoryReconciler) reconcile(ctx context.Context, sp *patch.Ser
 			return true
 		}
 
-		readyMsg := fmt.Sprintf("successful scan: found %d tags", foundTags)
+		readyMsg := fmt.Sprintf("successful scan: found %d tags with checksum %s", numFoundTags, tagsChecksum)
 		rs := reconcile.NewResultFinalizer(isSuccess, readyMsg)
 		retErr = rs.Finalize(obj, result, retErr)
 
@@ -300,20 +302,18 @@ func (r *ImageRepositoryReconciler) reconcile(ctx context.Context, sp *patch.Ser
 			return
 		}
 
-		tags, err := r.scan(ctx, obj, ref, opts)
-		if err != nil {
+		if err := r.scan(ctx, obj, ref, opts); err != nil {
 			e := fmt.Errorf("scan failed: %w", err)
 			conditions.MarkFalse(obj, meta.ReadyCondition, imagev1.ReadOperationFailedReason, "%s", e)
 			result, retErr = ctrl.Result{}, e
 			return
 		}
-		foundTags = tags
 
 		nextScanMsg = fmt.Sprintf("next scan in %s", when.String())
 		// Check if new tags were found.
 		if oldObj.Status.LastScanResult != nil &&
-			oldObj.Status.LastScanResult.TagCount == foundTags {
-			nextScanMsg = "no new tags found, " + nextScanMsg
+			oldObj.Status.LastScanResult.Revision == obj.Status.LastScanResult.Revision {
+			nextScanMsg = "tags did not change, " + nextScanMsg
 		} else {
 			// When new tags are found, this message will be suppressed by
 			// another event based on the new Ready=true status value. This is
@@ -321,9 +321,10 @@ func (r *ImageRepositoryReconciler) reconcile(ctx context.Context, sp *patch.Ser
 			nextScanMsg = "successful scan, " + nextScanMsg
 		}
 	} else {
-		foundTags = obj.Status.LastScanResult.TagCount
 		nextScanMsg = fmt.Sprintf("no change in repository configuration since last scan, next scan in %s", when.String())
 	}
+	tagsChecksum = obj.Status.LastScanResult.Revision
+	numFoundTags = obj.Status.LastScanResult.TagCount
 
 	// Set the observations on the status.
 	obj.Status.CanonicalImageName = ref.Context().String()
@@ -409,7 +410,7 @@ func (r *ImageRepositoryReconciler) shouldScan(obj imagev1.ImageRepository, now
 
 // scan performs repository scanning and writes the scanned result in the
 // internal database and populates the status of the ImageRepository.
-func (r *ImageRepositoryReconciler) scan(ctx context.Context, obj *imagev1.ImageRepository, ref name.Reference, options []remote.Option) (int, error) {
+func (r *ImageRepositoryReconciler) scan(ctx context.Context, obj *imagev1.ImageRepository, ref name.Reference, options []remote.Option) error {
 	timeout := obj.GetTimeout()
 	ctx, cancel := context.WithTimeout(ctx, timeout)
 	defer cancel()
@@ -418,24 +419,30 @@ func (r *ImageRepositoryReconciler) scan(ctx context.Context, obj *imagev1.Image
 
 	tags, err := remote.List(ref.Context(), options...)
 	if err != nil {
-		return 0, err
+		return err
 	}
 
 	filteredTags, err := filterOutTags(tags, obj.GetExclusionList())
 	if err != nil {
-		return 0, err
+		return err
+	}
+
+	latestTags := sortTagsAndGetLatestTags(filteredTags)
+	if len(latestTags) == 0 {
+		latestTags = nil // for omission in json serialization when empty
 	}
 
 	canonicalName := ref.Context().String()
-	if err := r.Database.SetTags(canonicalName, filteredTags); err != nil {
-		return 0, fmt.Errorf("failed to set tags for %q: %w", canonicalName, err)
+	checksum, err := r.Database.SetTags(canonicalName, filteredTags)
+	if err != nil {
+		return fmt.Errorf("failed to set tags for %q: %w", canonicalName, err)
 	}
 
-	scanTime := metav1.Now()
 	obj.Status.LastScanResult = &imagev1.ScanResult{
+		Revision:   checksum,
 		TagCount:   len(filteredTags),
-		ScanTime:   scanTime,
-		LatestTags: getLatestTags(filteredTags),
+		ScanTime:   metav1.Now(),
+		LatestTags: latestTags,
 	}
 
 	// If the reconcile request annotation was set, consider it
@@ -445,7 +452,7 @@ func (r *ImageRepositoryReconciler) scan(ctx context.Context, obj *imagev1.Image
 		obj.Status.SetLastHandledReconcileRequest(token)
 	}
 
-	return len(filteredTags), nil
+	return nil
 }
 
 // reconcileDelete handles the deletion of the object.
@@ -507,19 +514,15 @@ 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
+// sortTagsAndGetLatestTags takes a slice of tags, sorts them in-place
+// in descending order of their values and returns the 10 latest tags.
+func sortTagsAndGetLatestTags(tags []string) []string {
+	slices.SortStableFunc(tags, func(a, b string) int { return -strings.Compare(a, b) })
+	latestTags := tags[:min(len(tags), latestTagsCount)]
+	// We can't return a slice of the original slice here because the original
+	// slice can be too large and we want to free up that memory. Our copy has
+	// at most latestTagsCount elements, which is specifically a small number.
+	return slices.Clone(latestTags)
 }
 
 // isEqualSliceContent compares two string slices to check if they have the same
diff --git a/internal/database/badger.go b/internal/database/badger.go
index 5efb4fb..f68501f 100644
--- a/internal/database/badger.go
+++ b/internal/database/badger.go
@@ -18,6 +18,7 @@ package database
 import (
 	"encoding/json"
 	"fmt"
+	"hash/adler32"
 
 	"github.com/dgraph-io/badger/v3"
 )
@@ -54,15 +55,19 @@ func (a *BadgerDatabase) Tags(repo string) ([]string, error) {
 // the repo.
 //
 // It overwrites existing tag sets for the provided repo.
-func (a *BadgerDatabase) SetTags(repo string, tags []string) error {
+func (a *BadgerDatabase) SetTags(repo string, tags []string) (string, error) {
 	b, err := marshal(tags)
 	if err != nil {
-		return err
+		return "", err
 	}
-	return a.db.Update(func(txn *badger.Txn) error {
+	err = a.db.Update(func(txn *badger.Txn) error {
 		e := badger.NewEntry(keyForRepo(tagsPrefix, repo), b)
 		return txn.SetEntry(e)
 	})
+	if err != nil {
+		return "", err
+	}
+	return fmt.Sprintf("%v", adler32.Checksum(b)), nil
 }
 
 func keyForRepo(prefix, repo string) []byte {


Test patch:
diff --git a/internal/controller/imagepolicy_controller_test.go b/internal/controller/imagepolicy_controller_test.go
index 2430be5..44c93ae 100644
--- a/internal/controller/imagepolicy_controller_test.go
+++ b/internal/controller/imagepolicy_controller_test.go
@@ -185,6 +185,147 @@ func TestImagePolicyReconciler_ignoresImageRepoNotReadyEvent(t *testing.T) {
 	}).Should(BeTrue())
 }
 
+func TestImagePolicyReconciler_imageRepoRevisionLifeCycle(t *testing.T) {
+	g := NewWithT(t)
+
+	namespaceName := "imagepolicy-" + randStringRunes(5)
+	namespace := &corev1.Namespace{
+		ObjectMeta: metav1.ObjectMeta{Name: namespaceName},
+	}
+	g.Expect(k8sClient.Create(ctx, namespace)).ToNot(HaveOccurred())
+	t.Cleanup(func() {
+		g.Expect(k8sClient.Delete(ctx, namespace)).NotTo(HaveOccurred())
+	})
+
+	imageRepo := &imagev1.ImageRepository{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: namespaceName,
+			Name:      "repo",
+		},
+		Spec: imagev1.ImageRepositorySpec{
+			Image: "ghcr.io/stefanprodan/podinfo",
+		},
+	}
+	g.Expect(k8sClient.Create(ctx, imageRepo)).NotTo(HaveOccurred())
+	t.Cleanup(func() {
+		g.Expect(k8sClient.Delete(ctx, imageRepo)).NotTo(HaveOccurred())
+	})
+
+	g.Eventually(func() bool {
+		err := k8sClient.Get(ctx, client.ObjectKeyFromObject(imageRepo), imageRepo)
+		return err == nil && conditions.IsReady(imageRepo) &&
+			imageRepo.Generation == conditions.GetObservedGeneration(imageRepo, meta.ReadyCondition)
+	}, timeout).Should(BeTrue())
+
+	imagePolicy := &imagev1.ImagePolicy{
+		ObjectMeta: metav1.ObjectMeta{
+			Namespace: namespaceName,
+			Name:      "test-imagepolicy",
+		},
+		Spec: imagev1.ImagePolicySpec{
+			ImageRepositoryRef: meta.NamespacedObjectReference{
+				Name: imageRepo.Name,
+			},
+			FilterTags: &imagev1.TagFilter{
+				Pattern: `^6\.7\.\d+$`,
+			},
+			Policy: imagev1.ImagePolicyChoice{
+				SemVer: &imagev1.SemVerPolicy{
+					Range: "6.7.x",
+				},
+			},
+		},
+	}
+	g.Expect(k8sClient.Create(ctx, imagePolicy)).NotTo(HaveOccurred())
+	t.Cleanup(func() {
+		g.Expect(k8sClient.Delete(ctx, imagePolicy)).NotTo(HaveOccurred())
+	})
+
+	g.Eventually(func() bool {
+		err := k8sClient.Get(ctx, client.ObjectKeyFromObject(imagePolicy), imagePolicy)
+		return err == nil && conditions.IsReady(imagePolicy) &&
+			imagePolicy.Generation == conditions.GetObservedGeneration(imagePolicy, meta.ReadyCondition) &&
+			imagePolicy.Status.LatestRef != nil &&
+			imagePolicy.Status.LatestRef.Tag == "6.7.1"
+	}, timeout).Should(BeTrue())
+	expectedImagePolicyLastTransitionTime := conditions.GetLastTransitionTime(imagePolicy, meta.ReadyCondition).Time
+
+	// Now force a reconciliation by setting the annotation.
+	var requestedAt string
+	g.Eventually(func() bool {
+		err := k8sClient.Get(ctx, client.ObjectKeyFromObject(imageRepo), imageRepo)
+		if err != nil {
+			return false
+		}
+		p := patch.NewSerialPatcher(imageRepo, k8sClient)
+		requestedAt = time.Now().Format(time.RFC3339Nano)
+		if imageRepo.Annotations == nil {
+			imageRepo.Annotations = make(map[string]string)
+		}
+		imageRepo.Annotations["reconcile.fluxcd.io/requestedAt"] = requestedAt
+		return p.Patch(ctx, imageRepo) == nil
+	}, timeout).Should(BeTrue())
+
+	// Wait for the ImageRepository to reconcile.
+	g.Eventually(func() bool {
+		err := k8sClient.Get(ctx, client.ObjectKeyFromObject(imageRepo), imageRepo)
+		return err == nil && conditions.IsReady(imageRepo) &&
+			imageRepo.Status.LastHandledReconcileAt == requestedAt
+	}, timeout).Should(BeTrue())
+
+	// Check that the ImagePolicy is still ready and does not get updated.
+	g.Eventually(func() bool {
+		err := k8sClient.Get(ctx, client.ObjectKeyFromObject(imagePolicy), imagePolicy)
+		return err == nil && conditions.IsReady(imagePolicy) &&
+			imagePolicy.Status.LatestRef != nil &&
+			imagePolicy.Status.LatestRef.Tag == "6.7.1"
+	}, timeout).Should(BeTrue())
+
+	// Wait a bit and check that the ImagePolicy remains ready.
+	time.Sleep(time.Second)
+	g.Eventually(func() bool {
+		err := k8sClient.Get(ctx, client.ObjectKeyFromObject(imagePolicy), imagePolicy)
+		return err == nil && conditions.IsReady(imagePolicy) &&
+			imagePolicy.Status.LatestRef != nil &&
+			imagePolicy.Status.LatestRef.Tag == "6.7.1"
+	}, timeout).Should(BeTrue())
+
+	// Check that the last transition time of the ImagePolicy Ready condition did not change since the beginning.
+	lastTransitionTime := conditions.GetLastTransitionTime(imagePolicy, meta.ReadyCondition).Time
+	g.Expect(lastTransitionTime).To(Equal(expectedImagePolicyLastTransitionTime))
+
+	// Now add an exclusion rule to force the checksum to change.
+	firstChecksum := imageRepo.Status.LastScanResult.Revision
+	g.Eventually(func() bool {
+		err := k8sClient.Get(ctx, client.ObjectKeyFromObject(imageRepo), imageRepo)
+		if err != nil {
+			return false
+		}
+		p := patch.NewSerialPatcher(imageRepo, k8sClient)
+		imageRepo.Spec.ExclusionList = []string{`^6\.7\.1$`}
+		return p.Patch(ctx, imageRepo) == nil
+	}, timeout).Should(BeTrue())
+
+	// Wait for the ImageRepository to reconcile.
+	g.Eventually(func() bool {
+		err := k8sClient.Get(ctx, client.ObjectKeyFromObject(imageRepo), imageRepo)
+		return err == nil && conditions.IsReady(imageRepo) &&
+			imageRepo.Generation == conditions.GetObservedGeneration(imageRepo, meta.ReadyCondition) &&
+			imageRepo.Status.LastScanResult.Revision != firstChecksum
+	}, timeout).Should(BeTrue())
+
+	// Check that the ImagePolicy receives the update and the latest tag changes.
+	g.Eventually(func() bool {
+		err := k8sClient.Get(ctx, client.ObjectKeyFromObject(imagePolicy), imagePolicy)
+		if err != nil {
+			return false
+		}
+		return conditions.IsReady(imagePolicy) &&
+			imagePolicy.Generation == conditions.GetObservedGeneration(imagePolicy, meta.ReadyCondition) &&
+			imagePolicy.Status.LatestRef.Tag == "6.7.0"
+	}, timeout).Should(BeTrue())
+}
+
 func TestImagePolicyReconciler_invalidImage(t *testing.T) {
 	g := NewWithT(t)
 
diff --git a/internal/controller/imagerepository_controller_test.go b/internal/controller/imagerepository_controller_test.go
index 7090099..079eb25 100644
--- a/internal/controller/imagerepository_controller_test.go
+++ b/internal/controller/imagerepository_controller_test.go
@@ -22,8 +22,10 @@ import (
 	"crypto/x509"
 	"errors"
 	"fmt"
+	"hash/adler32"
 	"net/http"
 	"net/url"
+	"strings"
 	"testing"
 	"time"
 
@@ -56,12 +58,12 @@ type mockDatabase struct {
 }
 
 // SetTags implements the DatabaseWriter interface of the Database.
-func (db *mockDatabase) SetTags(repo string, tags []string) error {
+func (db *mockDatabase) SetTags(repo string, tags []string) (string, error) {
 	if db.WriteError != nil {
-		return db.WriteError
+		return "", db.WriteError
 	}
 	db.TagData = append(db.TagData, tags...)
-	return nil
+	return fmt.Sprintf("%v", adler32.Checksum([]byte(strings.Join(tags, ",")))), nil
 }
 
 // Tags implements the DatabaseReader interface of the Database.
@@ -277,66 +279,74 @@ func TestImageRepositoryReconciler_scan(t *testing.T) {
 	proxyAddr, proxyPort := test.NewProxy(t)
 
 	tests := []struct {
-		name           string
-		tags           []string
-		exclusionList  []string
-		annotation     string
-		db             *mockDatabase
-		proxyURL       *url.URL
-		wantErr        string
-		wantTags       []string
-		wantLatestTags []string
+		name          string
+		tags          []string
+		exclusionList []string
+		annotation    string
+		db            *mockDatabase
+		proxyURL      *url.URL
+		wantErr       string
+		wantChecksum  string
+		wantTags      []string
 	}{
 		{
 			name:    "no tags",
 			wantErr: "404 Not Found",
 		},
 		{
-			name:           "simple tags",
-			tags:           []string{"a", "b", "c", "d"},
-			db:             &mockDatabase{},
-			wantTags:       []string{"a", "b", "c", "d"},
-			wantLatestTags: []string{"d", "c", "b", "a"},
+			name:     "simple tags",
+			tags:     []string{"a", "b", "c", "d"},
+			db:       &mockDatabase{},
+			wantTags: []string{"d", "c", "b", "a"},
 		},
 		{
-			name:           "simple tags with proxy",
-			tags:           []string{"a", "b", "c", "d"},
-			db:             &mockDatabase{},
-			proxyURL:       &url.URL{Scheme: "http", Host: proxyAddr},
-			wantTags:       []string{"a", "b", "c", "d"},
-			wantLatestTags: []string{"d", "c", "b", "a"},
+			name:         "tags are sorted for checksum - order 1",
+			tags:         []string{"c", "d", "a", "b"},
+			db:           &mockDatabase{},
+			wantChecksum: "139002383",
+			wantTags:     []string{"d", "c", "b", "a"},
 		},
 		{
-			name:           "simple tags with incorrect proxy",
-			tags:           []string{"a", "b", "c", "d"},
-			db:             &mockDatabase{},
-			proxyURL:       &url.URL{Scheme: "http", Host: fmt.Sprintf("localhost:%d", proxyPort+1)},
-			wantErr:        "connection refused",
-			wantTags:       []string{"a", "b", "c", "d"},
-			wantLatestTags: []string{"d", "c", "b", "a"},
+			name:         "tags are sorted for checksum - order 2",
+			tags:         []string{"c", "b", "a", "d"},
+			db:           &mockDatabase{},
+			wantChecksum: "139002383",
+			wantTags:     []string{"d", "c", "b", "a"},
 		},
 		{
-			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:     "simple tags with proxy",
+			tags:     []string{"a", "b", "c", "d"},
+			db:       &mockDatabase{},
+			proxyURL: &url.URL{Scheme: "http", Host: proxyAddr},
+			wantTags: []string{"d", "c", "b", "a"},
+		},
+		{
+			name:     "simple tags with incorrect proxy",
+			tags:     []string{"a", "b", "c", "d"},
+			db:       &mockDatabase{},
+			proxyURL: &url.URL{Scheme: "http", Host: fmt.Sprintf("localhost:%d", proxyPort+1)},
+			wantErr:  "connection refused",
+			wantTags: []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"},
-			wantLatestTags: []string{"d", "b", "a"},
+			name:     "more than maximum amount of tags for latest tags",
+			tags:     []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"},
+			db:       &mockDatabase{},
+			wantTags: []string{"k", "j", "i", "h", "g", "f", "e", "d", "c", "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:          "with single exclusion pattern",
+			tags:          []string{"a", "b", "c", "d"},
+			exclusionList: []string{"c"},
+			db:            &mockDatabase{},
+			wantTags:      []string{"d", "b", "a"},
+		},
+		{
+			name:          "with multiple exclusion pattern",
+			tags:          []string{"a", "b", "c", "d"},
+			exclusionList: []string{"c", "a"},
+			db:            &mockDatabase{},
+			wantTags:      []string{"d", "b"},
 		},
 		{
 			name:          "bad exclusion pattern",
@@ -351,12 +361,11 @@ func TestImageRepositoryReconciler_scan(t *testing.T) {
 			wantErr: "failed to set tags",
 		},
 		{
-			name:           "with reconcile annotation",
-			tags:           []string{"a", "b"},
-			annotation:     "foo",
-			db:             &mockDatabase{},
-			wantTags:       []string{"a", "b"},
-			wantLatestTags: []string{"b", "a"},
+			name:       "with reconcile annotation",
+			tags:       []string{"a", "b"},
+			annotation: "foo",
+			db:         &mockDatabase{},
+			wantTags:   []string{"b", "a"},
 		},
 	}
 
@@ -393,7 +402,7 @@ func TestImageRepositoryReconciler_scan(t *testing.T) {
 				opts = append(opts, remote.WithTransport(tr))
 			}
 
-			tagCount, err := r.scan(context.TODO(), repo, ref, opts)
+			err = r.scan(context.TODO(), repo, ref, opts)
 			if tt.wantErr != "" {
 				g.Expect(err).To(HaveOccurred())
 				g.Expect(err.Error()).To(ContainSubstring(tt.wantErr))
@@ -401,10 +410,14 @@ func TestImageRepositoryReconciler_scan(t *testing.T) {
 				g.Expect(err).NotTo(HaveOccurred())
 			}
 			if err == nil {
-				g.Expect(tagCount).To(Equal(len(tt.wantTags)))
 				g.Expect(r.Database.Tags(imgRepo)).To(Equal(tt.wantTags))
+				if tt.wantChecksum != "" {
+					g.Expect(repo.Status.LastScanResult.Revision).To(Equal(tt.wantChecksum))
+				}
 				g.Expect(repo.Status.LastScanResult.TagCount).To(Equal(len(tt.wantTags)))
 				g.Expect(repo.Status.LastScanResult.ScanTime).ToNot(BeZero())
+				g.Expect(len(repo.Status.LastScanResult.LatestTags)).To(BeNumerically("<=", latestTagsCount))
+				g.Expect(repo.Status.LastScanResult.LatestTags).To(Equal(tt.wantTags[:min(len(tt.wantTags), latestTagsCount)]))
 				if tt.annotation != "" {
 					g.Expect(repo.Status.LastHandledReconcileAt).To(Equal(tt.annotation))
 				}
@@ -413,7 +426,7 @@ func TestImageRepositoryReconciler_scan(t *testing.T) {
 	}
 }
 
-func TestGetLatestTags(t *testing.T) {
+func TestSortTagsAndGetLatestTags(t *testing.T) {
 	tests := []struct {
 		name           string
 		tags           []string
@@ -464,7 +477,7 @@ func TestGetLatestTags(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			g := NewWithT(t)
 
-			g.Expect(getLatestTags(tt.tags)).To(Equal(tt.wantLatestTags))
+			g.Expect(sortTagsAndGetLatestTags(tt.tags)).To(Equal(tt.wantLatestTags))
 		})
 	}
 }
diff --git a/internal/database/badger_gc_test.go b/internal/database/badger_gc_test.go
index 61bb5c8..f43af85 100644
--- a/internal/database/badger_gc_test.go
+++ b/internal/database/badger_gc_test.go
@@ -42,8 +42,9 @@ func TestBadgerGarbageCollectorDoesStop(t *testing.T) {
 	time.Sleep(time.Second)
 
 	tags := []string{"latest", "v0.0.1", "v0.0.2"}
-	fatalIfError(t, db.SetTags(testRepo, tags))
-	_, err := db.Tags(testRepo)
+	_, err := db.SetTags(testRepo, tags)
+	fatalIfError(t, err)
+	_, err = db.Tags(testRepo)
 	fatalIfError(t, err)
 	t.Log("wrote tags successfully")
 
diff --git a/internal/database/badger_test.go b/internal/database/badger_test.go
index 36730fd..b33859f 100644
--- a/internal/database/badger_test.go
+++ b/internal/database/badger_test.go
@@ -16,6 +16,7 @@ limitations under the License.
 package database
 
 import (
+	"fmt"
 	"os"
 	"reflect"
 	"testing"
@@ -40,7 +41,7 @@ func TestSetTags(t *testing.T) {
 	db := createBadgerDatabase(t)
 	tags := []string{"latest", "v0.0.1", "v0.0.2"}
 
-	fatalIfError(t, db.SetTags(testRepo, tags))
+	fatalIfError(t, setTags(t, db, testRepo, tags, "1943865137"))
 
 	loaded, err := db.Tags(testRepo)
 	fatalIfError(t, err)
@@ -53,9 +54,9 @@ func TestSetTagsOverwrites(t *testing.T) {
 	db := createBadgerDatabase(t)
 	tags1 := []string{"latest", "v0.0.1", "v0.0.2"}
 	tags2 := []string{"latest", "v0.0.1", "v0.0.2", "v0.0.3"}
-	fatalIfError(t, db.SetTags(testRepo, tags1))
+	fatalIfError(t, setTags(t, db, testRepo, tags1, "1943865137"))
 
-	fatalIfError(t, db.SetTags(testRepo, tags2))
+	fatalIfError(t, setTags(t, db, testRepo, tags2, "3168012550"))
 
 	loaded, err := db.Tags(testRepo)
 	fatalIfError(t, err)
@@ -67,10 +68,10 @@ func TestSetTagsOverwrites(t *testing.T) {
 func TestGetOnlyFetchesForRepo(t *testing.T) {
 	db := createBadgerDatabase(t)
 	tags1 := []string{"latest", "v0.0.1", "v0.0.2"}
-	fatalIfError(t, db.SetTags(testRepo, tags1))
+	fatalIfError(t, setTags(t, db, testRepo, tags1, "1943865137"))
 	testRepo2 := "another/repo"
 	tags2 := []string{"v0.0.3", "v0.0.4"}
-	fatalIfError(t, db.SetTags(testRepo2, tags2))
+	fatalIfError(t, setTags(t, db, testRepo2, tags2, "728958008"))
 
 	loaded, err := db.Tags(testRepo)
 	fatalIfError(t, err)
@@ -96,6 +97,18 @@ func createBadgerDatabase(t *testing.T) *BadgerDatabase {
 	return NewBadgerDatabase(db)
 }
 
+func setTags(t *testing.T, db *BadgerDatabase, repo string, tags []string, expectedChecksum string) error {
+	t.Helper()
+	checksum, err := db.SetTags(repo, tags)
+	if err != nil {
+		return err
+	}
+	if checksum != expectedChecksum {
+		return fmt.Errorf("SetTags returned unexpected checksum: got %s, want %s", checksum, expectedChecksum)
+	}
+	return nil
+}
+
 func fatalIfError(t *testing.T, err error) {
 	t.Helper()
 	if err != nil {