diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index d44ac70fb..d68cea8a6 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -138,6 +138,11 @@ jobs: kubectl -n install-create-target-ns get deployment install-create-target-ns-install-create-target-ns-podinfo kubectl -n helm-system delete -f config/testdata/install-create-target-ns + - name: Run install from helmChart test + run: | + kubectl -n helm-system apply -f config/testdata/install-from-hc-source + kubectl -n helm-system wait helmreleases/podinfo-from-hc --for=condition=ready --timeout=4m + kubectl -n helm-system delete -f config/testdata/install-from-hc-source - name: Run install from ocirepo test run: | kubectl -n helm-system apply -f config/testdata/install-from-ocirepo-source diff --git a/api/v2beta2/reference_types.go b/api/v2beta2/reference_types.go index 344e5038f..385118673 100644 --- a/api/v2beta2/reference_types.go +++ b/api/v2beta2/reference_types.go @@ -50,7 +50,7 @@ type CrossNamespaceSourceReference struct { APIVersion string `json:"apiVersion,omitempty"` // Kind of the referent. - // +kubebuilder:validation:Enum=OCIRepository + // +kubebuilder:validation:Enum=OCIRepository;HelmChart // +required Kind string `json:"kind"` diff --git a/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml b/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml index e38f8922e..ffdd84437 100644 --- a/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml +++ b/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml @@ -1436,6 +1436,7 @@ spec: description: Kind of the referent. enum: - OCIRepository + - HelmChart type: string name: description: Name of the referent. diff --git a/config/testdata/install-from-hc-source/test.yaml b/config/testdata/install-from-hc-source/test.yaml new file mode 100644 index 000000000..b84cf51c9 --- /dev/null +++ b/config/testdata/install-from-hc-source/test.yaml @@ -0,0 +1,29 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1beta2 +kind: HelmChart +metadata: + name: podinfo-hc +spec: + chart: podinfo + version: '6.2.1' + sourceRef: + kind: HelmRepository + name: podinfo-oci + interval: 30s + verify: + provider: cosign +--- +apiVersion: helm.toolkit.fluxcd.io/v2beta2 +kind: HelmRelease +metadata: + name: podinfo-from-hc +spec: + chartRef: + kind: HelmChart + name: podinfo-hc + interval: 30s + values: + resources: + requests: + cpu: 100m + memory: 64Mi diff --git a/docs/spec/v2beta2/helmreleases.md b/docs/spec/v2beta2/helmreleases.md index 02f475145..15b2f115a 100644 --- a/docs/spec/v2beta2/helmreleases.md +++ b/docs/spec/v2beta2/helmreleases.md @@ -205,16 +205,17 @@ HelmRelease object. ### Chart reference -`.spec.chartRef` is an optional field used to refer to an [OCIRepository resource](https://fluxcd.io/flux/components/source/ocirepositories/) +`.spec.chartRef` is an optional field used to refer to an [OCIRepository resource](https://fluxcd.io/flux/components/source/ocirepositories/) or a [HelmChart resource](https://fluxcd.io/flux/components/source/helmcharts/) from which to fetch the Helm chart. The chart is fetched by the controller with the -information provided by `.status.artifact` of the OCIRepository. +information provided by `.status.artifact` of the referenced resource. -The chart version of the last release attempt is reported in `.status.lastAttemptedRevision`. -The version is in the format `+`. The digest of the OCI artifact -is appended to the version to ensure that a change in the artifact content triggers -a new release. The controller will automatically perform a Helm upgrade when the -OCIRepository detects a new digest in the OCI artifact stored in registry, even if -the version inside `Chart.yaml` is unchanged. +For a referenced resource of `kind OCIRepository`, the chart version of the last +release attempt is reported in `.status.lastAttemptedRevision`. The version is in +the format `+`. The digest of the OCI artifact is appended +to the version to ensure that a change in the artifact content triggers a new release. +The controller will automatically perform a Helm upgrade when the `OCIRepository` +detects a new digest in the OCI artifact stored in registry, even if the version +inside `Chart.yaml` is unchanged. **Warning:** One of `.spec.chart` or `.spec.chartRef` must be set, but not both. When switching from `.spec.chart` to `.spec.chartRef`, the controller will perform @@ -225,6 +226,8 @@ references with the `--no-cross-namespace-refs=true` controller flag. When this set, the HelmRelease can only refer to OCIRepositories in the same namespace as the HelmRelease object. +#### OCIRepository reference example + ```yaml apiVersion: source.toolkit.fluxcd.io/v1beta2 kind: OCIRepository @@ -252,6 +255,38 @@ spec: replicaCount: 2 ``` +#### HelmChart reference example + +```yaml +apiVersion: source.toolkit.fluxcd.io/v1beta2 +kind: HelmChart +metadata: + name: podinfo + namespace: default +spec: + interval: 5m0s + chart: podinfo + reconcileStrategy: ChartVersion + sourceRef: + kind: HelmRepository + name: podinfo + version: '5.*' +--- +apiVersion: helm.toolkit.fluxcd.io/v2beta2 +kind: HelmRelease +metadata: + name: podinfo + namespace: default +spec: + interval: 10m + chartRef: + kind: HelmChart + name: podinfo + namespace: default + values: + replicaCount: 2 +``` + ### Release name `.spec.releaseName` is an optional field used to specify the name of the Helm diff --git a/internal/controller/helmrelease_controller_test.go b/internal/controller/helmrelease_controller_test.go index 6cd56bc72..739b4032b 100644 --- a/internal/controller/helmrelease_controller_test.go +++ b/internal/controller/helmrelease_controller_test.go @@ -846,6 +846,322 @@ func TestHelmReleaseReconciler_reconcileRelease(t *testing.T) { }) } +func TestHelmReleaseReconciler_reconcileReleaseFromHelmChartSource(t *testing.T) { + t.Run("handles chartRef and Chart definition failure", func(t *testing.T) { + g := NewWithT(t) + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "release", + Namespace: "mock", + }, + Spec: v2.HelmReleaseSpec{ + ChartRef: &v2.CrossNamespaceSourceReference{ + Kind: sourcev1b2.HelmChartKind, + Name: "chart", + Namespace: "mock", + }, + Chart: v2.HelmChartTemplate{ + Spec: v2.HelmChartTemplateSpec{ + Chart: "mychart", + SourceRef: v2.CrossNamespaceObjectReference{ + Name: "something", + }, + }, + }, + }, + } + + r := &HelmReleaseReconciler{ + Client: fake.NewClientBuilder(). + WithScheme(NewTestScheme()). + WithStatusSubresource(&v2.HelmRelease{}). + WithObjects(obj). + Build(), + } + + res, err := r.Reconcile(context.TODO(), reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: obj.GetNamespace(), + Name: obj.GetName(), + }, + }) + + // only chartRef or Chart must be set + g.Expect(errors.Is(err, reconcile.TerminalError(fmt.Errorf("invalid Chart reference")))).To(BeTrue()) + g.Expect(res.IsZero()).To(BeTrue()) + }) + + t.Run("handles ChartRef get failure", func(t *testing.T) { + g := NewWithT(t) + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "release", + Namespace: "mock", + }, + Spec: v2.HelmReleaseSpec{ + ChartRef: &v2.CrossNamespaceSourceReference{ + Kind: sourcev1b2.HelmChartKind, + Name: "chart", + Namespace: "mock", + }, + }, + } + + r := &HelmReleaseReconciler{ + Client: fake.NewClientBuilder(). + WithScheme(NewTestScheme()). + WithStatusSubresource(&v2.HelmRelease{}). + WithObjects(obj). + Build(), + EventRecorder: record.NewFakeRecorder(32), + } + + _, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj) + g.Expect(err).To(HaveOccurred()) + + g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "Fulfilling prerequisites"), + *conditions.FalseCondition(meta.ReadyCondition, v2.ArtifactFailedReason, "could not get Source object"), + })) + }) + + t.Run("handles ACL error for ChartRef", func(t *testing.T) { + g := NewWithT(t) + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "release", + Namespace: "mock", + }, + Spec: v2.HelmReleaseSpec{ + ChartRef: &v2.CrossNamespaceSourceReference{ + Kind: sourcev1b2.HelmChartKind, + Name: "chart", + Namespace: "mock-other", + }, + }, + } + + r := &HelmReleaseReconciler{ + Client: fake.NewClientBuilder(). + WithScheme(NewTestScheme()). + WithStatusSubresource(&v2.HelmRelease{}). + WithObjects(obj). + Build(), + EventRecorder: record.NewFakeRecorder(32), + } + + res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj) + g.Expect(err).To(HaveOccurred()) + g.Expect(errors.Is(err, reconcile.TerminalError(nil))).To(BeTrue()) + g.Expect(res.IsZero()).To(BeTrue()) + + g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ + *conditions.TrueCondition(meta.StalledCondition, acl.AccessDeniedReason, "cross-namespace references are not allowed"), + *conditions.FalseCondition(meta.ReadyCondition, acl.AccessDeniedReason, "cross-namespace references are not allowed"), + })) + }) + + t.Run("waits for ChartRef to have an Artifact", func(t *testing.T) { + g := NewWithT(t) + + chart := &sourcev1b2.HelmChart{ + TypeMeta: metav1.TypeMeta{ + APIVersion: sourcev1b2.GroupVersion.String(), + Kind: sourcev1b2.HelmChartKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "chart", + Namespace: "mock", + Generation: 2, + }, + Status: sourcev1b2.HelmChartStatus{ + ObservedGeneration: 2, + Conditions: []metav1.Condition{ + { + Type: meta.ReadyCondition, + Status: metav1.ConditionTrue, + }, + }, + }, + } + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "release", + Namespace: "mock", + }, + Spec: v2.HelmReleaseSpec{ + ChartRef: &v2.CrossNamespaceSourceReference{ + Kind: sourcev1b2.HelmChartKind, + Name: "chart", + Namespace: "mock", + }, + Interval: metav1.Duration{Duration: 1 * time.Second}, + }, + } + + r := &HelmReleaseReconciler{ + Client: fake.NewClientBuilder(). + WithScheme(NewTestScheme()). + WithStatusSubresource(&v2.HelmRelease{}). + WithObjects(chart, obj). + Build(), + } + + res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj) + g.Expect(err).To(Equal(errWaitForChart)) + g.Expect(res.RequeueAfter).To(Equal(obj.Spec.Interval.Duration)) + + g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""), + *conditions.FalseCondition(meta.ReadyCondition, "SourceNotReady", "HelmChart 'mock/chart' is not ready"), + })) + }) + + t.Run("reports Helm chart load failure", func(t *testing.T) { + g := NewWithT(t) + + chart := &sourcev1b2.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: "chart", + Namespace: "mock", + Generation: 2, + }, + Spec: sourcev1b2.HelmChartSpec{ + Interval: metav1.Duration{Duration: 1 * time.Second}, + }, + Status: sourcev1b2.HelmChartStatus{ + ObservedGeneration: 2, + Artifact: &sourcev1.Artifact{ + URL: testServer.URL() + "/does-not-exist", + }, + Conditions: []metav1.Condition{ + { + Type: meta.ReadyCondition, + Status: metav1.ConditionTrue, + }, + }, + }, + } + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "release", + Namespace: "mock", + }, + Spec: v2.HelmReleaseSpec{ + ChartRef: &v2.CrossNamespaceSourceReference{ + Kind: sourcev1b2.HelmChartKind, + Name: "chart", + Namespace: "mock", + }, + }, + } + + r := &HelmReleaseReconciler{ + Client: fake.NewClientBuilder(). + WithScheme(NewTestScheme()). + WithStatusSubresource(&v2.HelmRelease{}). + WithObjects(chart, obj). + Build(), + requeueDependency: 10 * time.Second, + EventRecorder: record.NewFakeRecorder(32), + } + + res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj) + g.Expect(err).To(Equal(errWaitForDependency)) + g.Expect(res.RequeueAfter).To(Equal(r.requeueDependency)) + + g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""), + *conditions.FalseCondition(meta.ReadyCondition, v2.ArtifactFailedReason, "Source not ready"), + })) + }) + t.Run("report helmChart load failure when switching from existing HelmChat to chartRef", func(t *testing.T) { + g := NewWithT(t) + + chart := &sourcev1b2.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: "chart", + Namespace: "mock", + Generation: 1, + }, + Status: sourcev1b2.HelmChartStatus{ + ObservedGeneration: 1, + Artifact: &sourcev1.Artifact{}, + Conditions: []metav1.Condition{ + { + Type: meta.ReadyCondition, + Status: metav1.ConditionTrue, + }, + }, + }, + } + + sharedChart := &sourcev1b2.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sharedChart", + Namespace: "mock", + Generation: 2, + }, + Spec: sourcev1b2.HelmChartSpec{ + Interval: metav1.Duration{Duration: 1 * time.Second}, + }, + Status: sourcev1b2.HelmChartStatus{ + ObservedGeneration: 2, + Artifact: &sourcev1.Artifact{ + URL: testServer.URL() + "/does-not-exist", + }, + Conditions: []metav1.Condition{ + { + Type: meta.ReadyCondition, + Status: metav1.ConditionTrue, + }, + }, + }, + } + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "release", + Namespace: "mock", + }, + Spec: v2.HelmReleaseSpec{ + ChartRef: &v2.CrossNamespaceSourceReference{ + Kind: sourcev1b2.HelmChartKind, + Name: "sharedChart", + Namespace: "mock", + }, + }, + Status: v2.HelmReleaseStatus{ + HelmChart: "mock/chart", + }, + } + + r := &HelmReleaseReconciler{ + Client: fake.NewClientBuilder(). + WithScheme(NewTestScheme()). + WithStatusSubresource(&v2.HelmRelease{}). + WithObjects(chart, sharedChart, obj). + Build(), + requeueDependency: 10 * time.Second, + EventRecorder: record.NewFakeRecorder(32), + } + + res, err := r.reconcileRelease(context.TODO(), patch.NewSerialPatcher(obj, r.Client), obj) + g.Expect(err).To(Equal(errWaitForDependency)) + g.Expect(res.RequeueAfter).To(Equal(r.requeueDependency)) + + g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ + *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, ""), + *conditions.FalseCondition(meta.ReadyCondition, v2.ArtifactFailedReason, "Source not ready"), + })) + }) +} + func TestHelmReleaseReconciler_reconcileReleaseFromOCIRepositorySource(t *testing.T) { t.Run("handles chartRef and Chart definition failure", func(t *testing.T) {