Repository: rancher/fleet
Pull request #3831 (2025-06-23 12:56:05)

Skip polling for OCI and tarball charts
OCI and direct referencing through tarballs does not involve an `index.yaml` file which Helm can search for new versions over time. Therefore, Fleet now skips Helm polling in such cases.

Refers to #3764
Follow-up to #3784.

## Additional Information

### Checklist

- [x] <!-- If applicable,--> I have updated the documentation via a pull request in the
[fleet-docs](https://github.com/rancher/fleet-docs) repository: see https://github.com/rancher/fleet-docs/pull/272



Patch:
diff --git a/internal/cmd/controller/helmops/reconciler/helmop_controller.go b/internal/cmd/controller/helmops/reconciler/helmop_controller.go
index 55c711b2..859cc0c2 100644
--- a/internal/cmd/controller/helmops/reconciler/helmop_controller.go
+++ b/internal/cmd/controller/helmops/reconciler/helmop_controller.go
@@ -407,6 +407,16 @@ func usesPolling(helmop fleet.HelmOp) bool {
 		return false
 	}
 
+	// Polling does not apply to OCI and tarball charts, where no index.yaml file is available to check for new
+	// chart versions.
+	if strings.HasSuffix(strings.ToLower(helmop.Spec.Helm.Chart), ".tgz") {
+		return false
+	}
+
+	if strings.HasPrefix(strings.ToLower(helmop.Spec.Helm.Repo), "oci://") {
+		return false
+	}
+
 	// we only need to poll if the version is set to a constraint on versions, which may resolve to
 	// different available versions as the contents of the Helm repository evolves over time.
 	_, err := semver.StrictNewVersion(helmop.Spec.Helm.Version)


Test patch:
diff --git a/internal/cmd/controller/helmops/reconciler/helmop_controller_test.go b/internal/cmd/controller/helmops/reconciler/helmop_controller_test.go
index 8c38ad12..8fd60911 100644
--- a/internal/cmd/controller/helmops/reconciler/helmop_controller_test.go
+++ b/internal/cmd/controller/helmops/reconciler/helmop_controller_test.go
@@ -761,6 +761,108 @@ func TestReconcile_ManagePollingJobs(t *testing.T) {
 				scheduler.EXPECT().DeleteJob(gomock.Any()).Return(nil)
 			},
 		},
+		{
+			name: "does not poll when referencing an OCI chart",
+			helmOp: fleet.HelmOp{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "helmop",
+					Namespace: "default",
+				},
+				Spec: fleet.HelmOpSpec{
+					PollingInterval:       &metav1.Duration{Duration: 1 * time.Minute},
+					InsecureSkipTLSverify: true,
+					BundleSpec: fleet.BundleSpec{
+						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+							Helm: &fleet.HelmOptions{
+								Repo:    "oci://chart",
+								Version: "0.x.x",
+							},
+						},
+					},
+				},
+			},
+			expectedSchedulerCalls: func(_ *gomock.Controller, scheduler *mocks.MockScheduler, helmop fleet.HelmOp) {
+				scheduler.EXPECT().GetScheduledJob(gomock.Any()).Return(nil, quartz.ErrJobNotFound)
+
+				// No job expected to be created nor deleted
+			},
+		},
+		{
+			name: "deletes existing polling job when referencing an OCI chart",
+			helmOp: fleet.HelmOp{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "helmop",
+					Namespace: "default",
+				},
+				Spec: fleet.HelmOpSpec{
+					PollingInterval:       &metav1.Duration{Duration: 1 * time.Minute},
+					InsecureSkipTLSverify: true,
+					BundleSpec: fleet.BundleSpec{
+						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+							Helm: &fleet.HelmOptions{
+								Repo:    "oci://chart",
+								Version: "0.x.x",
+							},
+						},
+					},
+				},
+			},
+			expectedSchedulerCalls: func(ctrl *gomock.Controller, scheduler *mocks.MockScheduler, helmop fleet.HelmOp) {
+				job := mocks.NewMockScheduledJob(ctrl)
+				scheduler.EXPECT().GetScheduledJob(gomock.Any()).Return(job, nil)
+				scheduler.EXPECT().DeleteJob(gomock.Any()).Return(nil)
+			},
+		},
+		{
+			name: "does not poll when referencing a tarball chart",
+			helmOp: fleet.HelmOp{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "helmop",
+					Namespace: "default",
+				},
+				Spec: fleet.HelmOpSpec{
+					PollingInterval:       &metav1.Duration{Duration: 1 * time.Minute},
+					InsecureSkipTLSverify: true,
+					BundleSpec: fleet.BundleSpec{
+						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+							Helm: &fleet.HelmOptions{
+								Chart: fmt.Sprintf("%s/alpine/alpine-0.1.0.tgz", svr1.URL),
+							},
+						},
+					},
+				},
+			},
+			expectedSchedulerCalls: func(_ *gomock.Controller, scheduler *mocks.MockScheduler, helmop fleet.HelmOp) {
+				scheduler.EXPECT().GetScheduledJob(gomock.Any()).Return(nil, quartz.ErrJobNotFound)
+
+				// No job expected to be created nor deleted
+			},
+		},
+		{
+			name: "deletes existing polling job when referencing a tarball chart",
+			helmOp: fleet.HelmOp{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "helmop",
+					Namespace: "default",
+				},
+				Spec: fleet.HelmOpSpec{
+					PollingInterval:       &metav1.Duration{Duration: 1 * time.Minute},
+					InsecureSkipTLSverify: true,
+					BundleSpec: fleet.BundleSpec{
+						BundleDeploymentOptions: fleet.BundleDeploymentOptions{
+							Helm: &fleet.HelmOptions{
+								Chart: fmt.Sprintf("%s/alpine/alpine-0.1.0.tgz", svr1.URL),
+							},
+						},
+					},
+				},
+			},
+			expectedSchedulerCalls: func(ctrl *gomock.Controller, scheduler *mocks.MockScheduler, helmop fleet.HelmOp) {
+				job := mocks.NewMockScheduledJob(ctrl)
+				scheduler.EXPECT().GetScheduledJob(gomock.Any()).Return(job, nil)
+				scheduler.EXPECT().DeleteJob(gomock.Any()).Return(nil)
+			},
+		},
 		{
 			name: "returns an error when failing to delete a polling job",
 			helmOp: fleet.HelmOp{