Skip to content

Commit 77c8894

Browse files
committed
Implement OCIRepository ref.semver
Signed-off-by: Stefan Prodan <[email protected]>
1 parent 37a7bc5 commit 77c8894

File tree

2 files changed

+134
-62
lines changed

2 files changed

+134
-62
lines changed

controllers/ocirepository_controller.go

Lines changed: 113 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ import (
2121
"errors"
2222
"fmt"
2323
"os"
24+
"sort"
2425
"time"
2526

27+
"github.com/Masterminds/semver/v3"
2628
"github.com/google/go-containerregistry/pkg/crane"
2729
gcrv1 "github.com/google/go-containerregistry/pkg/v1"
2830
corev1 "k8s.io/api/core/v1"
@@ -45,6 +47,7 @@ import (
4547
"github.com/fluxcd/pkg/runtime/patch"
4648
"github.com/fluxcd/pkg/runtime/predicates"
4749
"github.com/fluxcd/pkg/untar"
50+
"github.com/fluxcd/pkg/version"
4851
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
4952
serror "github.com/fluxcd/source-controller/internal/error"
5053
sreconcile "github.com/fluxcd/source-controller/internal/reconcile"
@@ -271,78 +274,44 @@ func (r *OCIRepositoryReconciler) reconcile(ctx context.Context, obj *sourcev1.O
271274
return res, resErr
272275
}
273276

274-
// notify emits notification related to the reconciliation.
275-
func (r *OCIRepositoryReconciler) notify(ctx context.Context, oldObj, newObj *sourcev1.OCIRepository, digest *gcrv1.Hash, res sreconcile.Result, resErr error) {
276-
// Notify successful reconciliation for new artifact and recovery from any
277-
// failure.
278-
if resErr == nil && res == sreconcile.ResultSuccess && newObj.Status.Artifact != nil {
279-
annotations := map[string]string{
280-
sourcev1.GroupVersion.Group + "/revision": newObj.Status.Artifact.Revision,
281-
sourcev1.GroupVersion.Group + "/checksum": newObj.Status.Artifact.Checksum,
282-
}
283-
284-
var oldChecksum string
285-
if oldObj.GetArtifact() != nil {
286-
oldChecksum = oldObj.GetArtifact().Checksum
287-
}
288-
289-
message := fmt.Sprintf("stored artifact with digest '%s' from '%s'", digest.String(), newObj.Spec.URL)
290-
291-
// Notify on new artifact and failure recovery.
292-
if oldChecksum != newObj.GetArtifact().Checksum {
293-
r.AnnotatedEventf(newObj, annotations, corev1.EventTypeNormal,
294-
"NewArtifact", message)
295-
ctrl.LoggerFrom(ctx).Info(message)
296-
} else {
297-
if sreconcile.FailureRecovery(oldObj, newObj, ociRepositoryFailConditions) {
298-
r.AnnotatedEventf(newObj, annotations, corev1.EventTypeNormal,
299-
meta.SucceededReason, message)
300-
ctrl.LoggerFrom(ctx).Info(message)
301-
}
302-
}
303-
}
304-
}
305-
306-
// reconcileSource fetches the upstream OCI artifact content.
277+
// reconcileSource fetches the upstream OCI artifact metadata and content.
307278
// If this fails, it records v1beta2.FetchFailedCondition=True on the object and returns early.
308279
func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, obj *sourcev1.OCIRepository, digest *gcrv1.Hash, dir string) (sreconcile.Result, error) {
309280
ctxTimeout, cancel := context.WithTimeout(ctx, obj.Spec.Timeout.Duration)
310281
defer cancel()
311282

312-
url := obj.Spec.URL
313-
if obj.Spec.Reference != nil {
314-
if obj.Spec.Reference.Tag != "" {
315-
url = fmt.Sprintf("%s:%s", obj.Spec.URL, obj.Spec.Reference.Tag)
316-
}
317-
if obj.Spec.Reference.Digest != "" {
318-
url = fmt.Sprintf("%s@%s", obj.Spec.URL, obj.Spec.Reference.Digest)
319-
}
283+
// Determine which artifact revision to pull
284+
url, err := r.getArtifactURL(ctxTimeout, obj)
285+
if err != nil {
286+
e := &serror.Event{Err: err, Reason: sourcev1.OCIOperationFailedReason}
287+
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Error())
288+
return sreconcile.ResultEmpty, e
320289
}
321290

322-
// Pull OCI artifact
291+
// Pull artifact from the remote container registry
323292
img, err := crane.Pull(url, r.craneOptions(ctxTimeout)...)
324293
if err != nil {
325294
e := &serror.Event{Err: err, Reason: sourcev1.OCIOperationFailedReason}
326295
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Error())
327296
return sreconcile.ResultEmpty, e
328297
}
329298

330-
// Fetch digest
299+
// Determine the artifact SHA256 digest
331300
imgDigest, err := img.Digest()
332301
if err != nil {
333302
e := &serror.Event{Err: err, Reason: sourcev1.OCIOperationFailedReason}
334303
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Error())
335304
return sreconcile.ResultEmpty, e
336305
}
337306

338-
// Set revision from digest hex
307+
// Set the internal revision to the remote digest hex
339308
imgDigest.DeepCopyInto(digest)
340309
revision := imgDigest.Hex
341310

342311
// Mark observations about the revision on the object
343312
defer func() {
344313
if !obj.GetArtifact().HasRevision(revision) {
345-
message := fmt.Sprintf("new upstream revision '%s'", revision)
314+
message := fmt.Sprintf("new upstream revision '%s' for '%s'", revision, url)
346315
conditions.MarkTrue(obj, sourcev1.ArtifactOutdatedCondition, "NewRevision", message)
347316
conditions.MarkReconciling(obj, "NewRevision", message)
348317
}
@@ -382,6 +351,76 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, obj *sour
382351
return sreconcile.ResultSuccess, nil
383352
}
384353

354+
// getArtifactURL determines which tag or digest should be used and returns the OCI artifact FQN.
355+
func (r *OCIRepositoryReconciler) getArtifactURL(ctx context.Context, obj *sourcev1.OCIRepository) (string, error) {
356+
url := obj.Spec.URL
357+
if obj.Spec.Reference != nil {
358+
if obj.Spec.Reference.Digest != "" {
359+
return fmt.Sprintf("%s@%s", obj.Spec.URL, obj.Spec.Reference.Digest), nil
360+
}
361+
362+
if obj.Spec.Reference.SemVer != "" {
363+
tag, err := r.getTagBySemver(ctx, url, obj.Spec.Reference.SemVer)
364+
if err != nil {
365+
return "", err
366+
}
367+
return fmt.Sprintf("%s:%s", obj.Spec.URL, tag), nil
368+
}
369+
370+
if obj.Spec.Reference.Tag != "latest" {
371+
return fmt.Sprintf("%s:%s", obj.Spec.URL, obj.Spec.Reference.Tag), nil
372+
}
373+
}
374+
375+
return url, nil
376+
}
377+
378+
// getTagBySemver call the remote container registry, fetches all the tags from the repository,
379+
// and returns the latest tag according to the semver expression.
380+
func (r *OCIRepositoryReconciler) getTagBySemver(ctx context.Context, url, exp string) (string, error) {
381+
tags, err := crane.ListTags(url, r.craneOptions(ctx)...)
382+
if err != nil {
383+
return "", err
384+
}
385+
386+
constraint, err := semver.NewConstraint(exp)
387+
if err != nil {
388+
return "", fmt.Errorf("semver '%s' parse error: %w", exp, err)
389+
}
390+
391+
var matchingVersions []*semver.Version
392+
for _, t := range tags {
393+
v, err := version.ParseVersion(t)
394+
if err != nil {
395+
continue
396+
}
397+
398+
if constraint.Check(v) {
399+
matchingVersions = append(matchingVersions, v)
400+
}
401+
}
402+
403+
if len(matchingVersions) == 0 {
404+
return "", fmt.Errorf("no match found for semver: %s", exp)
405+
}
406+
407+
sort.Sort(sort.Reverse(semver.Collection(matchingVersions)))
408+
return matchingVersions[0].Original(), nil
409+
}
410+
411+
// craneOptions sets the timeout and user agent for all operations against remote container registries.
412+
func (r *OCIRepositoryReconciler) craneOptions(ctx context.Context) []crane.Option {
413+
return []crane.Option{
414+
crane.WithContext(ctx),
415+
crane.WithUserAgent("flux/v2"),
416+
crane.WithPlatform(&gcrv1.Platform{
417+
Architecture: "flux",
418+
OS: "flux",
419+
OSVersion: "v2",
420+
}),
421+
}
422+
}
423+
385424
// reconcileStorage ensures the current state of the storage matches the
386425
// desired and previously observed state.
387426
//
@@ -580,14 +619,34 @@ func (r *OCIRepositoryReconciler) eventLogf(ctx context.Context, obj runtime.Obj
580619
r.Eventf(obj, eventType, reason, msg)
581620
}
582621

583-
func (r *OCIRepositoryReconciler) craneOptions(ctx context.Context) []crane.Option {
584-
return []crane.Option{
585-
crane.WithContext(ctx),
586-
crane.WithUserAgent("flux/v2"),
587-
crane.WithPlatform(&gcrv1.Platform{
588-
Architecture: "flux",
589-
OS: "flux",
590-
OSVersion: "v2",
591-
}),
622+
// notify emits notification related to the reconciliation.
623+
func (r *OCIRepositoryReconciler) notify(ctx context.Context, oldObj, newObj *sourcev1.OCIRepository, digest *gcrv1.Hash, res sreconcile.Result, resErr error) {
624+
// Notify successful reconciliation for new artifact and recovery from any
625+
// failure.
626+
if resErr == nil && res == sreconcile.ResultSuccess && newObj.Status.Artifact != nil {
627+
annotations := map[string]string{
628+
sourcev1.GroupVersion.Group + "/revision": newObj.Status.Artifact.Revision,
629+
sourcev1.GroupVersion.Group + "/checksum": newObj.Status.Artifact.Checksum,
630+
}
631+
632+
var oldChecksum string
633+
if oldObj.GetArtifact() != nil {
634+
oldChecksum = oldObj.GetArtifact().Checksum
635+
}
636+
637+
message := fmt.Sprintf("stored artifact with digest '%s' from '%s'", digest.String(), newObj.Spec.URL)
638+
639+
// Notify on new artifact and failure recovery.
640+
if oldChecksum != newObj.GetArtifact().Checksum {
641+
r.AnnotatedEventf(newObj, annotations, corev1.EventTypeNormal,
642+
"NewArtifact", message)
643+
ctrl.LoggerFrom(ctx).Info(message)
644+
} else {
645+
if sreconcile.FailureRecovery(oldObj, newObj, ociRepositoryFailConditions) {
646+
r.AnnotatedEventf(newObj, annotations, corev1.EventTypeNormal,
647+
meta.SucceededReason, message)
648+
ctrl.LoggerFrom(ctx).Info(message)
649+
}
650+
}
592651
}
593652
}

controllers/ocirepository_controller_test.go

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,25 @@ import (
1717
)
1818

1919
func TestOCIRepository_Reconcile(t *testing.T) {
20-
2120
tests := []struct {
2221
name string
2322
url string
2423
tag string
24+
semver string
2525
digest string
2626
}{
2727
{
28-
name: "public latest",
28+
name: "public tag",
2929
url: "ghcr.io/stefanprodan/manifests/podinfo",
3030
tag: "6.1.6",
3131
digest: "3b6cdcc7adcc9a84d3214ee1c029543789d90b5ae69debe9efa3f66e982875de",
3232
},
33+
{
34+
name: "public semver",
35+
url: "ghcr.io/stefanprodan/manifests/podinfo",
36+
semver: ">= 6.1 <= 6.1.5",
37+
digest: "1d1bf6980fc86f69481bd8c875c531aa23d761ac890ce2594d4df2b39ecd8713",
38+
},
3339
}
3440

3541
for _, tt := range tests {
@@ -46,14 +52,19 @@ func TestOCIRepository_Reconcile(t *testing.T) {
4652
Namespace: ns.Name,
4753
},
4854
Spec: sourcev1.OCIRepositorySpec{
49-
URL: tt.url,
50-
Interval: metav1.Duration{Duration: 60 * time.Minute},
51-
Reference: &sourcev1.OCIRepositoryRef{
52-
Tag: tt.tag,
53-
},
55+
URL: tt.url,
56+
Interval: metav1.Duration{Duration: 60 * time.Minute},
57+
Reference: &sourcev1.OCIRepositoryRef{},
5458
},
5559
}
5660

61+
if tt.tag != "" {
62+
obj.Spec.Reference.Tag = tt.tag
63+
}
64+
if tt.semver != "" {
65+
obj.Spec.Reference.SemVer = tt.semver
66+
}
67+
5768
g.Expect(testEnv.Create(ctx, obj)).To(Succeed())
5869

5970
key := client.ObjectKey{Name: obj.Name, Namespace: obj.Namespace}
@@ -79,7 +90,9 @@ func TestOCIRepository_Reconcile(t *testing.T) {
7990
obj.Generation == obj.Status.ObservedGeneration
8091
}, timeout).Should(BeTrue())
8192

82-
// Check if the revision is set to the digest format
93+
t.Log(obj.Spec.Reference)
94+
95+
// Check if the revision matches the expected digest
8396
g.Expect(obj.Status.Artifact.Revision).To(Equal(tt.digest))
8497

8598
// Check if the object status is valid

0 commit comments

Comments
 (0)