Merge pull request #780 from fluxcd/feat-778

Store checksum of ImageRepository tags and trigger ImagePolicy watch only when it changes
This commit is contained in:
Matheus Pimenta 2025-06-22 18:54:28 +01:00 committed by GitHub
commit 05a6e55930
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 338 additions and 111 deletions

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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) {

View File

@ -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)

View File

@ -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

View File

@ -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: "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: "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 multiple exclusion pattern",
tags: []string{"a", "b", "c", "d"},
exclusionList: []string{"c", "a"},
db: &mockDatabase{},
wantTags: []string{"b", "d"},
wantLatestTags: []string{"d", "b"},
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 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))
})
}
}

View File

@ -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 {

View File

@ -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")

View File

@ -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 {