Skip to content

Commit e38869c

Browse files
roshprcursoragent
andcommitted
Set Ready on OCI HelmRepository objects
OCI HelmRepositories finished migration with an empty status, so kubectl Ready/Status columns stayed blank. Set Ready=True with reason NoIndex after migrating to a static object, and treat that state as fully migrated so a later reconcile does not wipe it. Assisted-by: Cursor Grok 4.6/cursor-grok-4.6-high-fast Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: Rosh Ramadass <roshpr@gmail.com>
1 parent 143c11a commit e38869c

6 files changed

Lines changed: 261 additions & 38 deletions

File tree

‎api/v1/helmrepository_types.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ const (
156156
// IndexationFailedReason signals that the HelmRepository index fetch
157157
// failed.
158158
IndexationFailedReason string = "IndexationFailed"
159+
160+
// NoIndexReason signals that the HelmRepository is of type OCI and does
161+
// not produce an index Artifact. Charts are resolved on demand.
162+
NoIndexReason string = "NoIndex"
159163
)
160164

161165
// GetConditions returns the status conditions of the object.

‎docs/spec/v1/helmrepositories.md‎

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,12 @@ You can run this example by saving the manifest into `helmrepository.yaml`.
131131

132132
```console
133133
NAME URL AGE READY STATUS
134-
podinfo oci://ghcr.io/stefanprodan/charts 3m22s
134+
podinfo oci://ghcr.io/stefanprodan/charts 3m22s True OCI HelmRepositories do not produce an index artifact; charts are resolved on demand
135135
```
136136

137-
Because the OCI Helm repository is a data container, there's nothing to report
138-
for `READY` and `STATUS` columns above. The existence of the object can be
139-
considered to be ready for use.
137+
The controller sets `Ready=True` with reason `NoIndex`. OCI Helm repositories
138+
do not produce an index Artifact; [HelmCharts](helmcharts.md) resolve charts
139+
from the registry on demand.
140140

141141
## Writing a HelmRepository spec
142142

@@ -522,10 +522,11 @@ For practical information, see
522522

523523
## Working with HelmRepositories
524524

525-
**Note:** This section does not apply to [OCI Helm
526-
Repositories](#helm-oci-repository), being a data container, once created, they
527-
are ready to used by [HelmCharts](helmcharts.md).
528-
525+
**Note:** Triggering a reconcile and fetching an index Artifact do not apply to
526+
[OCI Helm Repositories](#helm-oci-repository). They are static data containers.
527+
Waiting for `Ready` does apply: the controller sets `Ready=True` with reason
528+
`NoIndex` after the object is recorded as a static source.
529+
529530
### Triggering a reconcile
530531

531532
To manually tell the source-controller to reconcile a HelmRepository outside the
@@ -626,9 +627,10 @@ flux resume source helm <repository-name>
626627

627628
### Debugging a HelmRepository
628629

629-
**Note:** This section does not apply to [OCI Helm
630-
Repositories](#helm-oci-repository), being a data container, they are static
631-
objects that don't require debugging if valid.
630+
**Note:** [OCI Helm Repositories](#helm-oci-repository) do not fetch an index.
631+
Chart pull failures are reported on the [HelmChart](helmcharts.md). The
632+
HelmRepository itself reports `Ready=True` with reason `NoIndex` when it has
633+
been recorded as a static source.
632634

633635
There are several ways to gather information about a HelmRepository for debugging
634636
purposes.
@@ -695,9 +697,9 @@ specific HelmRepository, e.g. `flux logs --level=error --kind=HelmRepository --n
695697

696698
## HelmRepository Status
697699

698-
**Note:** This section does not apply to [OCI Helm
699-
Repositories](#helm-oci-repository), they do not contain any information in the
700-
status.
700+
**Note:** [OCI Helm Repositories](#helm-oci-repository) do not report an
701+
Artifact. They set a `Ready=True` condition with reason `NoIndex`. The rest of
702+
this section describes HTTP/S Helm repositories.
701703

702704
### Artifact
703705

@@ -782,13 +784,23 @@ characteristics:
782784
- The revision of the reported Artifact is up-to-date with the latest
783785
revision of the Helm repository.
784786

787+
[OCI Helm Repositories](#helm-oci-repository) are marked ready without an
788+
Artifact. Charts are resolved on demand by [HelmChart](helmcharts.md).
789+
785790
When the HelmRepository is "ready", the controller sets a Condition with the following
786791
attributes in the HelmRepository's `.status.conditions`:
787792

788793
- `type: Ready`
789794
- `status: "True"`
790795
- `reason: Succeeded`
791796

797+
For [OCI Helm Repositories](#helm-oci-repository), there is no Artifact. After
798+
the object is recorded as a static source, the controller sets:
799+
800+
- `type: Ready`
801+
- `status: "True"`
802+
- `reason: NoIndex`
803+
792804
This `Ready` Condition will retain a status value of `"True"` until the
793805
HelmRepository is marked as [reconciling](#reconciling-helmrepository), or e.g.
794806
a [transient error](#failed-helmrepository) occurs due to a temporary network

‎internal/controller/helmrepository_controller.go‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,8 @@ func (r *HelmRepositoryReconciler) eventLogf(ctx context.Context, obj runtime.Ob
712712
r.Eventf(obj, eventType, reason, "%s", msg)
713713
}
714714

715+
const ociHelmRepositoryNoIndexMessage = "OCI HelmRepositories do not produce an index artifact; charts are resolved on demand"
716+
715717
// migrateToStatic is HelmRepository OCI migration to static object.
716718
func (r *HelmRepositoryReconciler) migrationToStatic(ctx context.Context, sp *patch.SerialPatcher, obj *sourcev1.HelmRepository) (result ctrl.Result, err error) {
717719
// Skip migration if suspended and not being deleted.
@@ -724,14 +726,22 @@ func (r *HelmRepositoryReconciler) migrationToStatic(ctx context.Context, sp *pa
724726
return ctrl.Result{}, nil
725727
}
726728

727-
// Delete any artifact.
729+
// Delete any leftover HTTP-style artifact.
728730
_, err = r.reconcileDelete(ctx, obj)
729731
if err != nil {
730732
return ctrl.Result{}, err
731733
}
732-
// Delete finalizer and reset the status.
734+
// Remove the source finalizer; OCI objects are static data containers.
733735
controllerutil.RemoveFinalizer(obj, sourcev1.SourceFinalizer)
734-
obj.Status = sourcev1.HelmRepositoryStatus{}
736+
737+
if obj.DeletionTimestamp.IsZero() {
738+
obj.Status = sourcev1.HelmRepositoryStatus{
739+
ObservedGeneration: obj.GetGeneration(),
740+
}
741+
conditions.MarkTrue(obj, meta.ReadyCondition, sourcev1.NoIndexReason, "%s", ociHelmRepositoryNoIndexMessage)
742+
} else {
743+
obj.Status = sourcev1.HelmRepositoryStatus{}
744+
}
735745

736746
if err := sp.Patch(ctx, obj); err != nil {
737747
return ctrl.Result{}, err

‎internal/controller/helmrepository_controller_test.go‎

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1756,6 +1756,10 @@ func TestHelmRepositoryReconciler_ReconcileTypeUpdatePredicateFilter(t *testing.
17561756
!intpredicates.HelmRepositoryOCIRequireMigration(obj)
17571757
}, timeout).Should(BeTrue())
17581758

1759+
g.Expect(obj.GetArtifact()).To(BeNil())
1760+
g.Expect(conditions.IsReady(obj)).To(BeTrue())
1761+
g.Expect(conditions.Get(obj, meta.ReadyCondition).Reason).To(Equal(sourcev1.NoIndexReason))
1762+
17591763
g.Expect(testEnv.Delete(ctx, obj)).To(Succeed())
17601764

17611765
// Wait for HelmRepository to be deleted
@@ -1963,6 +1967,9 @@ func TestHelmRepositoryReconciler_ociMigration(t *testing.T) {
19631967
_ = testEnv.Get(ctx, hrKey, hr)
19641968
return !intpredicates.HelmRepositoryOCIRequireMigration(hr)
19651969
}, timeout, time.Second).Should(BeTrue())
1970+
g.Expect(conditions.IsReady(hr)).To(BeTrue())
1971+
g.Expect(conditions.Get(hr, meta.ReadyCondition).Reason).To(Equal(sourcev1.NoIndexReason))
1972+
g.Expect(controllerutil.ContainsFinalizer(hr, sourcev1.SourceFinalizer)).To(BeFalse())
19661973

19671974
// Migrates updated object with finalizer.
19681975

@@ -1976,6 +1983,8 @@ func TestHelmRepositoryReconciler_ociMigration(t *testing.T) {
19761983
_ = testEnv.Get(ctx, hrKey, hr)
19771984
return !intpredicates.HelmRepositoryOCIRequireMigration(hr)
19781985
}, timeout, time.Second).Should(BeTrue())
1986+
g.Expect(conditions.IsReady(hr)).To(BeTrue())
1987+
g.Expect(conditions.Get(hr, meta.ReadyCondition).Reason).To(Equal(sourcev1.NoIndexReason))
19791988

19801989
// Migrates deleted object with finalizer.
19811990

@@ -2017,3 +2026,148 @@ func TestHelmRepositoryReconciler_ociMigration(t *testing.T) {
20172026
return false
20182027
}, timeout).Should(BeTrue())
20192028
}
2029+
2030+
func TestHelmRepositoryReconciler_ociReadyCondition(t *testing.T) {
2031+
g := NewWithT(t)
2032+
2033+
ns, err := testEnv.CreateNamespace(ctx, "hr-oci-ready-test")
2034+
g.Expect(err).ToNot(HaveOccurred())
2035+
t.Cleanup(func() {
2036+
g.Expect(testEnv.Cleanup(ctx, ns)).ToNot(HaveOccurred())
2037+
})
2038+
2039+
hr := &sourcev1.HelmRepository{
2040+
ObjectMeta: metav1.ObjectMeta{
2041+
GenerateName: "hr-oci-",
2042+
Namespace: ns.Name,
2043+
},
2044+
Spec: sourcev1.HelmRepositorySpec{
2045+
Type: sourcev1.HelmRepositoryTypeOCI,
2046+
URL: "oci://ghcr.io/stefanprodan/charts",
2047+
Interval: metav1.Duration{Duration: interval},
2048+
},
2049+
}
2050+
g.Expect(testEnv.Create(ctx, hr)).ToNot(HaveOccurred())
2051+
2052+
waitForSourceReady(ctx, g, hr, false)
2053+
2054+
g.Expect(hr.GetArtifact()).To(BeNil())
2055+
g.Expect(hr.Status.URL).To(BeEmpty())
2056+
g.Expect(controllerutil.ContainsFinalizer(hr, sourcev1.SourceFinalizer)).To(BeFalse())
2057+
g.Expect(conditions.Get(hr, meta.ReadyCondition).Reason).To(Equal(sourcev1.NoIndexReason))
2058+
g.Expect(conditions.Get(hr, meta.ReadyCondition).Message).To(ContainSubstring("do not produce an index artifact"))
2059+
g.Expect(intpredicates.HelmRepositoryOCIRequireMigration(hr)).To(BeFalse())
2060+
2061+
// Spec updates on a static Ready object must not wipe the condition.
2062+
patchHelper, err := patch.NewHelper(hr, testEnv.Client)
2063+
g.Expect(err).ToNot(HaveOccurred())
2064+
hr.Spec.URL = "oci://ghcr.io/stefanprodan/charts/podinfo"
2065+
g.Expect(patchHelper.Patch(ctx, hr)).ToNot(HaveOccurred())
2066+
2067+
g.Consistently(func() bool {
2068+
if err := testEnv.Get(ctx, client.ObjectKeyFromObject(hr), hr); err != nil {
2069+
return false
2070+
}
2071+
ready := conditions.Get(hr, meta.ReadyCondition)
2072+
return conditions.IsReady(hr) && ready != nil && ready.Reason == sourcev1.NoIndexReason && hr.GetArtifact() == nil
2073+
}, 2*time.Second, 200*time.Millisecond).Should(BeTrue())
2074+
}
2075+
2076+
func TestHelmRepositoryReconciler_migrationToStatic(t *testing.T) {
2077+
g := NewWithT(t)
2078+
2079+
obj := &sourcev1.HelmRepository{
2080+
ObjectMeta: metav1.ObjectMeta{
2081+
Name: "oci-repo",
2082+
Namespace: "default",
2083+
Generation: 1,
2084+
Finalizers: []string{sourcev1.SourceFinalizer},
2085+
},
2086+
Spec: sourcev1.HelmRepositorySpec{
2087+
Type: sourcev1.HelmRepositoryTypeOCI,
2088+
URL: "oci://example.com/charts",
2089+
Interval: metav1.Duration{Duration: interval},
2090+
},
2091+
Status: sourcev1.HelmRepositoryStatus{
2092+
ObservedGeneration: 1,
2093+
URL: "http://source-controller/index.yaml",
2094+
Artifact: &meta.Artifact{Path: "old-index.yaml"},
2095+
},
2096+
}
2097+
conditions.MarkTrue(obj, meta.ReadyCondition, meta.SucceededReason, "fetched index")
2098+
2099+
r := &HelmRepositoryReconciler{
2100+
Client: fakeclient.NewClientBuilder().
2101+
WithScheme(testEnv.GetScheme()).
2102+
WithObjects(obj).
2103+
WithStatusSubresource(&sourcev1.HelmRepository{}).
2104+
Build(),
2105+
EventRecorder: record.NewFakeRecorder(32),
2106+
Storage: testStorage,
2107+
}
2108+
2109+
_, err := r.Reconcile(context.TODO(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(obj)})
2110+
g.Expect(err).ToNot(HaveOccurred())
2111+
2112+
got := &sourcev1.HelmRepository{}
2113+
g.Expect(r.Client.Get(context.TODO(), client.ObjectKeyFromObject(obj), got)).To(Succeed())
2114+
g.Expect(controllerutil.ContainsFinalizer(got, sourcev1.SourceFinalizer)).To(BeFalse())
2115+
g.Expect(got.GetArtifact()).To(BeNil())
2116+
g.Expect(got.Status.URL).To(BeEmpty())
2117+
g.Expect(conditions.IsReady(got)).To(BeTrue())
2118+
g.Expect(conditions.Get(got, meta.ReadyCondition).Reason).To(Equal(sourcev1.NoIndexReason))
2119+
g.Expect(got.Status.ObservedGeneration).To(Equal(got.Generation))
2120+
g.Expect(intpredicates.HelmRepositoryOCIRequireMigration(got)).To(BeFalse())
2121+
2122+
// A second reconcile must not wipe Ready.
2123+
_, err = r.Reconcile(context.TODO(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(obj)})
2124+
g.Expect(err).ToNot(HaveOccurred())
2125+
got2 := &sourcev1.HelmRepository{}
2126+
g.Expect(r.Client.Get(context.TODO(), client.ObjectKeyFromObject(obj), got2)).To(Succeed())
2127+
g.Expect(conditions.IsReady(got2)).To(BeTrue())
2128+
g.Expect(conditions.Get(got2, meta.ReadyCondition).Reason).To(Equal(sourcev1.NoIndexReason))
2129+
g.Expect(got2.GetArtifact()).To(BeNil())
2130+
}
2131+
2132+
func TestHelmRepositoryReconciler_migrationToStatic_emptyStatus(t *testing.T) {
2133+
g := NewWithT(t)
2134+
2135+
obj := &sourcev1.HelmRepository{
2136+
ObjectMeta: metav1.ObjectMeta{
2137+
Name: "oci-empty",
2138+
Namespace: "default",
2139+
Generation: 1,
2140+
},
2141+
Spec: sourcev1.HelmRepositorySpec{
2142+
Type: sourcev1.HelmRepositoryTypeOCI,
2143+
URL: "oci://example.com/charts",
2144+
Interval: metav1.Duration{Duration: interval},
2145+
},
2146+
}
2147+
2148+
r := &HelmRepositoryReconciler{
2149+
Client: fakeclient.NewClientBuilder().
2150+
WithScheme(testEnv.GetScheme()).
2151+
WithObjects(obj).
2152+
WithStatusSubresource(&sourcev1.HelmRepository{}).
2153+
Build(),
2154+
EventRecorder: record.NewFakeRecorder(32),
2155+
Storage: testStorage,
2156+
}
2157+
2158+
_, err := r.Reconcile(context.TODO(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(obj)})
2159+
g.Expect(err).ToNot(HaveOccurred())
2160+
2161+
got := &sourcev1.HelmRepository{}
2162+
g.Expect(r.Client.Get(context.TODO(), client.ObjectKeyFromObject(obj), got)).To(Succeed())
2163+
g.Expect(conditions.IsReady(got)).To(BeTrue())
2164+
g.Expect(conditions.Get(got, meta.ReadyCondition).Reason).To(Equal(sourcev1.NoIndexReason))
2165+
g.Expect(intpredicates.HelmRepositoryOCIRequireMigration(got)).To(BeFalse())
2166+
2167+
_, err = r.Reconcile(context.TODO(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(obj)})
2168+
g.Expect(err).ToNot(HaveOccurred())
2169+
got2 := &sourcev1.HelmRepository{}
2170+
g.Expect(r.Client.Get(context.TODO(), client.ObjectKeyFromObject(obj), got2)).To(Succeed())
2171+
g.Expect(conditions.IsReady(got2)).To(BeTrue())
2172+
g.Expect(conditions.Get(got2, meta.ReadyCondition).Reason).To(Equal(sourcev1.NoIndexReason))
2173+
}

‎internal/predicates/helmrepository_type_predicate.go‎

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import (
2222
"sigs.k8s.io/controller-runtime/pkg/event"
2323
"sigs.k8s.io/controller-runtime/pkg/predicate"
2424

25+
"github.com/fluxcd/pkg/apis/meta"
26+
"github.com/fluxcd/pkg/runtime/conditions"
27+
2528
sourcev1 "github.com/fluxcd/source-controller/api/v1"
2629
)
2730

@@ -48,8 +51,12 @@ func (HelmRepositoryOCIMigrationPredicate) Delete(e event.DeleteEvent) bool {
4851
}
4952

5053
// HelmRepositoryOCIRequireMigration returns if a given HelmRepository of type
51-
// OCI requires migration to static object. For non-OCI HelmRepository, it
54+
// OCI requires migration to a static object. For non-OCI HelmRepository, it
5255
// returns true.
56+
//
57+
// An OCI object is fully migrated when the source finalizer is gone, no
58+
// leftover index Artifact remains, and Ready=True is set. Empty status is
59+
// not treated as migrated so a one-shot reconcile can set that condition.
5360
func HelmRepositoryOCIRequireMigration(o client.Object) bool {
5461
if o == nil {
5562
return false
@@ -65,22 +72,22 @@ func HelmRepositoryOCIRequireMigration(o client.Object) bool {
6572
return true
6673
}
6774

68-
if controllerutil.ContainsFinalizer(hr, sourcev1.SourceFinalizer) || !hasEmptyHelmRepositoryStatus(hr) {
75+
if controllerutil.ContainsFinalizer(hr, sourcev1.SourceFinalizer) {
6976
return true
7077
}
7178

72-
return false
79+
if isOCIHelmRepositoryStatic(hr) {
80+
return false
81+
}
82+
83+
return true
7384
}
7485

75-
// hasEmptyHelmRepositoryStatus checks if the status of a HelmRepository is
76-
// empty.
77-
func hasEmptyHelmRepositoryStatus(obj *sourcev1.HelmRepository) bool {
78-
if obj.Status.ObservedGeneration == 0 &&
79-
obj.Status.Conditions == nil &&
80-
obj.Status.URL == "" &&
81-
obj.Status.Artifact == nil &&
82-
obj.Status.ReconcileRequestStatus.LastHandledReconcileAt == "" {
83-
return true
86+
// isOCIHelmRepositoryStatic reports whether an OCI HelmRepository has completed
87+
// migration to a static object with Ready=True and no leftover index Artifact.
88+
func isOCIHelmRepositoryStatic(obj *sourcev1.HelmRepository) bool {
89+
if obj.Status.Artifact != nil || obj.Status.URL != "" {
90+
return false
8491
}
85-
return false
92+
return conditions.IsTrue(obj, meta.ReadyCondition)
8693
}

0 commit comments

Comments
 (0)