diff --git a/api/v1beta2/imagepolicy_types.go b/api/v1beta2/imagepolicy_types.go index a7369a77..672d3665 100644 --- a/api/v1beta2/imagepolicy_types.go +++ b/api/v1beta2/imagepolicy_types.go @@ -105,6 +105,10 @@ type ImagePolicyStatus struct { // the image repository, when filtered and ordered according to // the policy. LatestImage string `json:"latestImage,omitempty"` + // LatestDigest is the digest of the latest image stored in the + // accompanying LatestImage field. + // +optional + LatestDigest string `json:"latestDigest,omitempty"` // ObservedPreviousImage is the observed previous LatestImage. It is used // to keep track of the previous and current images. // +optional diff --git a/config/crd/bases/image.toolkit.fluxcd.io_imagepolicies.yaml b/config/crd/bases/image.toolkit.fluxcd.io_imagepolicies.yaml index 4e6ec8af..4c09094a 100644 --- a/config/crd/bases/image.toolkit.fluxcd.io_imagepolicies.yaml +++ b/config/crd/bases/image.toolkit.fluxcd.io_imagepolicies.yaml @@ -383,6 +383,10 @@ spec: - type type: object type: array + latestDigest: + description: LatestDigest is the digest of the latest image stored + in the accompanying LatestImage field. + type: string latestImage: description: LatestImage gives the first in the list of images scanned by the image repository, when filtered and ordered according to diff --git a/docs/api/image-reflector.md b/docs/api/image-reflector.md index f4eeceeb..b0b81091 100644 --- a/docs/api/image-reflector.md +++ b/docs/api/image-reflector.md @@ -313,6 +313,19 @@ the policy.

+latestDigest
+ +string + + + +(Optional) +

LatestDigest is the digest of the latest image stored in the +accompanying LatestImage field.

+ + + + observedPreviousImage
string diff --git a/internal/controllers/controllers_fuzzer_test.go b/internal/controllers/controllers_fuzzer_test.go index be586b03..b40d92da 100644 --- a/internal/controllers/controllers_fuzzer_test.go +++ b/internal/controllers/controllers_fuzzer_test.go @@ -50,7 +50,7 @@ import ( fuzz "github.com/AdaLogics/go-fuzz-headers" imagev1 "github.com/fluxcd/image-reflector-controller/api/v1beta2" - "github.com/fluxcd/image-reflector-controller/internal/database" + ircbadger "github.com/fluxcd/image-reflector-controller/internal/database/badger" "github.com/fluxcd/image-reflector-controller/internal/test" ) @@ -252,7 +252,7 @@ func initFunc() { imageRepoReconciler = &ImageRepositoryReconciler{ Client: k8sMgr.GetClient(), - Database: database.NewBadgerDatabase(badgerDB), + Database: ircbadger.NewBadgerDatabase(badgerDB), EventRecorder: record.NewFakeRecorder(256), patchOptions: getPatchOptions(imageRepositoryOwnedConditions, "irc"), } @@ -263,7 +263,7 @@ func initFunc() { imagePolicyReconciler = &ImagePolicyReconciler{ Client: k8sMgr.GetClient(), - Database: database.NewBadgerDatabase(badgerDB), + Database: ircbadger.NewBadgerDatabase(badgerDB), EventRecorder: record.NewFakeRecorder(256), patchOptions: getPatchOptions(imagePolicyOwnedConditions, "irc"), } diff --git a/internal/controllers/imagepolicy_controller.go b/internal/controllers/imagepolicy_controller.go index 4e4b7f77..588c60bc 100644 --- a/internal/controllers/imagepolicy_controller.go +++ b/internal/controllers/imagepolicy_controller.go @@ -48,6 +48,7 @@ import ( pkgreconcile "github.com/fluxcd/pkg/runtime/reconcile" imagev1 "github.com/fluxcd/image-reflector-controller/api/v1beta2" + "github.com/fluxcd/image-reflector-controller/internal/database" "github.com/fluxcd/image-reflector-controller/internal/policy" ) @@ -108,7 +109,7 @@ type ImagePolicyReconciler struct { helper.Metrics ControllerName string - Database DatabaseReader + Database database.DatabaseReader ACLOptions acl.Options patchOptions []patch.Option @@ -259,6 +260,7 @@ func (r *ImagePolicyReconciler) reconcile(ctx context.Context, sp *patch.SerialP // Cleanup the last result. obj.Status.LatestImage = "" + obj.Status.LatestDigest = "" // Get ImageRepository from reference. repo, err := r.getImageRepository(ctx, obj) @@ -317,7 +319,8 @@ func (r *ImagePolicyReconciler) reconcile(ctx context.Context, sp *patch.SerialP } // Write the observations on status. - obj.Status.LatestImage = repo.Spec.Image + ":" + latest + obj.Status.LatestImage = repo.Spec.Image + ":" + latest.Name + obj.Status.LatestDigest = latest.Digest // 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 @@ -340,7 +343,7 @@ func (r *ImagePolicyReconciler) reconcile(ctx context.Context, sp *patch.SerialP } resultImage = repo.Spec.Image - resultTag = latest + resultTag = latest.Name conditions.Delete(obj, meta.ReadyCondition) @@ -386,36 +389,37 @@ func (r *ImagePolicyReconciler) getImageRepository(ctx context.Context, obj *ima // applyPolicy reads the tags of the given repository from the internal database // and applies the tag filters and constraints to return the latest image. -func (r *ImagePolicyReconciler) applyPolicy(ctx context.Context, obj *imagev1.ImagePolicy, repo *imagev1.ImageRepository) (string, error) { +func (r *ImagePolicyReconciler) applyPolicy(ctx context.Context, obj *imagev1.ImagePolicy, repo *imagev1.ImageRepository) (*database.Tag, error) { policer, err := policy.PolicerFromSpec(obj.Spec.Policy) if err != nil { - return "", errInvalidPolicy{err: fmt.Errorf("invalid policy: %w", err)} + return nil, errInvalidPolicy{err: fmt.Errorf("invalid policy: %w", err)} } // Read tags from database, apply and filter is configured and compute the // result. tags, err := r.Database.Tags(repo.Status.CanonicalImageName) if err != nil { - return "", fmt.Errorf("failed to read tags from database: %w", err) + return nil, fmt.Errorf("failed to read tags from database: %w", err) } if len(tags) == 0 { - return "", errNoTagsInDatabase + return nil, errNoTagsInDatabase } // Apply tag filter. if obj.Spec.FilterTags != nil { filter, err := policy.NewRegexFilter(obj.Spec.FilterTags.Pattern, obj.Spec.FilterTags.Extract) if err != nil { - return "", errInvalidPolicy{err: fmt.Errorf("failed to filter tags: %w", err)} + return nil, errInvalidPolicy{err: fmt.Errorf("failed to filter tags: %w", err)} } filter.Apply(tags) - tags = filter.Items() - latest, err := policer.Latest(tags) + tagNames := filter.Items() + latest, err := policer.Latest(tagNames) if err != nil { - return "", err + return nil, err } - return filter.GetOriginalTag(latest), nil + origTag := filter.GetOriginalTag(latest.Name) + return &origTag, nil } // Compute and return result. return policer.Latest(tags) diff --git a/internal/controllers/imagepolicy_controller_test.go b/internal/controllers/imagepolicy_controller_test.go index f12aebae..f754e77c 100644 --- a/internal/controllers/imagepolicy_controller_test.go +++ b/internal/controllers/imagepolicy_controller_test.go @@ -31,6 +31,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" imagev1 "github.com/fluxcd/image-reflector-controller/api/v1beta2" + "github.com/fluxcd/image-reflector-controller/internal/database" "github.com/fluxcd/image-reflector-controller/internal/policy" ) @@ -231,7 +232,7 @@ func TestImagePolicyReconciler_applyPolicy(t *testing.T) { filter *imagev1.TagFilter db *mockDatabase wantErr bool - wantResult string + wantResult *database.Tag }{ { name: "invalid policy", @@ -251,16 +252,21 @@ func TestImagePolicyReconciler_applyPolicy(t *testing.T) { wantErr: true, }, { - name: "semver, no tag filter", - policy: imagev1.ImagePolicyChoice{SemVer: &imagev1.SemVerPolicy{Range: "1.0.x"}}, - db: &mockDatabase{TagData: []string{"1.0.0", "2.0.0", "1.0.1", "1.2.0"}}, - wantResult: "1.0.1", + name: "semver, no tag filter", + policy: imagev1.ImagePolicyChoice{SemVer: &imagev1.SemVerPolicy{Range: "1.0.x"}}, + db: &mockDatabase{TagData: []database.Tag{ + {Name: "1.0.0"}, + {Name: "2.0.0"}, + {Name: "1.0.1"}, + {Name: "1.2.0"}, + }}, + wantResult: &database.Tag{Name: "1.0.1"}, }, { name: "invalid tag filter", policy: imagev1.ImagePolicyChoice{SemVer: &imagev1.SemVerPolicy{Range: "1.0.x"}}, filter: &imagev1.TagFilter{Pattern: "[="}, - db: &mockDatabase{TagData: []string{"1.0.0", "1.0.1"}}, + db: &mockDatabase{TagData: []database.Tag{{Name: "1.0.0"}, {Name: "1.0.1"}}}, wantErr: true, }, { @@ -270,10 +276,14 @@ func TestImagePolicyReconciler_applyPolicy(t *testing.T) { Pattern: "1.0.0-rc\\.(?P[0-9]+)", Extract: "$num", }, - db: &mockDatabase{TagData: []string{ - "1.0.0", "1.0.0-rc.1", "1.0.0-rc.2", "1.0.0-rc.3", "1.0.1-rc.2", + db: &mockDatabase{TagData: []database.Tag{ + {Name: "1.0.0"}, + {Name: "1.0.0-rc.1"}, + {Name: "1.0.0-rc.2"}, + {Name: "1.0.0-rc.3"}, + {Name: "1.0.1-rc.2"}, }}, - wantResult: "1.0.0-rc.3", + wantResult: &database.Tag{Name: "1.0.0-rc.3"}, }, { name: "valid tag filter with alphabetical policy", @@ -282,10 +292,14 @@ func TestImagePolicyReconciler_applyPolicy(t *testing.T) { Pattern: "foo-(?P[a-z]+)", Extract: "$word", }, - db: &mockDatabase{TagData: []string{ - "foo-aaa", "bar-bbb", "foo-zzz", "baz-nnn", "foo-ooo", + db: &mockDatabase{TagData: []database.Tag{ + {Name: "foo-aaa"}, + {Name: "bar-bbb"}, + {Name: "foo-zzz"}, + {Name: "baz-nnn"}, + {Name: "foo-ooo"}, }}, - wantResult: "foo-zzz", + wantResult: &database.Tag{Name: "foo-zzz"}, }, } diff --git a/internal/controllers/imagerepository_controller.go b/internal/controllers/imagerepository_controller.go index b19ed92a..9caff1a1 100644 --- a/internal/controllers/imagerepository_controller.go +++ b/internal/controllers/imagerepository_controller.go @@ -23,6 +23,7 @@ import ( "regexp" "sort" "strings" + "sync" "time" "github.com/google/go-containerregistry/pkg/authn" @@ -54,6 +55,7 @@ import ( "github.com/fluxcd/pkg/runtime/reconcile" imagev1 "github.com/fluxcd/image-reflector-controller/api/v1beta2" + "github.com/fluxcd/image-reflector-controller/internal/database" "github.com/fluxcd/image-reflector-controller/internal/secret" ) @@ -109,10 +111,11 @@ type ImageRepositoryReconciler struct { ControllerName string Database interface { - DatabaseWriter - DatabaseReader + database.DatabaseWriter + database.DatabaseReader } DeprecatedLoginOpts login.ProviderOptions + FetchDigests bool patchOptions []patch.Option } @@ -516,8 +519,44 @@ func (r *ImageRepositoryReconciler) scan(ctx context.Context, obj *imagev1.Image return 0, err } + storedTags := make([]database.Tag, 0, len(filteredTags)) + + resCh := make(chan database.Tag, len(filteredTags)) + var wg sync.WaitGroup + for _, tag := range filteredTags { + wg.Add(1) + go func(tag string, ch chan database.Tag) { + defer wg.Done() + res := database.Tag{ + Name: tag, + } + + if r.FetchDigests { + tagRef, err := name.ParseReference(strings.Join([]string{ref.Context().Name(), tag}, ":")) + if err != nil { + return + } + desc, err := remote.Head(tagRef, remote.WithContext(ctx)) + if err != nil { + return + } + res.Digest = desc.Digest.String() + } + + resCh <- res + + }(tag, resCh) + } + + wg.Wait() + close(resCh) + + for t := range resCh { + storedTags = append(storedTags, t) + } + canonicalName := ref.Context().String() - if err := r.Database.SetTags(canonicalName, filteredTags); err != nil { + if err := r.Database.SetTags(canonicalName, storedTags); err != nil { return 0, fmt.Errorf("failed to set tags for %q: %w", canonicalName, err) } diff --git a/internal/controllers/imagerepository_controller_test.go b/internal/controllers/imagerepository_controller_test.go index 4c3087a5..4f5d8769 100644 --- a/internal/controllers/imagerepository_controller_test.go +++ b/internal/controllers/imagerepository_controller_test.go @@ -35,19 +35,23 @@ import ( "github.com/fluxcd/pkg/runtime/conditions" imagev1 "github.com/fluxcd/image-reflector-controller/api/v1beta2" + "github.com/fluxcd/image-reflector-controller/internal/database" "github.com/fluxcd/image-reflector-controller/internal/secret" "github.com/fluxcd/image-reflector-controller/internal/test" ) // mockDatabase mocks the image repository database. type mockDatabase struct { - TagData []string + TagData []database.Tag ReadError error WriteError error } +var _ database.DatabaseReader = mockDatabase{} +var _ database.DatabaseWriter = &mockDatabase{} + // SetTags implements the DatabaseWriter interface of the Database. -func (db *mockDatabase) SetTags(repo string, tags []string) error { +func (db *mockDatabase) SetTags(repo string, tags []database.Tag) error { if db.WriteError != nil { return db.WriteError } @@ -56,7 +60,7 @@ func (db *mockDatabase) SetTags(repo string, tags []string) error { } // Tags implements the DatabaseReader interface of the Database. -func (db mockDatabase) Tags(repo string) ([]string, error) { +func (db mockDatabase) Tags(repo string) ([]database.Tag, error) { if db.ReadError != nil { return nil, db.ReadError } @@ -324,7 +328,7 @@ func TestImageRepositoryReconciler_shouldScan(t *testing.T) { ScanTime: metav1.NewTime(reconcileTime.Add(-time.Second * 30)), } }, - db: &mockDatabase{TagData: []string{"foo"}}, + db: &mockDatabase{TagData: []database.Tag{{Name: "foo"}}}, wantScan: false, wantNextScan: time.Second * 30, }, @@ -339,7 +343,7 @@ func TestImageRepositoryReconciler_shouldScan(t *testing.T) { ScanTime: metav1.NewTime(reconcileTime.Add(-time.Second * 30)), } }, - db: &mockDatabase{TagData: []string{"foo"}}, + db: &mockDatabase{TagData: []database.Tag{{Name: "foo"}}}, wantScan: true, wantNextScan: time.Minute, wantReason: scanReasonNewImageName, @@ -355,7 +359,7 @@ func TestImageRepositoryReconciler_shouldScan(t *testing.T) { ScanTime: metav1.NewTime(reconcileTime.Add(-time.Second * 30)), } }, - db: &mockDatabase{TagData: []string{"foo"}}, + db: &mockDatabase{TagData: []database.Tag{{Name: "foo"}}}, wantScan: true, wantNextScan: time.Minute, wantReason: scanReasonUpdatedExclusionList, @@ -384,7 +388,7 @@ func TestImageRepositoryReconciler_shouldScan(t *testing.T) { ScanTime: metav1.NewTime(reconcileTime.Add(-time.Second * 30)), } }, - db: &mockDatabase{TagData: []string{"foo"}, ReadError: errors.New("fail")}, + db: &mockDatabase{TagData: []database.Tag{{Name: "foo"}}, ReadError: errors.New("fail")}, wantErr: true, wantScan: false, wantNextScan: time.Minute, @@ -398,7 +402,7 @@ func TestImageRepositoryReconciler_shouldScan(t *testing.T) { ScanTime: metav1.NewTime(reconcileTime.Add(-time.Minute * 2)), } }, - db: &mockDatabase{TagData: []string{"foo"}}, + db: &mockDatabase{TagData: []database.Tag{{Name: "foo"}}}, wantScan: true, wantNextScan: time.Minute, wantReason: scanReasonInterval, @@ -439,48 +443,43 @@ 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 - wantLatestTags []string + name string + tags []string + exclusionList []string + annotation string + db *mockDatabase + wantErr bool + wantTags []database.Tag }{ { name: "no tags", wantErr: true, }, { - 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: []database.Tag{{Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "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: "simple tags, 10+", + tags: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"}, + db: &mockDatabase{}, + wantTags: []database.Tag{{Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "d"}, {Name: "e"}, {Name: "f"}, {Name: "g"}, {Name: "h"}, {Name: "i"}, {Name: "j"}, {Name: "k"}}, }, { - 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 single exclusion pattern", + tags: []string{"a", "b", "c", "d"}, + exclusionList: []string{"c"}, + db: &mockDatabase{}, + wantTags: []database.Tag{{Name: "a"}, {Name: "b"}, {Name: "d"}}, }, { - 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 multiple exclusion pattern", + tags: []string{"a", "b", "c", "d"}, + exclusionList: []string{"c", "a"}, + db: &mockDatabase{}, + wantTags: []database.Tag{{Name: "b"}, {Name: "d"}}, }, { name: "bad exclusion pattern", @@ -495,12 +494,11 @@ func TestImageRepositoryReconciler_scan(t *testing.T) { wantErr: true, }, { - 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: []database.Tag{{Name: "a"}, {Name: "b"}}, }, } @@ -536,7 +534,7 @@ func TestImageRepositoryReconciler_scan(t *testing.T) { g.Expect(err != nil).To(Equal(tt.wantErr)) if err == nil { g.Expect(tagCount).To(Equal(len(tt.wantTags))) - g.Expect(r.Database.Tags(imgRepo)).To(Equal(tt.wantTags)) + g.Expect(r.Database.Tags(imgRepo)).To(ConsistOf(tt.wantTags)) g.Expect(repo.Status.LastScanResult.TagCount).To(Equal(len(tt.wantTags))) g.Expect(repo.Status.LastScanResult.ScanTime).ToNot(BeZero()) if tt.annotation != "" { diff --git a/internal/controllers/policy_test.go b/internal/controllers/policy_test.go index 2f3cb9ac..7d4b3bc8 100644 --- a/internal/controllers/policy_test.go +++ b/internal/controllers/policy_test.go @@ -36,7 +36,7 @@ import ( "github.com/fluxcd/pkg/runtime/patch" imagev1 "github.com/fluxcd/image-reflector-controller/api/v1beta2" - "github.com/fluxcd/image-reflector-controller/internal/database" + "github.com/fluxcd/image-reflector-controller/internal/database/badger" "github.com/fluxcd/image-reflector-controller/internal/test" // +kubebuilder:scaffold:imports ) @@ -110,7 +110,7 @@ func TestImagePolicyReconciler_crossNamespaceRefsDisallowed(t *testing.T) { r := &ImagePolicyReconciler{ Client: builder.Build(), - Database: database.NewBadgerDatabase(testBadgerDB), + Database: badger.NewBadgerDatabase(testBadgerDB), EventRecorder: record.NewFakeRecorder(32), ACLOptions: acl.Options{ NoCrossNamespaceRefs: true, diff --git a/internal/controllers/scan_test.go b/internal/controllers/scan_test.go index af30ef4e..f0d1a0fe 100644 --- a/internal/controllers/scan_test.go +++ b/internal/controllers/scan_test.go @@ -38,7 +38,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" imagev1 "github.com/fluxcd/image-reflector-controller/api/v1beta2" - "github.com/fluxcd/image-reflector-controller/internal/database" + "github.com/fluxcd/image-reflector-controller/internal/database/badger" "github.com/fluxcd/image-reflector-controller/internal/test" // +kubebuilder:scaffold:imports ) @@ -186,7 +186,7 @@ func TestImageRepositoryReconciler_repositorySuspended(t *testing.T) { r := &ImageRepositoryReconciler{ Client: builder.Build(), - Database: database.NewBadgerDatabase(testBadgerDB), + Database: badger.NewBadgerDatabase(testBadgerDB), patchOptions: getPatchOptions(imageRepositoryOwnedConditions, "irc"), } diff --git a/internal/controllers/suite_test.go b/internal/controllers/suite_test.go index 171fa1d5..ff7aabb9 100644 --- a/internal/controllers/suite_test.go +++ b/internal/controllers/suite_test.go @@ -34,7 +34,7 @@ import ( "github.com/fluxcd/pkg/runtime/testenv" imagev1 "github.com/fluxcd/image-reflector-controller/api/v1beta2" - "github.com/fluxcd/image-reflector-controller/internal/database" + ircbadger "github.com/fluxcd/image-reflector-controller/internal/database/badger" // +kubebuilder:scaffold:imports ) @@ -83,7 +83,7 @@ func TestMain(m *testing.M) { if err = (&ImageRepositoryReconciler{ Client: testEnv, - Database: database.NewBadgerDatabase(testBadgerDB), + Database: ircbadger.NewBadgerDatabase(testBadgerDB), EventRecorder: record.NewFakeRecorder(256), }).SetupWithManager(testEnv, ImageRepositoryReconcilerOptions{ RateLimiter: controller.GetDefaultRateLimiter(), @@ -93,7 +93,7 @@ func TestMain(m *testing.M) { if err = (&ImagePolicyReconciler{ Client: testEnv, - Database: database.NewBadgerDatabase(testBadgerDB), + Database: ircbadger.NewBadgerDatabase(testBadgerDB), EventRecorder: record.NewFakeRecorder(256), }).SetupWithManager(testEnv, ImagePolicyReconcilerOptions{ RateLimiter: controller.GetDefaultRateLimiter(), diff --git a/internal/database/badger.go b/internal/database/badger/badger.go similarity index 66% rename from internal/database/badger.go rename to internal/database/badger/badger.go index 5efb4fbe..22c5bcd8 100644 --- a/internal/database/badger.go +++ b/internal/database/badger/badger.go @@ -13,13 +13,15 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -package database +package badger import ( "encoding/json" "fmt" "github.com/dgraph-io/badger/v3" + + "github.com/fluxcd/image-reflector-controller/internal/database" ) const tagsPrefix = "tags" @@ -29,6 +31,9 @@ type BadgerDatabase struct { db *badger.DB } +var _ database.DatabaseWriter = &BadgerDatabase{} +var _ database.DatabaseReader = &BadgerDatabase{} + // NewBadgerDatabase creates and returns a new database implementation using // Badger for storing the image tags. func NewBadgerDatabase(db *badger.DB) *BadgerDatabase { @@ -40,8 +45,8 @@ func NewBadgerDatabase(db *badger.DB) *BadgerDatabase { // Tags implements the DatabaseReader interface, fetching the tags for the repo. // // If the repo does not exist, an empty set of tags is returned. -func (a *BadgerDatabase) Tags(repo string) ([]string, error) { - var tags []string +func (a *BadgerDatabase) Tags(repo string) ([]database.Tag, error) { + var tags []database.Tag err := a.db.View(func(txn *badger.Txn) error { var err error tags, err = getOrEmpty(txn, repo) @@ -54,7 +59,7 @@ 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 []database.Tag) error { b, err := marshal(tags) if err != nil { return err @@ -69,15 +74,15 @@ func keyForRepo(prefix, repo string) []byte { return []byte(fmt.Sprintf("%s:%s", prefix, repo)) } -func getOrEmpty(txn *badger.Txn, repo string) ([]string, error) { +func getOrEmpty(txn *badger.Txn, repo string) ([]database.Tag, error) { item, err := txn.Get(keyForRepo(tagsPrefix, repo)) if err == badger.ErrKeyNotFound { - return []string{}, nil + return []database.Tag{}, nil } if err != nil { return nil, err } - var tags []string + var tags []database.Tag err = item.Value(func(val []byte) error { tags, err = unmarshal(val) return err @@ -85,14 +90,22 @@ func getOrEmpty(txn *badger.Txn, repo string) ([]string, error) { return tags, err } -func marshal(t []string) ([]byte, error) { +func marshal(t []database.Tag) ([]byte, error) { return json.Marshal(t) } -func unmarshal(b []byte) ([]string, error) { - var tags []string +func unmarshal(b []byte) ([]database.Tag, error) { + var tags []database.Tag if err := json.Unmarshal(b, &tags); err != nil { - return nil, err + // If unmarshalling fails we may be operating on an old database so try to read the old format before eventually bailing out. + var tagsOld []string + if err2 := json.Unmarshal(b, &tagsOld); err2 != nil { + return nil, fmt.Errorf("failed unmarshaling values. First error: %s. Second error: %w", err, err2) + } + tags = make([]database.Tag, len(tagsOld)) + for idx, tag := range tagsOld { + tags[idx] = database.Tag{Name: tag} + } } return tags, nil } diff --git a/internal/database/badger_test.go b/internal/database/badger/badger_test.go similarity index 67% rename from internal/database/badger_test.go rename to internal/database/badger/badger_test.go index 36730fdd..92756335 100644 --- a/internal/database/badger_test.go +++ b/internal/database/badger/badger_test.go @@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -package database +package badger import ( "os" @@ -21,6 +21,7 @@ import ( "testing" "github.com/dgraph-io/badger/v3" + "github.com/fluxcd/image-reflector-controller/internal/database" ) const testRepo = "testing/testing" @@ -31,14 +32,18 @@ func TestGetWithUnknownRepo(t *testing.T) { tags, err := db.Tags(testRepo) fatalIfError(t, err) - if !reflect.DeepEqual([]string{}, tags) { - t.Fatalf("Tags() for unknown repo got %#v, want %#v", tags, []string{}) + if !reflect.DeepEqual([]database.Tag{}, tags) { + t.Fatalf("Tags() for unknown repo got %#v, want %#v", tags, []database.Tag{}) } } func TestSetTags(t *testing.T) { db := createBadgerDatabase(t) - tags := []string{"latest", "v0.0.1", "v0.0.2"} + tags := []database.Tag{ + {Name: "latest", Digest: "latest-digest"}, + {Name: "v0.0.1", Digest: "v0.0.1-digest"}, + {Name: "v0.0.2", Digest: "v0.0.2-digest"}, + } fatalIfError(t, db.SetTags(testRepo, tags)) @@ -51,8 +56,8 @@ func TestSetTags(t *testing.T) { 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"} + tags1 := []database.Tag{{Name: "latest", Digest: "latest-digest"}, {Name: "v0.0.1"}, {Name: "v0.0.2"}} + tags2 := []database.Tag{{Name: "latest", Digest: "new-digest"}, {Name: "v0.0.1"}, {Name: "v0.0.2"}, {Name: "v0.0.3"}} fatalIfError(t, db.SetTags(testRepo, tags1)) fatalIfError(t, db.SetTags(testRepo, tags2)) @@ -66,10 +71,10 @@ func TestSetTagsOverwrites(t *testing.T) { func TestGetOnlyFetchesForRepo(t *testing.T) { db := createBadgerDatabase(t) - tags1 := []string{"latest", "v0.0.1", "v0.0.2"} + tags1 := []database.Tag{{Name: "latest"}, {Name: "v0.0.1"}, {Name: "v0.0.2"}} fatalIfError(t, db.SetTags(testRepo, tags1)) testRepo2 := "another/repo" - tags2 := []string{"v0.0.3", "v0.0.4"} + tags2 := []database.Tag{{Name: "v0.0.3"}, {Name: "v0.0.4"}} fatalIfError(t, db.SetTags(testRepo2, tags2)) loaded, err := db.Tags(testRepo) @@ -79,6 +84,21 @@ func TestGetOnlyFetchesForRepo(t *testing.T) { } } +func TestReadOldData(t *testing.T) { + db := createBadgerDatabase(t) + f, err := os.Open("testdata/old.db") + fatalIfError(t, err) + fatalIfError(t, db.db.Load(f, 1)) + + loaded, err := db.Tags(testRepo) + fatalIfError(t, err) + + expected := []database.Tag{{Name: "latest"}, {Name: "v0.0.1"}, {Name: "v0.0.2"}} + if !reflect.DeepEqual(expected, loaded) { + t.Fatalf("Tags() failed, got %#v, want %#v", loaded, expected) + } +} + func createBadgerDatabase(t *testing.T) *BadgerDatabase { t.Helper() dir, err := os.MkdirTemp(os.TempDir(), "badger") diff --git a/internal/database/badger/testdata/old.db b/internal/database/badger/testdata/old.db new file mode 100644 index 00000000..4d744518 Binary files /dev/null and b/internal/database/badger/testdata/old.db differ diff --git a/internal/controllers/database.go b/internal/database/database.go similarity index 71% rename from internal/controllers/database.go rename to internal/database/database.go index 129b4e16..26135607 100644 --- a/internal/controllers/database.go +++ b/internal/database/database.go @@ -14,11 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -package controllers +package database + +// Tag defines the data structure used to store tags in the database. +type Tag struct { + // Name represents the actual tag's value. + Name string `json:"name"` + // Digest represents the digest of the image referred to by the tag. + Digest string `json:"digest"` +} // DatabaseWriter implementations record the tags for an image repository. type DatabaseWriter interface { - SetTags(repo string, tags []string) error + SetTags(repo string, tags []Tag) error } // DatabaseReader implementations get the stored set of tags for an image @@ -27,5 +35,5 @@ type DatabaseWriter interface { // If no tags are availble for the repo, then implementations should return an // empty set of tags. type DatabaseReader interface { - Tags(repo string) ([]string, error) + Tags(repo string) ([]Tag, error) } diff --git a/internal/features/features.go b/internal/features/features.go index e369074c..f96edb03 100644 --- a/internal/features/features.go +++ b/internal/features/features.go @@ -27,12 +27,21 @@ const ( // When enabled, it will cache both object types, resulting in increased // memory usage and cluster-wide RBAC permissions (list and watch). CacheSecretsAndConfigMaps = "CacheSecretsAndConfigMaps" + // StoreImageDigests toggles fetching and storing the digests for all + // image tags discovered for an ImageRepository. + // + // When enabled, the digest of the latest image tag will be put into the + // `.status.latestImageDigest` field of an ImagePolicy. + StoreImageDigests = "StoreImageDigests" ) var features = map[string]bool{ // CacheSecretsAndConfigMaps // opt-in from v0.24 CacheSecretsAndConfigMaps: false, + // StoreImageDigests + // opt-in + StoreImageDigests: false, } // FeatureGates contains a list of all supported feature gates and their default diff --git a/internal/policy/alphabetical.go b/internal/policy/alphabetical.go index f588f66c..ba03e624 100644 --- a/internal/policy/alphabetical.go +++ b/internal/policy/alphabetical.go @@ -19,6 +19,8 @@ package policy import ( "fmt" "sort" + + "github.com/fluxcd/image-reflector-controller/internal/database" ) const ( @@ -33,6 +35,8 @@ type Alphabetical struct { Order string } +var _ Policer = &Alphabetical{} + // NewAlphabetical constructs a Alphabetical object validating the provided // order argument func NewAlphabetical(order string) (*Alphabetical, error) { @@ -51,16 +55,25 @@ func NewAlphabetical(order string) (*Alphabetical, error) { } // Latest returns latest version from a provided list of strings -func (p *Alphabetical) Latest(versions []string) (string, error) { +func (p *Alphabetical) Latest(versions []database.Tag) (*database.Tag, error) { if len(versions) == 0 { - return "", fmt.Errorf("version list argument cannot be empty") + return nil, fmt.Errorf("version list argument cannot be empty") } - var sorted sort.StringSlice = versions + tagNames := make([]string, len(versions)) + tagsByName := make(map[string]database.Tag, len(versions)) + for idx, name := range versions { + tagNames[idx] = name.Name + tagsByName[name.Name] = name + } + + var sorted sort.StringSlice = tagNames if p.Order == AlphabeticalOrderDesc { sort.Sort(sorted) } else { sort.Sort(sort.Reverse(sorted)) } - return sorted[0], nil + selected := tagsByName[sorted[0]] + + return &selected, nil } diff --git a/internal/policy/alphabetical_test.go b/internal/policy/alphabetical_test.go index 525dec74..40905631 100644 --- a/internal/policy/alphabetical_test.go +++ b/internal/policy/alphabetical_test.go @@ -18,6 +18,10 @@ package policy import ( "testing" + + . "github.com/onsi/gomega" + + "github.com/fluxcd/image-reflector-controller/internal/database" ) func TestNewAlphabetical(t *testing.T) { @@ -62,83 +66,136 @@ func TestAlphabetical_Latest(t *testing.T) { cases := []struct { label string order string - versions []string - expectedVersion string + versions []database.Tag + expectedVersion database.Tag expectErr bool }{ { - label: "With Ubuntu CalVer", - versions: []string{"16.04", "16.04.1", "16.10", "20.04", "20.10"}, - expectedVersion: "20.10", + label: "With Ubuntu CalVer", + versions: []database.Tag{ + {Name: "16.04"}, + {Name: "16.04.1"}, + {Name: "16.10"}, + {Name: "20.04"}, + {Name: "20.10", Digest: "20.10-dig"}, + }, + expectedVersion: database.Tag{Name: "20.10", Digest: "20.10-dig"}, }, { - label: "With Ubuntu CalVer descending", - versions: []string{"16.04", "16.04.1", "16.10", "20.04", "20.10"}, + label: "With Ubuntu CalVer descending", + versions: []database.Tag{ + {Name: "16.04", Digest: "16.04-dig"}, + {Name: "16.04.1"}, + {Name: "16.10"}, + {Name: "20.04"}, + {Name: "20.10"}, + }, order: AlphabeticalOrderDesc, - expectedVersion: "16.04", + expectedVersion: database.Tag{Name: "16.04", Digest: "16.04-dig"}, }, { - label: "With Ubuntu code names", - versions: []string{"xenial", "yakkety", "zesty", "artful", "bionic"}, - expectedVersion: "zesty", + label: "With Ubuntu code names", + versions: []database.Tag{ + {Name: "xenial"}, + {Name: "yakkety"}, + {Name: "zesty", Digest: "dig"}, + {Name: "artful"}, + {Name: "bionic"}, + }, + expectedVersion: database.Tag{Name: "zesty", Digest: "dig"}, }, { - label: "With Ubuntu code names descending", - versions: []string{"xenial", "yakkety", "zesty", "artful", "bionic"}, + label: "With Ubuntu code names descending", + versions: []database.Tag{ + {Name: "xenial"}, + {Name: "yakkety"}, + {Name: "zesty"}, + {Name: "artful", Digest: "aec070645fe53ee3b3763059376134f058cc337"}, + {Name: "bionic"}, + }, order: AlphabeticalOrderDesc, - expectedVersion: "artful", + expectedVersion: database.Tag{Name: "artful", Digest: "aec070645fe53ee3b3763059376134f058cc337"}, }, { - label: "With Timestamps", - versions: []string{"1606234201", "1606364286", "1606334092", "1606334284", "1606334201"}, - expectedVersion: "1606364286", + label: "With Timestamps", + versions: []database.Tag{ + {Name: "1606234201"}, + {Name: "1606364286", Digest: "1606364286-33383"}, + {Name: "1606334092"}, + {Name: "1606334284"}, + {Name: "1606334201"}, + }, + expectedVersion: database.Tag{Name: "1606364286", Digest: "1606364286-33383"}, }, { - label: "With Unix Timestamps desc", - versions: []string{"1606234201", "1606364286", "1606334092", "1606334284", "1606334201"}, + label: "With Unix Timestamps desc", + versions: []database.Tag{ + {Name: "1606234201", Digest: "1606234201@494781"}, + {Name: "1606364286"}, + {Name: "1606334092"}, + {Name: "1606334284"}, + {Name: "1606334201"}, + }, order: AlphabeticalOrderDesc, - expectedVersion: "1606234201", + expectedVersion: database.Tag{Name: "1606234201", Digest: "1606234201@494781"}, }, { - label: "With Unix Timestamps prefix", - versions: []string{"rel-1606234201", "rel-1606364286", "rel-1606334092", "rel-1606334284", "rel-1606334201"}, - expectedVersion: "rel-1606364286", + label: "With Unix Timestamps prefix", + versions: []database.Tag{ + {Name: "rel-1606234201"}, + {Name: "rel-1606364286", Digest: "80f95d07201fd766c027279813220d6fc6826038e45cdd4f2b78e00297beb337"}, + {Name: "rel-1606334092"}, + {Name: "rel-1606334284"}, + {Name: "rel-1606334201"}, + }, + expectedVersion: database.Tag{Name: "rel-1606364286", Digest: "80f95d07201fd766c027279813220d6fc6826038e45cdd4f2b78e00297beb337"}, }, { - label: "With RFC3339", - versions: []string{"2021-01-08T21-18-21Z", "2020-05-08T21-18-21Z", "2021-01-08T19-20-00Z", "1990-01-08T00-20-00Z", "2023-05-08T00-20-00Z"}, - expectedVersion: "2023-05-08T00-20-00Z", + label: "With RFC3339", + versions: []database.Tag{ + {Name: "2021-01-08T21-18-21Z"}, + {Name: "2020-05-08T21-18-21Z"}, + {Name: "2021-01-08T19-20-00Z"}, + {Name: "1990-01-08T00-20-00Z"}, + {Name: "2023-05-08T00-20-00Z", Digest: "MjAyMy0wNS0wOFQwMC0yMC0wMFo="}, + }, + expectedVersion: database.Tag{Name: "2023-05-08T00-20-00Z", Digest: "MjAyMy0wNS0wOFQwMC0yMC0wMFo="}, }, { - label: "With RFC3339 desc", - versions: []string{"2021-01-08T21-18-21Z", "2020-05-08T21-18-21Z", "2021-01-08T19-20-00Z", "1990-01-08T00-20-00Z", "2023-05-08T00-20-00Z"}, + label: "With RFC3339 desc", + versions: []database.Tag{ + {Name: "2021-01-08T21-18-21Z", Digest: "0"}, + {Name: "2020-05-08T21-18-21Z", Digest: "1"}, + {Name: "2021-01-08T19-20-00Z", Digest: "2"}, + {Name: "1990-01-08T00-20-00Z", Digest: "3"}, + {Name: "2023-05-08T00-20-00Z", Digest: "4"}, + }, order: AlphabeticalOrderDesc, - expectedVersion: "1990-01-08T00-20-00Z", + expectedVersion: database.Tag{Name: "1990-01-08T00-20-00Z", Digest: "3"}, }, { label: "Empty version list", - versions: []string{}, + versions: []database.Tag{}, expectErr: true, }, } for _, tt := range cases { t.Run(tt.label, func(t *testing.T) { + g := NewWithT(t) + policy, err := NewAlphabetical(tt.order) if err != nil { t.Fatalf("returned unexpected error: %s", err) } latest, err := policy.Latest(tt.versions) - if tt.expectErr && err == nil { - t.Fatalf("expecting error, got nil") - } - if !tt.expectErr && err != nil { - t.Fatalf("returned unexpected error: %s", err) + if tt.expectErr { + g.Expect(err).To(HaveOccurred()) + return } + g.Expect(err).NotTo(HaveOccurred()) - if latest != tt.expectedVersion { - t.Errorf("incorrect computed version returned, got '%s', expected '%s'", latest, tt.expectedVersion) - } + g.Expect(latest).To(Equal(&tt.expectedVersion)) }) } } diff --git a/internal/policy/filter.go b/internal/policy/filter.go index 0ff7f60b..4f234ad1 100644 --- a/internal/policy/filter.go +++ b/internal/policy/filter.go @@ -19,11 +19,13 @@ package policy import ( "fmt" "regexp" + + "github.com/fluxcd/image-reflector-controller/internal/database" ) // RegexFilter represents a regular expression filter type RegexFilter struct { - filtered map[string]string + filtered map[string]database.Tag Regexp *regexp.Regexp Replace string @@ -42,31 +44,35 @@ func NewRegexFilter(pattern string, replace string) (*RegexFilter, error) { } // Apply will construct the filtered list of tags based on the provided list of tags -func (f *RegexFilter) Apply(list []string) { - f.filtered = map[string]string{} +func (f *RegexFilter) Apply(list []database.Tag) { + f.filtered = map[string]database.Tag{} for _, item := range list { - if submatches := f.Regexp.FindStringSubmatchIndex(item); len(submatches) > 0 { - tag := item + if submatches := f.Regexp.FindStringSubmatchIndex(item.Name); len(submatches) > 0 { + tag := item.Name if f.Replace != "" { result := []byte{} - result = f.Regexp.ExpandString(result, f.Replace, item, submatches) + result = f.Regexp.ExpandString(result, f.Replace, item.Name, submatches) tag = string(result) } - f.filtered[tag] = item + f.filtered[tag] = database.Tag{ + Name: item.Name, + Digest: item.Digest, + } } } } // Items returns the list of filtered tags -func (f *RegexFilter) Items() []string { - var filtered []string - for k := range f.filtered { - filtered = append(filtered, k) +func (f *RegexFilter) Items() []database.Tag { + var filtered []database.Tag + for filteredTag, v := range f.filtered { + v.Name = filteredTag + filtered = append(filtered, v) } return filtered } // GetOriginalTag returns the original tag before replace extraction -func (f *RegexFilter) GetOriginalTag(tag string) string { +func (f *RegexFilter) GetOriginalTag(tag string) database.Tag { return f.filtered[tag] } diff --git a/internal/policy/filter_test.go b/internal/policy/filter_test.go index d5f9b1e7..1a5603f3 100644 --- a/internal/policy/filter_test.go +++ b/internal/policy/filter_test.go @@ -17,45 +17,78 @@ limitations under the License. package policy import ( - "reflect" - "sort" "testing" + + "github.com/fluxcd/image-reflector-controller/internal/database" + . "github.com/onsi/gomega" ) func TestRegexFilter(t *testing.T) { cases := []struct { label string - tags []string + tags []database.Tag pattern string extract string - expected []string + expected []database.Tag + origTags map[string]int }{ { label: "none", - tags: []string{"a"}, - expected: []string{"a"}, + tags: []database.Tag{{Name: "a", Digest: "aa"}}, + expected: []database.Tag{{Name: "a", Digest: "aa"}}, + origTags: map[string]int{ + "a": 0, + }, }, { - label: "valid pattern", - tags: []string{"ver1", "ver2", "ver3", "rel1"}, - pattern: "^ver", - expected: []string{"ver1", "ver2", "ver3"}, + label: "valid pattern", + tags: []database.Tag{ + {Name: "ver1", Digest: "1rev"}, + {Name: "ver2", Digest: "2rev"}, + {Name: "ver3", Digest: "3rev"}, + {Name: "rel1", Digest: "1ler"}, + }, + pattern: "^ver", + expected: []database.Tag{ + {Name: "ver1", Digest: "1rev"}, + {Name: "ver2", Digest: "2rev"}, + {Name: "ver3", Digest: "3rev"}, + }, + origTags: map[string]int{ + "ver1": 0, + }, }, { - label: "valid pattern with capture group", - tags: []string{"ver1", "ver2", "ver3", "rel1"}, - pattern: `ver(\d+)`, - extract: `$1`, - expected: []string{"1", "2", "3"}, + label: "valid pattern with capture group", + tags: []database.Tag{ + {Name: "ver1", Digest: "foo"}, + {Name: "ver2", Digest: "bar"}, + {Name: "rel1", Digest: "qux"}, + {Name: "ver3", Digest: "baz"}, + }, + pattern: `ver(\d+)`, + extract: `$1`, + expected: []database.Tag{ + {Name: "1", Digest: "foo"}, + {Name: "2", Digest: "bar"}, + {Name: "3", Digest: "baz"}, + }, + origTags: map[string]int{ + "1": 0, + "2": 1, + "3": 3, + }, }, } for _, tt := range cases { t.Run(tt.label, func(t *testing.T) { + g := NewWithT(t) filter := newRegexFilter(tt.pattern, tt.extract) filter.Apply(tt.tags) - r := sort.StringSlice(filter.Items()) - if reflect.DeepEqual(r, tt.expected) { - t.Errorf("incorrect value returned, got '%s', expected '%s'", r, tt.expected) + g.Expect(filter.Items()).To(HaveLen(len(tt.expected))) + g.Expect(filter.Items()).To(ContainElements(tt.expected)) + for tagKey, idx := range tt.origTags { + g.Expect(filter.GetOriginalTag(tagKey)).To(Equal(tt.tags[idx])) } }) } diff --git a/internal/policy/numerical.go b/internal/policy/numerical.go index b7f32a08..bfac88d2 100644 --- a/internal/policy/numerical.go +++ b/internal/policy/numerical.go @@ -19,6 +19,8 @@ package policy import ( "fmt" "strconv" + + "github.com/fluxcd/image-reflector-controller/internal/database" ) const ( @@ -33,6 +35,8 @@ type Numerical struct { Order string } +var _ Policer = &Numerical{} + // NewNumerical constructs a Numerical object validating the provided // order argument func NewNumerical(order string) (*Numerical, error) { @@ -51,17 +55,17 @@ func NewNumerical(order string) (*Numerical, error) { } // Latest returns latest version from a provided list of strings -func (p *Numerical) Latest(versions []string) (string, error) { +func (p *Numerical) Latest(versions []database.Tag) (*database.Tag, error) { if len(versions) == 0 { - return "", fmt.Errorf("version list argument cannot be empty") + return nil, fmt.Errorf("version list argument cannot be empty") } - var latest string + var latest database.Tag var pv float64 for i, version := range versions { - cv, err := strconv.ParseFloat(version, 64) + cv, err := strconv.ParseFloat(version.Name, 64) if err != nil { - return "", fmt.Errorf("failed to parse invalid numeric value '%s'", version) + return nil, fmt.Errorf("failed to parse invalid numeric value '%s'", version) } switch { @@ -75,5 +79,5 @@ func (p *Numerical) Latest(versions []string) (string, error) { pv = cv } - return latest, nil + return &latest, nil } diff --git a/internal/policy/numerical_test.go b/internal/policy/numerical_test.go index 497c7900..d8d26b08 100644 --- a/internal/policy/numerical_test.go +++ b/internal/policy/numerical_test.go @@ -20,6 +20,10 @@ import ( "math/rand" "testing" "time" + + . "github.com/onsi/gomega" + + "github.com/fluxcd/image-reflector-controller/internal/database" ) func TestNewNumerical(t *testing.T) { @@ -64,88 +68,145 @@ func TestNumerical_Latest(t *testing.T) { cases := []struct { label string order string - versions []string - expectedVersion string + versions []database.Tag + expectedVersion database.Tag expectErr bool }{ { - label: "With unordered list of integers ascending", - versions: shuffle([]string{"-62", "-88", "73", "72", "15", "16", "15", "29", "-33", "-91"}), - expectedVersion: "73", + label: "With unordered list of integers ascending", + versions: shuffle([]database.Tag{ + {Name: "-62"}, + {Name: "-88"}, + {Name: "73", Digest: "foodigest"}, + {Name: "72"}, + {Name: "15"}, + {Name: "16"}, + {Name: "15"}, + {Name: "29"}, + {Name: "-33"}, + {Name: "-91"}, + }), + expectedVersion: database.Tag{Name: "73", Digest: "foodigest"}, }, { - label: "With unordered list of integers descending", - versions: shuffle([]string{"5", "-8", "-78", "25", "70", "-4", "80", "92", "-20", "-24"}), + label: "With unordered list of integers descending", + versions: shuffle([]database.Tag{ + {Name: "5"}, + {Name: "-8"}, + {Name: "-78", Digest: "somedig"}, + {Name: "25"}, + {Name: "70"}, + {Name: "-4"}, + {Name: "80"}, + {Name: "92"}, + {Name: "-20"}, + {Name: "-24"}, + }), order: NumericalOrderDesc, - expectedVersion: "-78", + expectedVersion: database.Tag{Name: "-78", Digest: "somedig"}, }, { - label: "With unordered list of floats ascending", - versions: shuffle([]string{"47.40896403322944", "-27.8520927455902", "-27.930666514224427", "-31.352485948094568", "-50.41072694704882", "-21.962849842263736", "24.71884721436865", "-39.99177354004344", "53.47333823144817", "3.2008658570411086"}), - expectedVersion: "53.47333823144817", + label: "With unordered list of floats ascending", + versions: shuffle([]database.Tag{ + {Name: "47.40896403322944"}, + {Name: "-27.8520927455902"}, + {Name: "-27.930666514224427"}, + {Name: "-31.352485948094568"}, + {Name: "-50.41072694704882"}, + {Name: "-21.962849842263736"}, + {Name: "24.71884721436865"}, + {Name: "-39.99177354004344"}, + {Name: "53.47333823144817", Digest: "47333823144817"}, + {Name: "3.2008658570411086"}, + }), + expectedVersion: database.Tag{Name: "53.47333823144817", Digest: "47333823144817"}, }, { - label: "With unordered list of floats descending", - versions: shuffle([]string{"-65.27202780220686", "57.82948329142309", "22.40184684363291", "-86.36934305697784", "-90.29082099756083", "-12.041712603564264", "77.70488240399305", "-38.98425003883552", "16.06867070412028", "53.735674335181216"}), + label: "With unordered list of floats descending", + versions: shuffle([]database.Tag{ + {Name: "-65.27202780220686"}, + {Name: "57.82948329142309"}, + {Name: "22.40184684363291"}, + {Name: "-86.36934305697784"}, + {Name: "-90.29082099756083", Digest: "-90"}, + {Name: "-12.041712603564264"}, + {Name: "77.70488240399305"}, + {Name: "-38.98425003883552"}, + {Name: "16.06867070412028"}, + {Name: "53.735674335181216"}, + }), order: NumericalOrderDesc, - expectedVersion: "-90.29082099756083", + expectedVersion: database.Tag{Name: "-90.29082099756083", Digest: "-90"}, }, { - label: "With Unix Timestamps ascending", - versions: shuffle([]string{"1606234201", "1606364286", "1606334092", "1606334284", "1606334201"}), - expectedVersion: "1606364286", + label: "With Unix Timestamps ascending", + versions: shuffle([]database.Tag{ + {Name: "1606234201"}, + {Name: "1606364286", Digest: "find-me"}, + {Name: "1606334092"}, + {Name: "1606334284"}, + {Name: "1606334201"}, + }), + expectedVersion: database.Tag{Name: "1606364286", Digest: "find-me"}, }, { - label: "With Unix Timestamps descending", - versions: shuffle([]string{"1606234201", "1606364286", "1606334092", "1606334284", "1606334201"}), + label: "With Unix Timestamps descending", + versions: shuffle([]database.Tag{ + {Name: "1606234201", Digest: "foobar"}, + {Name: "1606364286"}, + {Name: "1606334092"}, + {Name: "1606334284"}, + {Name: "1606334201"}, + }), order: NumericalOrderDesc, - expectedVersion: "1606234201", + expectedVersion: database.Tag{Name: "1606234201", Digest: "foobar"}, }, { label: "With single value ascending", - versions: []string{"1"}, - expectedVersion: "1", + versions: []database.Tag{{Name: "1"}}, + expectedVersion: database.Tag{Name: "1"}, }, { label: "With single value descending", - versions: []string{"1"}, + versions: []database.Tag{{Name: "1"}}, order: NumericalOrderDesc, - expectedVersion: "1", + expectedVersion: database.Tag{Name: "1"}, }, { - label: "With invalid numerical value", - versions: []string{"0", "1a", "b"}, + label: "With invalid numerical value", + versions: []database.Tag{{Name: "0"}, + {Name: "1a"}, + {Name: "b"}, + }, expectErr: true, }, { label: "Empty version list", - versions: []string{}, + versions: []database.Tag{}, expectErr: true, }, } for _, tt := range cases { t.Run(tt.label, func(t *testing.T) { + g := NewWithT(t) + policy, err := NewNumerical(tt.order) - if err != nil { - t.Fatalf("returned unexpected error: %s", err) - } + g.Expect(err).NotTo(HaveOccurred()) + latest, err := policy.Latest(tt.versions) - if tt.expectErr && err == nil { - t.Fatalf("expecting error, got nil") - } - if !tt.expectErr && err != nil { - t.Fatalf("returned unexpected error: %s", err) + if tt.expectErr { + g.Expect(err).To(HaveOccurred()) + return } + g.Expect(err).NotTo(HaveOccurred()) - if latest != tt.expectedVersion { - t.Errorf("incorrect computed version returned, got '%s', expected '%s'", latest, tt.expectedVersion) - } + g.Expect(latest).To(Equal(&tt.expectedVersion), "incorrect computed version returned") }) } } -func shuffle(list []string) []string { +func shuffle(list []database.Tag) []database.Tag { rand.Seed(time.Now().UnixNano()) rand.Shuffle(len(list), func(i, j int) { list[i], list[j] = list[j], list[i] }) return list diff --git a/internal/policy/policer.go b/internal/policy/policer.go index c70ca922..67730c13 100644 --- a/internal/policy/policer.go +++ b/internal/policy/policer.go @@ -16,7 +16,9 @@ limitations under the License. package policy +import "github.com/fluxcd/image-reflector-controller/internal/database" + // Policer is an interface representing a policy implementation type type Policer interface { - Latest([]string) (string, error) + Latest([]database.Tag) (*database.Tag, error) } diff --git a/internal/policy/semver.go b/internal/policy/semver.go index 64988731..7620c44a 100644 --- a/internal/policy/semver.go +++ b/internal/policy/semver.go @@ -20,6 +20,7 @@ import ( "fmt" "github.com/Masterminds/semver/v3" + "github.com/fluxcd/image-reflector-controller/internal/database" "github.com/fluxcd/pkg/version" ) @@ -44,22 +45,25 @@ func NewSemVer(r string) (*SemVer, error) { } // Latest returns latest version from a provided list of strings -func (p *SemVer) Latest(versions []string) (string, error) { +func (p *SemVer) Latest(versions []database.Tag) (*database.Tag, error) { if len(versions) == 0 { - return "", fmt.Errorf("version list argument cannot be empty") + return nil, fmt.Errorf("version list argument cannot be empty") } var latestVersion *semver.Version + var latestTag *database.Tag for _, tag := range versions { - if v, err := version.ParseVersion(tag); err == nil { + tag := tag + if v, err := version.ParseVersion(tag.Name); err == nil { if p.constraint.Check(v) && (latestVersion == nil || v.GreaterThan(latestVersion)) { latestVersion = v + latestTag = &tag } } } - if latestVersion != nil { - return latestVersion.Original(), nil + if latestTag != nil { + return latestTag, nil } - return "", fmt.Errorf("unable to determine latest version from provided list") + return nil, fmt.Errorf("unable to determine latest version from provided list") } diff --git a/internal/policy/semver_test.go b/internal/policy/semver_test.go index 12754440..96aaaddc 100644 --- a/internal/policy/semver_test.go +++ b/internal/policy/semver_test.go @@ -18,6 +18,10 @@ package policy import ( "testing" + + . "github.com/onsi/gomega" + + "github.com/fluxcd/image-reflector-controller/internal/database" ) func TestNewSemVer(t *testing.T) { @@ -56,37 +60,52 @@ func TestSemVer_Latest(t *testing.T) { cases := []struct { label string semverRange string - versions []string - expectedVersion string + versions []database.Tag + expectedVersion database.Tag expectErr bool }{ { - label: "With valid format", - versions: []string{"1.0.0", "1.0.0.1", "1.0.0p", "1.0.1", "1.2.0", "0.1.0"}, + label: "With valid format", + versions: []database.Tag{ + {Name: "1.0.0", Digest: "foo"}, + {Name: "1.0.0.1", Digest: "bar"}, + {Name: "1.0.0p", Digest: "baz"}, + {Name: "1.0.1", Digest: "qux"}, + {Name: "1.2.0", Digest: "faa"}, + {Name: "0.1.0", Digest: "quux"}, + }, semverRange: "1.0.x", - expectedVersion: "1.0.1", + expectedVersion: database.Tag{Name: "1.0.1", Digest: "qux"}, }, { - label: "With valid format prefix", - versions: []string{"v1.2.3", "v1.0.0", "v0.1.0"}, + label: "With valid format prefix", + versions: []database.Tag{ + {Name: "v1.2.3", Digest: "v1.2.3-digest"}, + {Name: "v1.0.0", Digest: "v1.0.0-digest"}, + {Name: "v0.1.0", Digest: "v0.1.0-dig"}, + }, semverRange: "1.0.x", - expectedVersion: "v1.0.0", + expectedVersion: database.Tag{Name: "v1.0.0", Digest: "v1.0.0-digest"}, }, { - label: "With invalid format prefix", - versions: []string{"b1.2.3", "b1.0.0", "b0.1.0"}, + label: "With invalid format prefix", + versions: []database.Tag{ + {Name: "b1.2.3"}, + {Name: "b1.0.0"}, + {Name: "b0.1.0"}, + }, semverRange: "1.0.x", expectErr: true, }, { label: "With empty list", - versions: []string{}, + versions: []database.Tag{}, semverRange: "1.0.x", expectErr: true, }, { label: "With non-matching version list", - versions: []string{"1.2.0"}, + versions: []database.Tag{{Name: "1.2.0"}}, semverRange: "1.0.x", expectErr: true, }, @@ -94,22 +113,20 @@ func TestSemVer_Latest(t *testing.T) { for _, tt := range cases { t.Run(tt.label, func(t *testing.T) { + g := NewWithT(t) + policy, err := NewSemVer(tt.semverRange) - if err != nil { - t.Fatalf("returned unexpected error: %s", err) - } + g.Expect(err).NotTo(HaveOccurred()) latest, err := policy.Latest(tt.versions) - if tt.expectErr && err == nil { - t.Fatalf("expecting error, got nil") - } - if !tt.expectErr && err != nil { - t.Fatalf("returned unexpected error: %s", err) + if tt.expectErr { + g.Expect(err).To(HaveOccurred()) + return } - if latest != tt.expectedVersion { - t.Errorf("incorrect computed version returned, got '%s', expected '%s'", latest, tt.expectedVersion) - } + g.Expect(err).NotTo(HaveOccurred()) + + g.Expect(latest).To(Equal(&tt.expectedVersion), "incorrect computed version returned") }) } } diff --git a/main.go b/main.go index aae1c762..52adcbf1 100644 --- a/main.go +++ b/main.go @@ -47,7 +47,7 @@ import ( imagev1 "github.com/fluxcd/image-reflector-controller/api/v1beta2" "github.com/fluxcd/image-reflector-controller/internal/controllers" - "github.com/fluxcd/image-reflector-controller/internal/database" + ircbadger "github.com/fluxcd/image-reflector-controller/internal/database/badger" "github.com/fluxcd/image-reflector-controller/internal/features" ) @@ -128,7 +128,7 @@ func main() { os.Exit(1) } defer badgerDB.Close() - db := database.NewBadgerDatabase(badgerDB) + db := ircbadger.NewBadgerDatabase(badgerDB) watchNamespace := "" if !watchOptions.AllNamespaces { @@ -145,6 +145,12 @@ func main() { disableCacheFor = append(disableCacheFor, &corev1.Secret{}, &corev1.ConfigMap{}) } + fetchDigests, err := features.Enabled(features.StoreImageDigests) + if err != nil { + setupLog.Error(err, "unable to check feature gate "+features.StoreImageDigests) + os.Exit(1) + } + restConfig := client.GetConfigOrDie(clientOptions) watchSelector, err := helper.GetWatchSelector(watchOptions) @@ -207,6 +213,7 @@ func main() { AzureAutoLogin: azureAutoLogin, GcpAutoLogin: gcpAutoLogin, }, + FetchDigests: fetchDigests, }).SetupWithManager(mgr, controllers.ImageRepositoryReconcilerOptions{ MaxConcurrentReconciles: concurrent, RateLimiter: helper.GetRateLimiter(rateLimiterOptions),