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:
commit
05a6e55930
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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’s the first 10 tags when sorting all the tags in descending
|
||||
alphabetical order.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue