From 44bca66f99d9865dac95a7c1b75adf93a9173768 Mon Sep 17 00:00:00 2001 From: Matheus Pimenta Date: Sat, 3 Oct 2026 22:10:57 +0100 Subject: [PATCH] Introduce OCIRepository for chart template Signed-off-by: Matheus Pimenta --- api/go.mod | 6 +- api/go.sum | 8 +- api/v2/helmrelease_types.go | 194 ++-- api/v2/helmrelease_types_test.go | 203 ++++ api/v2/reference_types.go | 45 +- api/v2/zz_generated.deepcopy.go | 48 +- .../helm.toolkit.fluxcd.io_helmreleases.yaml | 246 ++++- docs/api/v2/helm.md | 246 +++-- docs/spec/v2/helmreleases.md | 180 ++-- go.mod | 4 +- go.sum | 4 +- internal/acl/acl.go | 10 +- internal/acl/acl_test.go | 14 +- internal/controller/helmrelease_controller.go | 58 +- .../controller/helmrelease_controller_test.go | 143 ++- internal/controller/helmrelease_manager.go | 15 +- .../controller/helmrelease_validation_test.go | 246 +++++ internal/reconcile/helmchart_template.go | 205 +++-- internal/reconcile/helmchart_template_test.go | 870 +++++++++++++++++- 19 files changed, 2264 insertions(+), 481 deletions(-) create mode 100644 api/v2/helmrelease_types_test.go create mode 100644 internal/controller/helmrelease_validation_test.go diff --git a/api/go.mod b/api/go.mod index 83694cc91..5bf4423cf 100644 --- a/api/go.mod +++ b/api/go.mod @@ -5,13 +5,15 @@ go 1.26.0 require ( github.com/fluxcd/pkg/apis/kustomize v1.21.0 github.com/fluxcd/pkg/apis/meta v1.32.0 + github.com/fluxcd/source-controller/api v1.9.6 k8s.io/apiextensions-apiserver v0.37.0 k8s.io/apimachinery v0.37.0 sigs.k8s.io/yaml v1.6.0 ) require ( - github.com/fxamacker/cbor/v2 v2.9.1 // indirect + github.com/fluxcd/pkg/apis/acl v0.11.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -28,3 +30,5 @@ require ( sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect ) + +replace github.com/fluxcd/source-controller/api => github.com/fluxcd/source-controller/api v1.9.1-0.20261005091731-e47a59ad5d01 diff --git a/api/go.sum b/api/go.sum index 98c7a31b2..30191508a 100644 --- a/api/go.sum +++ b/api/go.sum @@ -2,12 +2,16 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fluxcd/pkg/apis/acl v0.11.0 h1:BxThatpA6qd5Qzwim6/hRu0gdAX0JxNm3PledDuJqXo= +github.com/fluxcd/pkg/apis/acl v0.11.0/go.mod h1:a87i2A7AlFO5N2J8CxtzaUCCDmuLLWOHwkKu3eJF5fY= github.com/fluxcd/pkg/apis/kustomize v1.21.0 h1:dNR/mcuEdziBQKH2bG/B1X6MmASFv9ijXU7PdY8s2JU= github.com/fluxcd/pkg/apis/kustomize v1.21.0/go.mod h1:cN3wx9ZwzYkjYJ2ugTA7yjiW19IirZxiEwZhu4SFxyc= github.com/fluxcd/pkg/apis/meta v1.32.0 h1:jWuNuIziUM8NOrhZB7vovdFQ4wBYRNJoAn+XUoYlj1I= github.com/fluxcd/pkg/apis/meta v1.32.0/go.mod h1:bZmU0RbSwFzsCg9sgjhbSWxgSbUy+3Oh/s7qUCZjwPk= -github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= -github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fluxcd/source-controller/api v1.9.1-0.20261005091731-e47a59ad5d01 h1:QKNUNGjIV94G0JvE++lXtSzoEzyRlsEHMAuvo6I/VaM= +github.com/fluxcd/source-controller/api v1.9.1-0.20261005091731-e47a59ad5d01/go.mod h1:EOuCFZQd2k3bBI+lte0x771y/QE9fR6JeGWxmxTuNNU= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= diff --git a/api/v2/helmrelease_types.go b/api/v2/helmrelease_types.go index f9c74d67c..6e56783f9 100644 --- a/api/v2/helmrelease_types.go +++ b/api/v2/helmrelease_types.go @@ -27,6 +27,7 @@ import ( "github.com/fluxcd/pkg/apis/kustomize" "github.com/fluxcd/pkg/apis/meta" + sourcev1 "github.com/fluxcd/source-controller/api/v1" ) const ( @@ -45,8 +46,9 @@ const ( // HelmReleaseSpec defines the desired state of a Helm release. // +kubebuilder:validation:XValidation:rule="(has(self.chart) && !has(self.chartRef)) || (!has(self.chart) && has(self.chartRef))", message="either chart or chartRef must be set" type HelmReleaseSpec struct { - // Chart defines the template of the v1.HelmChart that should be created - // for this HelmRelease. + // Chart defines the template of the source object that should be + // created for this HelmRelease. The Kind field determines whether a + // sourcev1.HelmChart or sourcev1.OCIRepository is created. // +optional Chart *HelmChartTemplate `json:"chart,omitempty"` @@ -208,6 +210,24 @@ type HelmReleaseSpec struct { HealthCheckExprs []kustomize.CustomHealthCheck `json:"healthCheckExprs,omitempty"` } +func (in *HelmRelease) GetHelmChartReference() *HelmChartReference { + if in == nil || in.Spec.ChartRef == nil { + return nil + } + ref := &HelmChartReference{ + Kind: in.Spec.ChartRef.Kind, + Name: in.Spec.ChartRef.Name, + Namespace: in.Spec.ChartRef.Namespace, + } + if ref.Kind == "" { + ref.Kind = sourcev1.HelmChartKind + } + if ref.Namespace == "" { + ref.Namespace = in.GetNamespace() + } + return ref +} + // +kubebuilder:object:generate=false type ValuesReference = meta.ValuesReference @@ -347,20 +367,45 @@ func (d DriftDetection) MustDetectChanges() bool { } // HelmChartTemplate defines the template from which the controller will -// generate a v1.HelmChart object in the same namespace as the referenced -// v1.Source. +// generate a sourcev1.HelmChart or sourcev1.OCIRepository object. The +// Kind field determines which object is generated. +// +// +kubebuilder:validation:XValidation:rule="!has(self.kind) || self.kind != 'OCIRepository' || (has(self.spec.url) && size(self.spec.url) > 0)", message="chart.spec.url must be set when chart.kind is 'OCIRepository'" +// +kubebuilder:validation:XValidation:rule="!has(self.spec.url) || (has(self.kind) && self.kind == 'OCIRepository')",message="chart.spec.url requires chart.kind: 'OCIRepository'; for a HelmChart template set spec.chart and spec.sourceRef instead" +// +kubebuilder:validation:XValidation:rule="(has(self.kind) && self.kind == 'OCIRepository') || (has(self.spec.chart) && has(self.spec.sourceRef))", message="chart.spec.chart and chart.spec.sourceRef must be set when chart.kind is not 'OCIRepository'" +// +kubebuilder:validation:XValidation:rule="!has(self.kind) || self.kind != 'OCIRepository' || !has(self.spec.chart)", message="chart.spec.chart cannot be set when chart.kind is 'OCIRepository'" +// +kubebuilder:validation:XValidation:rule="!has(self.kind) || self.kind != 'OCIRepository' || !has(self.spec.version) || self.spec.version == '*'", message="chart.spec.version cannot be set when chart.kind is 'OCIRepository'" +// +kubebuilder:validation:XValidation:rule="!has(self.kind) || self.kind != 'OCIRepository' || !has(self.spec.sourceRef)", message="chart.spec.sourceRef cannot be set when chart.kind is 'OCIRepository'" +// +kubebuilder:validation:XValidation:rule="!has(self.kind) || self.kind != 'OCIRepository' || !has(self.spec.reconcileStrategy) || self.spec.reconcileStrategy == 'ChartVersion'", message="chart.spec.reconcileStrategy cannot be set when chart.kind is 'OCIRepository'" +// +kubebuilder:validation:XValidation:rule="!has(self.kind) || self.kind != 'OCIRepository' || !has(self.spec.valuesFiles)", message="chart.spec.valuesFiles cannot be set when chart.kind is 'OCIRepository'" +// +kubebuilder:validation:XValidation:rule="!has(self.kind) || self.kind != 'OCIRepository' || !has(self.spec.ignoreMissingValuesFiles)", message="chart.spec.ignoreMissingValuesFiles cannot be set when chart.kind is 'OCIRepository'" type HelmChartTemplate struct { + // Kind is the kind of the source object generated from this template. Valid values: + // - HelmChart (default): generates a source.toolkit.fluxcd.io/v1 HelmChart from + // the HelmChartSpec-compatible fields under .spec.chart.spec. In particular, + // .spec.chart.spec.chart and .spec.chart.spec.sourceRef are required. + // - OCIRepository: generates a source.toolkit.fluxcd.io/v1 OCIRepository from + // the OCIRepositorySpec fields under .spec.chart.spec. In particular, + // .spec.chart.spec.url is required, and .spec.chart.spec.interval defaults + // to .spec.interval (interval is required in a plain OCIRepository). + // Use OCIRepository for charts hosted in OCI registries; use HelmChart for + // HelmRepository, GitRepository or Bucket sources. + // + // +kubebuilder:validation:Enum=HelmChart;OCIRepository + // +optional + Kind string `json:"kind,omitempty"` + // ObjectMeta holds the template for metadata like labels and annotations. // +optional ObjectMeta *HelmChartTemplateObjectMeta `json:"metadata,omitempty"` - // Spec holds the template for the v1.HelmChartSpec for this HelmRelease. + // Spec holds the spec of the object generated for this HelmRelease. // +required Spec HelmChartTemplateSpec `json:"spec"` } -// HelmChartTemplateObjectMeta defines the template for the ObjectMeta of a -// v1.HelmChart. +// HelmChartTemplateObjectMeta defines the template for the ObjectMeta of the +// generated object. type HelmChartTemplateObjectMeta struct { // Map of string keys and values that can be used to organize and categorize // (scope and select) objects. @@ -377,37 +422,30 @@ type HelmChartTemplateObjectMeta struct { } // HelmChartTemplateSpec defines the template from which the controller will -// generate a v1.HelmChartSpec object. +// generate a sourcev1.HelmChartSpec or sourcev1.OCIRepositorySpec object. type HelmChartTemplateSpec struct { + sourcev1.OCIRepositorySpec `json:",inline"` + // The name or path the Helm chart is available at in the SourceRef. // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=2048 - // +required - Chart string `json:"chart"` + // +optional + Chart string `json:"chart,omitempty"` // Version semver expression, ignored for charts from v1.GitRepository and - // v1beta2.Bucket sources. Defaults to latest when omitted. - // +kubebuilder:default:=* + // v1beta2.Bucket sources. Defaults to latest when omitted i.e. to '*'. // +optional Version string `json:"version,omitempty"` // The name and namespace of the v1.Source the chart is available at. - // +required - SourceRef CrossNamespaceObjectReference `json:"sourceRef"` - - // Interval at which to check the v1.Source for updates. Defaults to - // 'HelmReleaseSpec.Interval'. - // +kubebuilder:validation:Type=string - // +kubebuilder:validation:Pattern="^([0-9]+(\\.[0-9]+)?(ms|s|m|h))+$" // +optional - Interval *metav1.Duration `json:"interval,omitempty"` + SourceRef *CrossNamespaceObjectReference `json:"sourceRef,omitempty"` // Determines what enables the creation of a new artifact. Valid values are // ('ChartVersion', 'Revision'). // See the documentation of the values for an explanation on their behavior. // Defaults to ChartVersion when omitted. // +kubebuilder:validation:Enum=ChartVersion;Revision - // +kubebuilder:default:=ChartVersion // +optional ReconcileStrategy string `json:"reconcileStrategy,omitempty"` @@ -421,46 +459,33 @@ type HelmChartTemplateSpec struct { // IgnoreMissingValuesFiles controls whether to silently ignore missing values files rather than failing. // +optional IgnoreMissingValuesFiles bool `json:"ignoreMissingValuesFiles,omitempty"` - - // Verify contains the secret name containing the trusted public keys - // used to verify the signature and specifies which provider to use to check - // whether OCI image is authentic. - // This field is only supported for OCI sources. - // Chart dependencies, which are not bundled in the umbrella chart artifact, - // are not verified. - // +optional - Verify *HelmChartTemplateVerification `json:"verify,omitempty"` } -// GetInterval returns the configured interval for the v1.HelmChart, -// or the given default. -func (in HelmChartTemplate) GetInterval(defaultInterval metav1.Duration) metav1.Duration { - if in.Spec.Interval == nil { - return defaultInterval +// GetTemplateInterval returns the configured interval for the generated +// object, or the HelmRelease interval if not set on the chart template. +func (in *HelmRelease) GetTemplateInterval() metav1.Duration { + if in.Spec.Chart == nil || in.Spec.Chart.Spec.Interval == nil { + return in.Spec.Interval } - return *in.Spec.Interval + return *in.Spec.Chart.Spec.Interval } -// GetNamespace returns the namespace targeted namespace for the -// v1.HelmChart, or the given default. -func (in HelmChartTemplate) GetNamespace(defaultNamespace string) string { - if in.Spec.SourceRef.Namespace == "" { - return defaultNamespace +// GetVersion returns the configured version for the generated object, +// or the default '*'. +func (in HelmChartTemplateSpec) GetVersion() string { + if in.Version == "" { + return "*" } - return in.Spec.SourceRef.Namespace + return in.Version } -// HelmChartTemplateVerification verifies the authenticity of an OCI Helm chart. -type HelmChartTemplateVerification struct { - // Provider specifies the technology used to sign the OCI Helm chart. - // +kubebuilder:validation:Enum=cosign;notation - // +kubebuilder:default:=cosign - Provider string `json:"provider"` - - // SecretRef specifies the Kubernetes Secret containing the - // trusted public keys. - // +optional - SecretRef *meta.LocalObjectReference `json:"secretRef,omitempty"` +// GetReconcileStrategy returns the configured reconcile strategy for the generated object, +// or the default 'ChartVersion'. +func (in HelmChartTemplateSpec) GetReconcileStrategy() string { + if in.ReconcileStrategy == "" { + return sourcev1.ReconcileStrategyChartVersion + } + return in.ReconcileStrategy } // WaitStrategyName is a strategy for waiting for resources to be ready. @@ -1308,7 +1333,9 @@ type HelmReleaseStatus struct { Conditions []metav1.Condition `json:"conditions,omitempty"` // HelmChart is the namespaced name of the HelmChart resource created by - // the controller for the HelmRelease. + // the controller for the HelmRelease, in the format '/', + // or the typed and namespaced name of the OCIRepository resource, in the + // format '//'. // +optional HelmChart string `json:"helmChart,omitempty"` @@ -1408,15 +1435,42 @@ func (in *HelmReleaseStatus) ClearFailures() { in.UpgradeFailures = 0 } -// GetHelmChart returns the namespace and name of the HelmChart. -func (in HelmReleaseStatus) GetHelmChart() (string, string) { +// HasChart returns true if the status has a HelmChart. +func (in *HelmReleaseStatus) HasChart() bool { + return in.HelmChart != "" +} + +// SetChart sets the namespaced name of the chart created by the controller +// for the HelmRelease. A HelmChart is stored as '/' for +// backwards compatibility, while any other kind is stored as +// '//'. +func (in *HelmReleaseStatus) SetChart(ref *HelmChartReference) { + in.HelmChart = strings.TrimPrefix(ref.String(), sourcev1.HelmChartKind+"/") +} + +// GetHelmChartReference parses the typed and namespaced reference of the chart +// from the status. A '/' value is interpreted as a HelmChart +// for backwards compatibility. +func (in HelmReleaseStatus) GetHelmChartReference() *HelmChartReference { if in.HelmChart == "" { - return "", "" + return nil } - if split := strings.Split(in.HelmChart, string(types.Separator)); len(split) > 1 { - return split[0], split[1] + switch s := strings.Split(in.HelmChart, string(types.Separator)); len(s) { + case 2: + return &HelmChartReference{ + Kind: sourcev1.HelmChartKind, + Namespace: s[0], + Name: s[1], + } + case 3: + return &HelmChartReference{ + Kind: s[0], + Namespace: s[1], + Name: s[2], + } + default: + return nil } - return "", "" } func (in *HelmReleaseStatus) GetLastAttemptedRevision() string { @@ -1576,9 +1630,23 @@ func (in HelmRelease) GetStorageNamespace() string { return in.Namespace } -// GetHelmChartName returns the name used by the controller for the HelmChart creation. -func (in HelmRelease) GetHelmChartName() string { - return strings.Join([]string{in.Namespace, in.Name}, "-") +// GetHelmChartTemplateReference returns the typed and namespaced reference of the HelmChartTemplate. +func (in *HelmRelease) GetHelmChartTemplateReference() *HelmChartReference { + if in == nil || in.Spec.Chart == nil { + return nil + } + ref := &HelmChartReference{ + Kind: in.Spec.Chart.Kind, + Name: strings.Join([]string{in.Namespace, in.Name}, "-"), + Namespace: in.GetNamespace(), + } + if ref.Kind == "" { + ref.Kind = sourcev1.HelmChartKind + } + if ref.Kind == sourcev1.HelmChartKind && in.Spec.Chart.Spec.SourceRef != nil && in.Spec.Chart.Spec.SourceRef.Namespace != "" { + ref.Namespace = in.Spec.Chart.Spec.SourceRef.Namespace + } + return ref } // GetTimeout returns the configured Timeout, or the default of 300s. diff --git a/api/v2/helmrelease_types_test.go b/api/v2/helmrelease_types_test.go new file mode 100644 index 000000000..7ca65b707 --- /dev/null +++ b/api/v2/helmrelease_types_test.go @@ -0,0 +1,203 @@ +/* +Copyright 2026 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +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 v2 + +import ( + "reflect" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + sourcev1 "github.com/fluxcd/source-controller/api/v1" +) + +func TestHelmReleaseStatus_SetChart(t *testing.T) { + tests := []struct { + name string + ref *HelmChartReference + want string + }{ + { + name: "HelmChart omits the kind for backwards compatibility", + ref: &HelmChartReference{Kind: "HelmChart", Namespace: "default", Name: "podinfo"}, + want: "default/podinfo", + }, + { + name: "OCIRepository includes the kind", + ref: &HelmChartReference{Kind: "OCIRepository", Namespace: "default", Name: "podinfo"}, + want: "OCIRepository/default/podinfo", + }, + { + name: "nil clears the reference", + ref: nil, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var status HelmReleaseStatus + status.SetChart(tt.ref) + if status.HelmChart != tt.want { + t.Errorf("SetChart() = %q, want %q", status.HelmChart, tt.want) + } + }) + } +} + +func TestHelmReleaseStatus_GetHelmChartReference(t *testing.T) { + tests := []struct { + name string + in string + want *HelmChartReference + }{ + { + name: "legacy HelmChart reference", + in: "default/podinfo", + want: &HelmChartReference{Kind: "HelmChart", Namespace: "default", Name: "podinfo"}, + }, + { + name: "typed OCIRepository reference", + in: "OCIRepository/default/podinfo", + want: &HelmChartReference{Kind: "OCIRepository", Namespace: "default", Name: "podinfo"}, + }, + { + name: "empty", + in: "", + want: nil, + }, + { + name: "unexpected format", + in: "default/podinfo/extra/segment", + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + status := HelmReleaseStatus{HelmChart: tt.in} + got := status.GetHelmChartReference() + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetHelmChartReference() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestHelmChartTemplateSpec_GetVersion(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "defaults to latest", + in: "", + want: "*", + }, + { + name: "returns the configured version", + in: "1.2.3", + want: "1.2.3", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spec := HelmChartTemplateSpec{Version: tt.in} + if got := spec.GetVersion(); got != tt.want { + t.Errorf("GetVersion() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestHelmChartTemplateSpec_GetReconcileStrategy(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "defaults to ChartVersion", + in: "", + want: "ChartVersion", + }, + { + name: "returns the configured strategy", + in: "Revision", + want: "Revision", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spec := HelmChartTemplateSpec{ReconcileStrategy: tt.in} + if got := spec.GetReconcileStrategy(); got != tt.want { + t.Errorf("GetReconcileStrategy() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestHelmRelease_GetTemplateInterval(t *testing.T) { + tests := []struct { + name string + obj *HelmRelease + want metav1.Duration + }{ + { + name: "no chart template falls back to the HelmRelease interval", + obj: &HelmRelease{ + Spec: HelmReleaseSpec{ + Interval: metav1.Duration{Duration: time.Minute}, + }, + }, + want: metav1.Duration{Duration: time.Minute}, + }, + { + name: "chart template without interval falls back to the HelmRelease interval", + obj: &HelmRelease{ + Spec: HelmReleaseSpec{ + Interval: metav1.Duration{Duration: time.Minute}, + Chart: &HelmChartTemplate{Spec: HelmChartTemplateSpec{}}, + }, + }, + want: metav1.Duration{Duration: time.Minute}, + }, + { + name: "chart template interval takes precedence", + obj: &HelmRelease{ + Spec: HelmReleaseSpec{ + Interval: metav1.Duration{Duration: time.Minute}, + Chart: &HelmChartTemplate{ + Spec: HelmChartTemplateSpec{ + OCIRepositorySpec: sourcev1.OCIRepositorySpec{ + Interval: &metav1.Duration{Duration: 2 * time.Minute}, + }, + }, + }, + }, + }, + want: metav1.Duration{Duration: 2 * time.Minute}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.obj.GetTemplateInterval(); got != tt.want { + t.Errorf("GetTemplateInterval() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/api/v2/reference_types.go b/api/v2/reference_types.go index 575df30d3..6b7293eae 100644 --- a/api/v2/reference_types.go +++ b/api/v2/reference_types.go @@ -16,7 +16,13 @@ limitations under the License. package v2 -import "github.com/fluxcd/pkg/apis/meta" +import ( + "fmt" + + "k8s.io/apimachinery/pkg/types" + + "github.com/fluxcd/pkg/apis/meta" +) // CrossNamespaceObjectReference contains enough information to let you locate // the typed referenced object at cluster level. @@ -71,5 +77,42 @@ type CrossNamespaceSourceReference struct { Namespace string `json:"namespace,omitempty"` } +// HelmChartReference holds a typed and namespaced reference for a Helm chart. +type HelmChartReference struct { + // Kind of the Helm chart. + // +kubebuilder:validation:Enum=HelmChart;OCIRepository;ExternalArtifact + // +required + Kind string `json:"kind"` + + // Name of the Helm chart. + // +required + Name string `json:"name"` + + // Namespace of the Helm chart. + // +required + Namespace string `json:"namespace"` +} + +func (in *HelmChartReference) String() string { + if in == nil { + return "" + } + return fmt.Sprintf("%s/%s/%s", in.Kind, in.Namespace, in.Name) +} + +func (in *HelmChartReference) GetObjectKey() types.NamespacedName { + return types.NamespacedName{ + Namespace: in.Namespace, + Name: in.Name, + } +} + +func (in *HelmChartReference) Matches(other *HelmChartReference) bool { + if in == nil || other == nil { + return false + } + return *in == *other +} + // DependencyReference defines a HelmRelease dependency on another HelmRelease resource. type DependencyReference = meta.DependencyReference diff --git a/api/v2/zz_generated.deepcopy.go b/api/v2/zz_generated.deepcopy.go index 2858c0d85..a2ba65d29 100644 --- a/api/v2/zz_generated.deepcopy.go +++ b/api/v2/zz_generated.deepcopy.go @@ -124,6 +124,21 @@ func (in *Filter) DeepCopy() *Filter { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HelmChartReference) DeepCopyInto(out *HelmChartReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmChartReference. +func (in *HelmChartReference) DeepCopy() *HelmChartReference { + if in == nil { + return nil + } + out := new(HelmChartReference) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HelmChartTemplate) DeepCopyInto(out *HelmChartTemplate) { *out = *in @@ -177,10 +192,10 @@ func (in *HelmChartTemplateObjectMeta) DeepCopy() *HelmChartTemplateObjectMeta { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HelmChartTemplateSpec) DeepCopyInto(out *HelmChartTemplateSpec) { *out = *in - out.SourceRef = in.SourceRef - if in.Interval != nil { - in, out := &in.Interval, &out.Interval - *out = new(v1.Duration) + in.OCIRepositorySpec.DeepCopyInto(&out.OCIRepositorySpec) + if in.SourceRef != nil { + in, out := &in.SourceRef, &out.SourceRef + *out = new(CrossNamespaceObjectReference) **out = **in } if in.ValuesFiles != nil { @@ -188,11 +203,6 @@ func (in *HelmChartTemplateSpec) DeepCopyInto(out *HelmChartTemplateSpec) { *out = make([]string, len(*in)) copy(*out, *in) } - if in.Verify != nil { - in, out := &in.Verify, &out.Verify - *out = new(HelmChartTemplateVerification) - (*in).DeepCopyInto(*out) - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmChartTemplateSpec. @@ -205,26 +215,6 @@ func (in *HelmChartTemplateSpec) DeepCopy() *HelmChartTemplateSpec { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *HelmChartTemplateVerification) DeepCopyInto(out *HelmChartTemplateVerification) { - *out = *in - if in.SecretRef != nil { - in, out := &in.SecretRef, &out.SecretRef - *out = new(meta.LocalObjectReference) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HelmChartTemplateVerification. -func (in *HelmChartTemplateVerification) DeepCopy() *HelmChartTemplateVerification { - if in == nil { - return nil - } - out := new(HelmChartTemplateVerification) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HelmRelease) DeepCopyInto(out *HelmRelease) { *out = *in diff --git a/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml b/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml index 5ae44deb8..7eb7b8864 100644 --- a/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml +++ b/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml @@ -58,9 +58,26 @@ spec: properties: chart: description: |- - Chart defines the template of the v1.HelmChart that should be created - for this HelmRelease. + Chart defines the template of the source object that should be + created for this HelmRelease. The Kind field determines whether a + sourcev1.HelmChart or sourcev1.OCIRepository is created. properties: + kind: + description: |- + Kind is the kind of the source object generated from this template. Valid values: + - HelmChart (default): generates a source.toolkit.fluxcd.io/v1 HelmChart from + the HelmChartSpec-compatible fields under .spec.chart.spec. In particular, + .spec.chart.spec.chart and .spec.chart.spec.sourceRef are required. + - OCIRepository: generates a source.toolkit.fluxcd.io/v1 OCIRepository from + the OCIRepositorySpec fields under .spec.chart.spec. In particular, + .spec.chart.spec.url is required, and .spec.chart.spec.interval defaults + to .spec.interval (interval is required in a plain OCIRepository). + Use OCIRepository for charts hosted in OCI registries; use HelmChart for + HelmRepository, GitRepository or Bucket sources. + enum: + - HelmChart + - OCIRepository + type: string metadata: description: ObjectMeta holds the template for metadata like labels and annotations. @@ -84,27 +101,101 @@ spec: type: object type: object spec: - description: Spec holds the template for the v1.HelmChartSpec - for this HelmRelease. + description: Spec holds the spec of the object generated for this + HelmRelease. properties: + certSecretRef: + description: |- + CertSecretRef can be given the name of a Secret containing + either or both of + + - a PEM-encoded client certificate (`tls.crt`) and private + key (`tls.key`); + - a PEM-encoded CA certificate (`ca.crt`) + + and whichever are supplied, will be used for connecting to the + registry. The client cert and key are useful if you are + authenticating with a certificate; the CA cert is useful if + you are using a self-signed server certificate. The Secret must + be of type `Opaque` or `kubernetes.io/tls`. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object chart: description: The name or path the Helm chart is available at in the SourceRef. maxLength: 2048 minLength: 1 type: string + ignore: + description: |- + Ignore overrides the set of excluded patterns in the .sourceignore format + (which is the same as .gitignore). If not provided, a default will be used, + consult the documentation for your version to find out what those are. + type: string ignoreMissingValuesFiles: description: IgnoreMissingValuesFiles controls whether to silently ignore missing values files rather than failing. type: boolean + insecure: + description: Insecure allows connecting to a non-TLS HTTP + container registry. + type: boolean interval: description: |- - Interval at which to check the v1.Source for updates. Defaults to - 'HelmReleaseSpec.Interval'. + Interval at which the OCIRepository URL is checked for updates. + This interval is approximate and may be subject to jitter to ensure + efficient use of resources. pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m|h))+$ type: string + layerSelector: + description: |- + LayerSelector specifies which layer should be extracted from the OCI artifact. + When not specified, the first layer found in the artifact is selected. + properties: + mediaType: + description: |- + MediaType specifies the OCI media type of the layer + which should be extracted from the OCI Artifact. The + first layer matching this type is selected. + type: string + operation: + description: |- + Operation specifies how the selected layer should be processed. + By default, the layer compressed content is extracted to storage. + When the operation is set to 'copy', the layer compressed content + is persisted to storage as it is. + enum: + - extract + - copy + type: string + type: object + provider: + description: |- + The provider used for authentication, can be 'aws', 'azure', 'gcp' or 'generic'. + When not specified, defaults to 'generic'. + enum: + - generic + - aws + - azure + - gcp + type: string + proxySecretRef: + description: |- + ProxySecretRef specifies the Secret containing the proxy configuration + to use while communicating with the container registry. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object reconcileStrategy: - default: ChartVersion description: |- Determines what enables the creation of a new artifact. Valid values are ('ChartVersion', 'Revision'). @@ -114,6 +205,48 @@ spec: - ChartVersion - Revision type: string + ref: + description: |- + The OCI reference to pull and monitor for changes, + defaults to the latest tag. + properties: + digest: + description: |- + Digest is the image digest to pull, takes precedence over SemVer. + The value should be in the format 'sha256:'. + type: string + semver: + description: |- + SemVer is the range of tags to pull selecting the latest within + the range, takes precedence over Tag. + type: string + semverFilter: + description: SemverFilter is a regex pattern to filter + the tags within the SemVer range. + type: string + tag: + description: Tag is the image tag to pull, defaults to + latest. + type: string + type: object + secretRef: + description: |- + SecretRef contains the secret name containing the registry login + credentials to resolve image metadata. + The secret must be of type kubernetes.io/dockerconfigjson. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + serviceAccountName: + description: |- + ServiceAccountName is the name of the Kubernetes ServiceAccount used to authenticate + the image pull if the service account has attached pull secrets. For more information: + https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#add-imagepullsecrets-to-a-service-account + type: string sourceRef: description: The name and namespace of the v1.Source the chart is available at. @@ -142,6 +275,21 @@ spec: - kind - name type: object + suspend: + description: This flag tells the controller to suspend the + reconciliation of this source. + type: boolean + timeout: + description: The timeout for remote OCI Repository operations + like pulling, defaults to 60s. + pattern: ^([0-9]+(\.[0-9]+)?(ms|s|m))+$ + type: string + url: + description: |- + URL is a reference to an OCI artifact repository hosted + on a remote container registry. + pattern: ^oci://.*$ + type: string valuesFiles: description: |- Alternative list of values files to use as the chart values (values.yaml @@ -156,14 +304,39 @@ spec: Verify contains the secret name containing the trusted public keys used to verify the signature and specifies which provider to use to check whether OCI image is authentic. - This field is only supported for OCI sources. - Chart dependencies, which are not bundled in the umbrella chart artifact, - are not verified. properties: + matchOIDCIdentity: + description: |- + MatchOIDCIdentity specifies the identity matching criteria to use + while verifying an OCI artifact which was signed using Cosign keyless + signing. The artifact's identity is deemed to be verified if any of the + specified matchers match against the identity. + items: + description: |- + OIDCIdentityMatch specifies options for verifying the certificate identity, + i.e. the issuer and the subject of the certificate. + properties: + issuer: + description: |- + Issuer specifies the regex pattern to match against to verify + the OIDC issuer in the Fulcio certificate. The pattern must be a + valid Go regular expression. + type: string + subject: + description: |- + Subject specifies the regex pattern to match against to verify + the identity subject in the Fulcio certificate. The pattern must + be a valid Go regular expression. + type: string + required: + - issuer + - subject + type: object + type: array provider: default: cosign description: Provider specifies the technology used to - sign the OCI Helm chart. + sign the OCI Artifact. enum: - cosign - notation @@ -179,22 +352,59 @@ spec: required: - name type: object + trustedRootSecretRef: + description: |- + TrustedRootSecretRef specifies the Kubernetes Secret containing a + Sigstore trusted_root.json file. This enables verification against + self-hosted Sigstore infrastructure (custom Fulcio CA, self-hosted + Rekor instance). The Secret must contain a key named "trusted_root.json". + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object required: - provider type: object version: - default: '*' description: |- Version semver expression, ignored for charts from v1.GitRepository and - v1beta2.Bucket sources. Defaults to latest when omitted. + v1beta2.Bucket sources. Defaults to latest when omitted i.e. to '*'. type: string - required: - - chart - - sourceRef type: object required: - spec type: object + x-kubernetes-validations: + - message: chart.spec.url must be set when chart.kind is 'OCIRepository' + rule: '!has(self.kind) || self.kind != ''OCIRepository'' || (has(self.spec.url) + && size(self.spec.url) > 0)' + - message: 'chart.spec.url requires chart.kind: ''OCIRepository''; + for a HelmChart template set spec.chart and spec.sourceRef instead' + rule: '!has(self.spec.url) || (has(self.kind) && self.kind == ''OCIRepository'')' + - message: chart.spec.chart and chart.spec.sourceRef must be set when + chart.kind is not 'OCIRepository' + rule: (has(self.kind) && self.kind == 'OCIRepository') || (has(self.spec.chart) + && has(self.spec.sourceRef)) + - message: chart.spec.chart cannot be set when chart.kind is 'OCIRepository' + rule: '!has(self.kind) || self.kind != ''OCIRepository'' || !has(self.spec.chart)' + - message: chart.spec.version cannot be set when chart.kind is 'OCIRepository' + rule: '!has(self.kind) || self.kind != ''OCIRepository'' || !has(self.spec.version) + || self.spec.version == ''*''' + - message: chart.spec.sourceRef cannot be set when chart.kind is 'OCIRepository' + rule: '!has(self.kind) || self.kind != ''OCIRepository'' || !has(self.spec.sourceRef)' + - message: chart.spec.reconcileStrategy cannot be set when chart.kind + is 'OCIRepository' + rule: '!has(self.kind) || self.kind != ''OCIRepository'' || !has(self.spec.reconcileStrategy) + || self.spec.reconcileStrategy == ''ChartVersion''' + - message: chart.spec.valuesFiles cannot be set when chart.kind is + 'OCIRepository' + rule: '!has(self.kind) || self.kind != ''OCIRepository'' || !has(self.spec.valuesFiles)' + - message: chart.spec.ignoreMissingValuesFiles cannot be set when + chart.kind is 'OCIRepository' + rule: '!has(self.kind) || self.kind != ''OCIRepository'' || !has(self.spec.ignoreMissingValuesFiles)' chartRef: description: |- ChartRef holds a reference to a source controller resource containing the @@ -1240,7 +1450,9 @@ spec: helmChart: description: |- HelmChart is the namespaced name of the HelmChart resource created by - the controller for the HelmRelease. + the controller for the HelmRelease, in the format '/', + or the typed and namespaced name of the OCIRepository resource, in the + format '//'. type: string history: description: |- diff --git a/docs/api/v2/helm.md b/docs/api/v2/helm.md index eb76321b4..c6d7e924b 100644 --- a/docs/api/v2/helm.md +++ b/docs/api/v2/helm.md @@ -79,8 +79,9 @@ HelmChartTemplate (Optional) -

Chart defines the template of the v1.HelmChart that should be created -for this HelmRelease.

+

Chart defines the template of the source object that should be +created for this HelmRelease. The Kind field determines whether a +sourcev1.HelmChart or sourcev1.OCIRepository is created.

@@ -783,6 +784,56 @@ bool +

HelmChartReference +

+

HelmChartReference holds a typed and namespaced reference for a Helm chart.

+
+
+ + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+kind
+ +string + +
+

Kind of the Helm chart.

+
+name
+ +string + +
+

Name of the Helm chart.

+
+namespace
+ +string + +
+

Namespace of the Helm chart.

+
+
+

HelmChartTemplate

@@ -790,8 +841,8 @@ bool HelmReleaseSpec)

HelmChartTemplate defines the template from which the controller will -generate a v1.HelmChart object in the same namespace as the referenced -v1.Source.

+generate a sourcev1.HelmChart or sourcev1.OCIRepository object. The +Kind field determines which object is generated.

@@ -804,6 +855,27 @@ v1.Source.

+ + + + @@ -956,8 +1009,8 @@ are not verified.

(Appears on:HelmChartTemplate)

-

HelmChartTemplateObjectMeta defines the template for the ObjectMeta of a -v1.HelmChart.

+

HelmChartTemplateObjectMeta defines the template for the ObjectMeta of the +generated object.

+kind
+ +string + +
+(Optional) +

Kind is the kind of the source object generated from this template. Valid values: +- HelmChart (default): generates a source.toolkit.fluxcd.io/v1 HelmChart from +the HelmChartSpec-compatible fields under .spec.chart.spec. In particular, +.spec.chart.spec.chart and .spec.chart.spec.sourceRef are required. +- OCIRepository: generates a source.toolkit.fluxcd.io/v1 OCIRepository from +the OCIRepositorySpec fields under .spec.chart.spec. In particular, +.spec.chart.spec.url is required, and .spec.chart.spec.interval defaults +to .spec.interval (interval is required in a plain OCIRepository). +Use OCIRepository for charts hosted in OCI registries; use HelmChart for +HelmRepository, GitRepository or Bucket sources.

+
metadata
@@ -826,60 +898,60 @@ HelmChartTemplateSpec
-

Spec holds the template for the v1.HelmChartSpec for this HelmRelease.

+

Spec holds the spec of the object generated for this HelmRelease.



@@ -924,25 +996,6 @@ bool

IgnoreMissingValuesFiles controls whether to silently ignore missing values files rather than failing.

- - - -
-chart
+OCIRepositorySpec
-string +github.com/fluxcd/source-controller/api/v1.OCIRepositorySpec
-

The name or path the Helm chart is available at in the SourceRef.

+

+(Members of OCIRepositorySpec are embedded into this type.) +

-version
+chart
string
(Optional) -

Version semver expression, ignored for charts from v1.GitRepository and -v1beta2.Bucket sources. Defaults to latest when omitted.

+

The name or path the Helm chart is available at in the SourceRef.

-sourceRef
+version
- -CrossNamespaceObjectReference - +string
-

The name and namespace of the v1.Source the chart is available at.

+(Optional) +

Version semver expression, ignored for charts from v1.GitRepository and +v1beta2.Bucket sources. Defaults to latest when omitted i.e. to ‘*’.

-interval
+sourceRef
- -Kubernetes meta/v1.Duration + +CrossNamespaceObjectReference
(Optional) -

Interval at which to check the v1.Source for updates. Defaults to -‘HelmReleaseSpec.Interval’.

+

The name and namespace of the v1.Source the chart is available at.

-verify
- - -HelmChartTemplateVerification - - -
-(Optional) -

Verify contains the secret name containing the trusted public keys -used to verify the signature and specifies which provider to use to check -whether OCI image is authentic. -This field is only supported for OCI sources. -Chart dependencies, which are not bundled in the umbrella chart artifact, -are not verified.

-
@@ -1008,7 +1061,7 @@ More info: HelmChartTemplate)

HelmChartTemplateSpec defines the template from which the controller will -generate a v1.HelmChartSpec object.

+generate a sourcev1.HelmChartSpec or sourcev1.OCIRepositorySpec object.

@@ -1021,54 +1074,54 @@ generate a v1.HelmChartSpec object.

@@ -1113,72 +1166,6 @@ bool

IgnoreMissingValuesFiles controls whether to silently ignore missing values files rather than failing.

- - - - - -
-chart
+OCIRepositorySpec
-string +github.com/fluxcd/source-controller/api/v1.OCIRepositorySpec
-

The name or path the Helm chart is available at in the SourceRef.

+

+(Members of OCIRepositorySpec are embedded into this type.) +

-version
+chart
string
(Optional) -

Version semver expression, ignored for charts from v1.GitRepository and -v1beta2.Bucket sources. Defaults to latest when omitted.

+

The name or path the Helm chart is available at in the SourceRef.

-sourceRef
+version
- -CrossNamespaceObjectReference - +string
-

The name and namespace of the v1.Source the chart is available at.

+(Optional) +

Version semver expression, ignored for charts from v1.GitRepository and +v1beta2.Bucket sources. Defaults to latest when omitted i.e. to ‘*’.

-interval
+sourceRef
- -Kubernetes meta/v1.Duration + +CrossNamespaceObjectReference
(Optional) -

Interval at which to check the v1.Source for updates. Defaults to -‘HelmReleaseSpec.Interval’.

+

The name and namespace of the v1.Source the chart is available at.

-verify
- - -HelmChartTemplateVerification - - -
-(Optional) -

Verify contains the secret name containing the trusted public keys -used to verify the signature and specifies which provider to use to check -whether OCI image is authentic. -This field is only supported for OCI sources. -Chart dependencies, which are not bundled in the umbrella chart artifact, -are not verified.

-
-
-
-

HelmChartTemplateVerification -

-

-(Appears on: -HelmChartTemplateSpec) -

-

HelmChartTemplateVerification verifies the authenticity of an OCI Helm chart.

-
-
- - - - - - - - - - - - - - - -
FieldDescription
-provider
- -string - -
-

Provider specifies the technology used to sign the OCI Helm chart.

-
-secretRef
- - -github.com/fluxcd/pkg/apis/meta.LocalObjectReference - - -
-(Optional) -

SecretRef specifies the Kubernetes Secret containing the -trusted public keys.

-
@@ -1211,8 +1198,9 @@ HelmChartTemplate (Optional) -

Chart defines the template of the v1.HelmChart that should be created -for this HelmRelease.

+

Chart defines the template of the source object that should be +created for this HelmRelease. The Kind field determines whether a +sourcev1.HelmChart or sourcev1.OCIRepository is created.

@@ -1684,7 +1672,9 @@ string (Optional)

HelmChart is the namespaced name of the HelmChart resource created by -the controller for the HelmRelease.

+the controller for the HelmRelease, in the format ‘/’, +or the typed and namespaced name of the OCIRepository resource, in the +format ‘//’.

diff --git a/docs/spec/v2/helmreleases.md b/docs/spec/v2/helmreleases.md index 823c72d51..33026681b 100644 --- a/docs/spec/v2/helmreleases.md +++ b/docs/spec/v2/helmreleases.md @@ -14,15 +14,6 @@ The following is an example of a HelmRelease which installs the ```yaml --- -apiVersion: source.toolkit.fluxcd.io/v1 -kind: HelmRepository -metadata: - name: podinfo - namespace: default -spec: - interval: 15m - url: https://stefanprodan.github.io/podinfo ---- apiVersion: helm.toolkit.fluxcd.io/v2 kind: HelmRelease metadata: @@ -32,13 +23,12 @@ spec: interval: 15m timeout: 5m chart: + kind: OCIRepository spec: - chart: podinfo - version: '6.5.*' - sourceRef: - kind: HelmRepository - name: podinfo interval: 5m + url: oci://ghcr.io/stefanprodan/charts/podinfo + ref: + semver: '6.5.*' releaseName: podinfo install: remediation: @@ -60,12 +50,10 @@ spec: In the above example: -- A [HelmRepository](https://fluxcd.io/flux/components/source/helmrepositories/) - named `podinfo` is created, pointing to the Helm repository from which the - podinfo chart can be installed. -- A HelmRelease named `podinfo` is created, that will create a [HelmChart](https://fluxcd.io/flux/components/source/helmcharts/) object +- A HelmRelease named `podinfo` is created, that will create an + [OCIRepository](https://fluxcd.io/flux/components/source/ocirepositories/) object from [the `.spec.chart`](#chart-template) and watch it for Artifact changes. -- The controller will fetch the chart from the HelmChart's Artifact and use it +- The controller will fetch the chart from the OCIRepository's Artifact and use it together with the `.spec.releaseName` and `.spec.values` to confirm if the Helm release exists and is up-to-date. - If the Helm release does not exist, is not up-to-date, or has not observed to @@ -124,7 +112,7 @@ You can run this example by saving the manifest into `podinfo.yaml`. Reason: TestSucceeded Status: True Type: TestSuccess - Helm Chart: default/default-podinfo + Helm Chart: OCIRepository/default/default-podinfo History: Chart Name: podinfo Chart Version: 6.5.3 @@ -159,8 +147,7 @@ You can run this example by saving the manifest into `podinfo.yaml`. Events: Type Reason Age From Message ---- ------ ---- ---- ------- - Normal HelmChartCreated 23s helm-controller Created HelmChart/default/default-podinfo with SourceRef 'HelmRepository/default/podinfo' - Normal HelmChartInSync 22s helm-controller HelmChart/default/default-podinfo with SourceRef 'HelmRepository/default/podinfo' is in-sync + Normal OCIRepositoryCreated 23s helm-controller Created OCIRepository/default/default-podinfo Normal InstallSucceeded 18s helm-controller Helm install succeeded for release default/podinfo.v1 with chart podinfo@6.5.3 Normal TestSucceeded 10s helm-controller Helm test succeeded for release default/podinfo.v1 with chart podinfo@6.5.3: 3 test hooks completed successfully ``` @@ -177,22 +164,50 @@ A HelmRelease also needs a ### Chart template `.spec.chart` is an optional field used by the helm-controller as a template to -create a new [HelmChart resource](https://fluxcd.io/flux/components/source/helmcharts/). +create a new Source resource which provides the Helm chart artifact. The kind +of the resource is set with `.spec.chart.kind`, which defaults to `HelmChart`. +Supported kinds are [HelmChart](https://fluxcd.io/flux/components/source/helmcharts/) +and [OCIRepository](https://fluxcd.io/flux/components/source/ocirepositories/). -The spec for the HelmChart is provided via `.spec.chart.spec`, refer to +The spec of the created resource is provided via `.spec.chart.spec`. When +`.spec.chart.kind` is `HelmChart`, refer to [writing a HelmChart spec](https://fluxcd.io/flux/components/source/helmcharts/#writing-a-helmchart-spec) -for in-depth information. +for in-depth information. When `.spec.chart.kind` is `OCIRepository`, refer to +[writing an OCIRepository spec](https://fluxcd.io/flux/components/source/ocirepositories/#writing-an-ocirepository-spec) +for in-depth information; `.spec.chart.spec.url` is required in this case. + +The HelmChart-only fields are rejected by the API server when `.spec.chart.kind` +is `OCIRepository`. Conversely, `.spec.chart.spec.url` must and can only be set +when `.spec.chart.kind` is `OCIRepository`. The fields `.spec.chart.spec.chart` +and `.spec.chart.spec.sourceRef` are required when `.spec.chart.kind` is +`HelmChart`. + +When `.spec.chart.kind` is `OCIRepository` and `.spec.chart.spec.layerSelector` +is not set, the controller defaults it to selecting the Helm chart layer +(`application/vnd.cncf.helm.chart.content.v1.tar+gzip`) with the `copy` +operation. + +For `HelmChart`, the resource is created in the same namespace as the +`.sourceRef`. For `OCIRepository`, it is created in the same namespace as the +HelmRelease. In both cases the name matches the HelmRelease's +`<.metadata.namespace>-<.metadata.name>`, and the reference of the created +resource is reported in `.status.helmChart`. The reference is formatted as +`/` for `HelmChart` and as `//` for +other kinds. Annotations and labels can be added by configuring the respective `.spec.chart.metadata` fields. -The HelmChart is created in the same namespace as the `.sourceRef`, with a name -matching the HelmRelease's `<.metadata.namespace>-<.metadata.name>`, and will -be reported in `.status.helmChart`. - The chart version of the last release attempt is reported in `.status.lastAttemptedRevision`. The controller will automatically perform a -Helm release when the HelmChart produces a new chart (version). +Helm release when the source produces a new chart (version). + +A major advantage of a chart template versus a [chart reference](#chart-reference) +is the ability to atomically change `.spec.values` together with the chart version, +avoiding failures in the Helm upgrade due to incompatbility of values between the +two different chart versions. On the other hand, a disadvantage of a template versus +a reference is that the same source object cannot be reused for multiple HelmRelease +objects. **Warning:** Changing the `.spec.chart` to a Helm chart with a different name (as specified in the chart's `Chart.yaml`) will cause the controller to @@ -204,6 +219,61 @@ references with the `--no-cross-namespace-refs=true` flag. When this flag is set, the HelmRelease can only refer to Sources in the same namespace as the HelmRelease object. +#### OCIRepository template example + +```yaml +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: podinfo + namespace: default +spec: + interval: 10m + chart: + kind: OCIRepository + spec: + interval: 10m + url: oci://ghcr.io/stefanprodan/charts/podinfo + ref: + semver: ">= 6.0.0" + values: + replicaCount: 2 +``` + +#### HelmChart template example + +```yaml +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: podinfo + namespace: default +spec: + interval: 10m + url: https://stefanprodan.github.io/podinfo +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: podinfo + namespace: default +spec: + interval: 10m + chart: + kind: HelmChart + spec: + interval: 10m + chart: podinfo + sourceRef: + kind: HelmRepository + name: podinfo + version: "6.x" + valuesFiles: + - values-prod.yaml + values: + replicaCount: 2 +``` + ### Chart reference `.spec.chartRef` is an optional field used to refer to the Source object which has an @@ -223,12 +293,22 @@ 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. +A major advantage of a chart reference versus a [chart template](#chart-template) +is the ability to reuse the same source object across multiple HelmRelease objects. +On the other hand, a disadvantage of a reference versus a template is that it can +have transient failures in the Helm upgrade when trying to atomically change +`.spec.values` and the version of the chart in the source object in the same +commit. Because of potential value incompatibilies between the two different +chart versions, if helm-controller reconciles first, it will not see the new +chart version and will apply the `.spec.values` change with values that are +incompatible with the old chart version. + **Note:** Disabling the appending of the digest to the chart version can be done with the `--feature-gates=DisableChartDigestTracking=true` controller flag. **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 -an Helm upgrade and will garbage collect the old HelmChart object. +an Helm upgrade and will garbage collect the old source object. **Note:** On multi-tenant clusters, platform admins can disable cross-namespace references with the `--no-cross-namespace-refs=true` controller flag. When this flag is @@ -908,7 +988,7 @@ causes the selector to be more specific: `v2beta[\d]`. - `kind` (Optional): Matches the `.kind` of resources while offering support for regular expressions. For example, `Deployment`, `HelmRelelease` or - `(HelmRelease|HelmChart)`. + `(HelmRelease|OCIRepository)`. - `name` (Optional): Matches the `.metadata.name` of resources while offering support for regular expressions. For example, `podinfo` or `podinfo.*`. - `namespace` (Optional): Matches the `.metadata.namespace` of resources while @@ -1246,7 +1326,7 @@ duration string format](https://pkg.go.dev/time#ParseDuration), e.g. `15m0s` to reconcile the object every fifteen minutes. If the `.metadata.generation` of a resource changes (due to e.g. a change to -the spec) or the HelmChart revision changes (which generates a Kubernetes +the spec) or the OCIRepository revision changes (which generates a Kubernetes Event), or a ConfigMap/Secret referenced in `valuesFrom` changes, this is handled instantly outside the interval window. @@ -1408,13 +1488,12 @@ metadata: spec: interval: 15m chart: + kind: OCIRepository spec: - chart: my-operator - version: "1.0.1" - sourceRef: - kind: HelmRepository - name: my-operator-repo interval: 5m + url: oci://ghcr.io/example/my-operator + ref: + tag: "1.0.1" install: crds: CreateReplace upgrade: @@ -1496,11 +1575,9 @@ spec: serviceAccountName: webapp-reconciler interval: 15m chart: + kind: OCIRepository spec: - chart: podinfo - sourceRef: - kind: HelmRepository - name: podinfo + url: oci://ghcr.io/stefanprodan/charts/podinfo ``` When the controller reconciles the `podinfo` HelmRelease, it will impersonate @@ -1571,12 +1648,11 @@ spec: secretRef: name: stage-kubeconfig # Cluster API creates this for the matching Cluster chart: + kind: OCIRepository spec: - chart: prometheus - version: ">=4.0.0 <5.0.0" - sourceRef: - kind: HelmRepository - name: prometheus-community + url: oci://ghcr.io/prometheus-community/charts/prometheus + ref: + semver: ">=4.0.0 <5.0.0" install: remediation: retries: -1 @@ -1865,8 +1941,7 @@ Status: Events: Type Reason Age From Message ---- ------ ---- ---- ------- - Normal HelmChartCreated 88s helm-controller Created HelmChart/podinfo/podinfo-podinfo with SourceRef 'HelmRepository/podinfo/podinfo' - Normal HelmChartInSync 88s helm-controller HelmChart/podinfo/podinfo-podinfo with SourceRef 'HelmRepository/podinfo/podinfo' is in-sync + Normal OCIRepositoryCreated 88s helm-controller Created OCIRepository/podinfo/podinfo-podinfo Normal InstallSucceeded 83s helm-controller Helm install succeeded for release podinfo/podinfo.v1 with chart podinfo@6.5.3 Warning TestFailed 78s helm-controller Helm test failed for release podinfo/podinfo.v1 with chart podinfo@6.5.3: 1 error occurred: * pod podinfo-fault-test-a0tew failed @@ -1886,8 +1961,7 @@ lists ```shell LAST SEEN TYPE REASON OBJECT MESSAGE -88s Normal HelmChartCreated HelmRelease/podinfo Created HelmChart/podinfo/podinfo-podinfo with SourceRef 'HelmRepository/podinfo/podinfo' -88s Normal HelmChartInSync HelmRelease/podinfo HelmChart/podinfo/podinfo-podinfo with SourceRef 'HelmRepository/podinfo/podinfo' is in-sync +88s Normal OCIRepositoryCreated HelmRelease/podinfo Created OCIRepository/podinfo/podinfo-podinfo 83s Normal InstallSucceeded HelmRelease/podinfo Helm install succeeded for release podinfo/podinfo.v1 with chart podinfo@6.5.3 78s Warning TestFailed HelmRelease/podinfo Helm test failed for release podinfo/podinfo.v1 with chart podinfo@6.5.3: 1 error occurred: * pod podinfo-fault-test-a0tew failed @@ -2080,7 +2154,7 @@ better (timeout) support to solutions polling the HelmRelease to become `Ready`. #### Reconciling HelmRelease -The helm-controller marks the HelmRepository as _reconciling_ when it is working +The helm-controller marks the HelmRelease as _reconciling_ when it is working on re-assessing the Helm release state, or working on a Helm action such as installing or upgrading the release. @@ -2185,7 +2259,7 @@ attributes in the HelmRelease's `.status.conditions`: The helm-controller may get stuck trying to determine state or produce a Helm release without completing. This can occur due to some of the following factors: -- The HelmChart does not have an Artifact, or is not ready. +- The OCIRepository does not have an Artifact, or is not ready. - The HelmRelease's dependencies are not ready. - The composition of [values references](#values-references) and [inline values](#inline-values) failed due to a misconfiguration. diff --git a/go.mod b/go.mod index 99216f2c1..8658eb110 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( github.com/fluxcd/pkg/runtime v0.112.0 github.com/fluxcd/pkg/ssa v0.78.0 github.com/fluxcd/pkg/testserver v0.14.0 - github.com/fluxcd/source-controller/api v1.9.5 + github.com/fluxcd/source-controller/api v1.9.6 github.com/go-logr/logr v1.4.4 github.com/google/cel-go v0.29.2 github.com/google/go-cmp v0.7.0 @@ -224,3 +224,5 @@ require ( sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect ) + +replace github.com/fluxcd/source-controller/api => github.com/fluxcd/source-controller/api v1.9.1-0.20261005091731-e47a59ad5d01 diff --git a/go.sum b/go.sum index e1af14812..8fa5ec822 100644 --- a/go.sum +++ b/go.sum @@ -168,8 +168,8 @@ github.com/fluxcd/pkg/ssa v0.78.0 h1:Wzm9uoYLDUCRV2SlJuBLkOQu/OnPKuTWyTzIFZbpVfY github.com/fluxcd/pkg/ssa v0.78.0/go.mod h1:yuW/1rJj6w89NpRgj2OJAP7tfqaXD6JmKm+WVnPrHKk= github.com/fluxcd/pkg/testserver v0.14.0 h1:wVv/JPY3i4OEJ8xakfriuN2oHiUyZZ+BfhC/6RCD2nI= github.com/fluxcd/pkg/testserver v0.14.0/go.mod h1:6D6/SeGl8jT8L8pb5+k+mkE5CtqV1ozC5uqXRuTEBas= -github.com/fluxcd/source-controller/api v1.9.5 h1:QwOqmw6/NqOXUR+kGmBJ18CEvthD8DNrSRTtjQG+bHQ= -github.com/fluxcd/source-controller/api v1.9.5/go.mod h1:Y5mcHYzML/mJYjvSJRcIo7eLLjd+cZjnKR6WeIGrouE= +github.com/fluxcd/source-controller/api v1.9.1-0.20261005091731-e47a59ad5d01 h1:QKNUNGjIV94G0JvE++lXtSzoEzyRlsEHMAuvo6I/VaM= +github.com/fluxcd/source-controller/api v1.9.1-0.20261005091731-e47a59ad5d01/go.mod h1:EOuCFZQd2k3bBI+lte0x771y/QE9fR6JeGWxmxTuNNU= github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= diff --git a/internal/acl/acl.go b/internal/acl/acl.go index b27b263ec..c31d3dfa8 100644 --- a/internal/acl/acl.go +++ b/internal/acl/acl.go @@ -19,9 +19,10 @@ package acl import ( "fmt" - "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" + v2 "github.com/fluxcd/helm-controller/api/v2" + "github.com/fluxcd/pkg/runtime/acl" ) @@ -33,11 +34,10 @@ var ( // AllowsAccessTo returns an error if the object does not allow access to the // given reference. -func AllowsAccessTo(obj client.Object, kind string, ref types.NamespacedName) error { +func AllowsAccessTo(obj client.Object, ref *v2.HelmChartReference) error { if !AllowCrossNamespaceRef && obj.GetNamespace() != ref.Namespace { - return acl.AccessDeniedError(fmt.Sprintf("cross-namespace references are not allowed: cannot access %s %s", - kind, ref.String(), - )) + msg := fmt.Sprintf("cross-namespace references are not allowed: cannot access %s", ref) + return acl.AccessDeniedError(msg) } return nil } diff --git a/internal/acl/acl_test.go b/internal/acl/acl_test.go index 3a0ab7c33..066e31956 100644 --- a/internal/acl/acl_test.go +++ b/internal/acl/acl_test.go @@ -20,7 +20,6 @@ import ( "testing" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" v2 "github.com/fluxcd/helm-controller/api/v2" @@ -31,7 +30,7 @@ func TestAllowsAccessTo(t *testing.T) { name string allow bool obj client.Object - ref types.NamespacedName + ref v2.HelmChartReference wantErr bool }{ { @@ -43,7 +42,8 @@ func TestAllowsAccessTo(t *testing.T) { Namespace: "some-namespace", }, }, - ref: types.NamespacedName{ + ref: v2.HelmChartReference{ + Kind: "mock", Name: "some-name", Namespace: "some-other-namespace", }, @@ -58,7 +58,8 @@ func TestAllowsAccessTo(t *testing.T) { Namespace: "some-namespace", }, }, - ref: types.NamespacedName{ + ref: v2.HelmChartReference{ + Kind: "mock", Name: "some-name", Namespace: "some-other-namespace", }, @@ -73,7 +74,8 @@ func TestAllowsAccessTo(t *testing.T) { Namespace: "some-namespace", }, }, - ref: types.NamespacedName{ + ref: v2.HelmChartReference{ + Kind: "mock", Name: "some-name", Namespace: "some-namespace", }, @@ -87,7 +89,7 @@ func TestAllowsAccessTo(t *testing.T) { AllowCrossNamespaceRef = tt.allow t.Cleanup(func() { AllowCrossNamespaceRef = curAllow }) - if err := AllowsAccessTo(tt.obj, "mock", tt.ref); (err != nil) != tt.wantErr { + if err := AllowsAccessTo(tt.obj, &tt.ref); (err != nil) != tt.wantErr { t.Errorf("AllowsAccessTo() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/controller/helmrelease_controller.go b/internal/controller/helmrelease_controller.go index e4d201f15..7c1d0f18c 100644 --- a/internal/controller/helmrelease_controller.go +++ b/internal/controller/helmrelease_controller.go @@ -759,12 +759,12 @@ func (r *HelmReleaseReconciler) getSourceClient() client.Reader { return r.Client } -// getSource returns the source object containing the HelmChart, either by -// using the chartRef in the spec, or by looking up the HelmChart -// referenced in the status object. +// getSource returns the source object containing the Helm chart, either by +// using the chartRef in the spec, or by looking up the reference in the +// status object. // It returns the source object or an error. func (r *HelmReleaseReconciler) getSource(ctx context.Context, obj *v2.HelmRelease) (sourcev1.Source, error) { - var name, namespace string + var ref *v2.HelmChartReference if obj.HasChartRef() { if obj.Spec.ChartRef.Kind == sourcev1.OCIRepositoryKind { return r.getSourceFromOCIRef(ctx, obj) @@ -772,65 +772,63 @@ func (r *HelmReleaseReconciler) getSource(ctx context.Context, obj *v2.HelmRelea if obj.Spec.ChartRef.Kind == sourcev1.ExternalArtifactKind { return r.getSourceFromExternalArtifact(ctx, obj) } - name, namespace = obj.Spec.ChartRef.Name, obj.Spec.ChartRef.Namespace - if namespace == "" { - namespace = obj.GetNamespace() - } + ref = obj.GetHelmChartReference() } else { - namespace, name = obj.Status.GetHelmChart() + ref = obj.Status.GetHelmChartReference() + } + if ref == nil { + return nil, fmt.Errorf("no Helm chart reference found") } - chartRef := types.NamespacedName{Namespace: namespace, Name: name} - - if err := intacl.AllowsAccessTo(obj, sourcev1.HelmChartKind, chartRef); err != nil { + if err := intacl.AllowsAccessTo(obj, ref); err != nil { return nil, err } + if ref.Kind == sourcev1.OCIRepositoryKind { + or := sourcev1.OCIRepository{} + if err := r.getSourceClient().Get(ctx, ref.GetObjectKey(), &or); err != nil { + return nil, err + } + return &or, nil + } + hc := sourcev1.HelmChart{} - if err := r.getSourceClient().Get(ctx, chartRef, &hc); err != nil { + if err := r.getSourceClient().Get(ctx, ref.GetObjectKey(), &hc); err != nil { return nil, err } return &hc, nil } func (r *HelmReleaseReconciler) getSourceFromOCIRef(ctx context.Context, obj *v2.HelmRelease) (sourcev1.Source, error) { - name, namespace := obj.Spec.ChartRef.Name, obj.Spec.ChartRef.Namespace - if namespace == "" { - namespace = obj.GetNamespace() - } - ociRepoRef := types.NamespacedName{Namespace: namespace, Name: name} + ref := obj.GetHelmChartReference() - if err := intacl.AllowsAccessTo(obj, sourcev1.OCIRepositoryKind, ociRepoRef); err != nil { + if err := intacl.AllowsAccessTo(obj, ref); err != nil { return nil, err } or := sourcev1.OCIRepository{} - if err := r.getSourceClient().Get(ctx, ociRepoRef, &or); err != nil { + if err := r.getSourceClient().Get(ctx, ref.GetObjectKey(), &or); err != nil { return nil, err } return &or, nil } func (r *HelmReleaseReconciler) getSourceFromExternalArtifact(ctx context.Context, obj *v2.HelmRelease) (sourcev1.Source, error) { - name, namespace := obj.Spec.ChartRef.Name, obj.Spec.ChartRef.Namespace - if namespace == "" { - namespace = obj.GetNamespace() - } - sourceRef := types.NamespacedName{Namespace: namespace, Name: name} + ref := obj.GetHelmChartReference() - if err := intacl.AllowsAccessTo(obj, sourcev1.ExternalArtifactKind, sourceRef); err != nil { + if err := intacl.AllowsAccessTo(obj, ref); err != nil { return nil, err } // Check if ExternalArtifact kind is allowed. if obj.Spec.ChartRef.Kind == sourcev1.ExternalArtifactKind && !r.AllowExternalArtifact { - return nil, acl.AccessDeniedError( - fmt.Sprintf("can't access '%s/%s/%s', %s feature gate is disabled", - obj.Spec.ChartRef.Kind, namespace, name, helper.FeatureGateExternalArtifact)) + msg := fmt.Sprintf("can't access '%s', %s feature gate is disabled", + ref, helper.FeatureGateExternalArtifact) + return nil, acl.AccessDeniedError(msg) } or := sourcev1.ExternalArtifact{} - if err := r.getSourceClient().Get(ctx, sourceRef, &or); err != nil { + if err := r.getSourceClient().Get(ctx, ref.GetObjectKey(), &or); err != nil { return nil, err } return &or, nil diff --git a/internal/controller/helmrelease_controller_test.go b/internal/controller/helmrelease_controller_test.go index 090092c82..94dc51815 100644 --- a/internal/controller/helmrelease_controller_test.go +++ b/internal/controller/helmrelease_controller_test.go @@ -1068,7 +1068,7 @@ func TestHelmReleaseReconciler_reconcileReleaseFromHelmChartSource(t *testing.T) Chart: &v2.HelmChartTemplate{ Spec: v2.HelmChartTemplateSpec{ Chart: "mychart", - SourceRef: v2.CrossNamespaceObjectReference{ + SourceRef: &v2.CrossNamespaceObjectReference{ Name: "something", }, }, @@ -1536,7 +1536,7 @@ func TestHelmReleaseReconciler_reconcileReleaseFromOCIRepositorySource(t *testin Chart: &v2.HelmChartTemplate{ Spec: v2.HelmChartTemplateSpec{ Chart: "mychart", - SourceRef: v2.CrossNamespaceObjectReference{ + SourceRef: &v2.CrossNamespaceObjectReference{ Name: "something", }, }, @@ -1702,7 +1702,7 @@ func TestHelmReleaseReconciler_reconcileReleaseFromOCIRepositorySource(t *testin Generation: 2, }, Spec: sourcev1.OCIRepositorySpec{ - Interval: metav1.Duration{Duration: 1 * time.Second}, + Interval: &metav1.Duration{Duration: 1 * time.Second}, }, Status: sourcev1.OCIRepositoryStatus{ ObservedGeneration: 2, @@ -1764,7 +1764,7 @@ func TestHelmReleaseReconciler_reconcileReleaseFromOCIRepositorySource(t *testin Generation: 2, }, Spec: sourcev1.OCIRepositorySpec{ - Interval: metav1.Duration{Duration: 1 * time.Second}, + Interval: &metav1.Duration{Duration: 1 * time.Second}, }, Status: sourcev1.OCIRepositoryStatus{ ObservedGeneration: 2, @@ -1841,7 +1841,7 @@ func TestHelmReleaseReconciler_reconcileReleaseFromOCIRepositorySource(t *testin Generation: 2, }, Spec: sourcev1.OCIRepositorySpec{ - Interval: metav1.Duration{Duration: 1 * time.Second}, + Interval: &metav1.Duration{Duration: 1 * time.Second}, }, Status: sourcev1.OCIRepositoryStatus{ ObservedGeneration: 2, @@ -1910,7 +1910,7 @@ func TestHelmReleaseReconciler_reconcileReleaseFromOCIRepositorySource(t *testin Generation: 1, }, Spec: sourcev1.OCIRepositorySpec{ - Interval: metav1.Duration{Duration: 1 * time.Second}, + Interval: &metav1.Duration{Duration: 1 * time.Second}, }, Status: sourcev1.OCIRepositoryStatus{ ObservedGeneration: 1, @@ -2003,7 +2003,7 @@ func TestHelmReleaseReconciler_reconcileReleaseFromOCIRepositorySource(t *testin }, Spec: sourcev1.OCIRepositorySpec{ URL: "oci://test-example.com", - Interval: metav1.Duration{Duration: 1 * time.Second}, + Interval: &metav1.Duration{Duration: 1 * time.Second}, }, Status: sourcev1.OCIRepositoryStatus{ ObservedGeneration: 1, @@ -2100,7 +2100,7 @@ func TestHelmReleaseReconciler_reconcileReleaseFromOCIRepositorySource(t *testin }, Spec: sourcev1.OCIRepositorySpec{ URL: "oci://test-example.com", - Interval: metav1.Duration{Duration: 1 * time.Second}, + Interval: &metav1.Duration{Duration: 1 * time.Second}, }, Status: sourcev1.OCIRepositoryStatus{ ObservedGeneration: 1, @@ -2177,7 +2177,7 @@ func TestHelmReleaseReconciler_reconcileReleaseFromOCIRepositorySource(t *testin }, Spec: sourcev1.OCIRepositorySpec{ URL: "oci://test-example.com", - Interval: metav1.Duration{Duration: 1 * time.Second}, + Interval: &metav1.Duration{Duration: 1 * time.Second}, }, Status: sourcev1.OCIRepositoryStatus{ ObservedGeneration: 1, @@ -2280,7 +2280,7 @@ func TestHelmReleaseReconciler_reconcileReleaseFromOCIRepositorySource(t *testin }, Spec: sourcev1.OCIRepositorySpec{ URL: "oci://test-example.com", - Interval: metav1.Duration{Duration: 1 * time.Second}, + Interval: &metav1.Duration{Duration: 1 * time.Second}, }, Status: sourcev1.OCIRepositoryStatus{ ObservedGeneration: 1, @@ -2894,6 +2894,36 @@ func TestHelmReleaseReconciler_reconcileChartTemplate(t *testing.T) { g.Expect(err).To(HaveOccurred()) g.Expect(err.Error()).To(ContainSubstring("failed to run server-side apply")) }) + + t.Run("attempts to reconcile OCIRepository chart template", func(t *testing.T) { + g := NewWithT(t) + + r := &HelmReleaseReconciler{ + Client: fake.NewClientBuilder().WithScheme(NewTestScheme()).Build(), + EventRecorder: record.NewFakeRecorder(32), + } + + obj := &v2.HelmRelease{ + Spec: v2.HelmReleaseSpec{ + Chart: &v2.HelmChartTemplate{ + Kind: sourcev1.OCIRepositoryKind, + Spec: v2.HelmChartTemplateSpec{ + OCIRepositorySpec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + }, + }, + }, + }, + Status: v2.HelmReleaseStatus{ + StorageNamespace: "default", + }, + } + + // We do not care about the result of the reconcile, only that it was attempted. + err := r.reconcileChartTemplate(context.TODO(), obj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("failed to run server-side apply")) + }) } func TestHelmReleaseReconciler_reconcileUninstall(t *testing.T) { @@ -3639,6 +3669,97 @@ func TestHelmReleaseReconciler_getHelmChart(t *testing.T) { } } +func TestHelmReleaseReconciler_getSource_OCIRepository(t *testing.T) { + g := NewWithT(t) + + repo := &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "some-namespace", + Name: "some-repo-name", + }, + } + + tests := []struct { + name string + rel *v2.HelmRelease + repo *sourcev1.OCIRepository + expectRepo bool + wantErr bool + disallowCrossNS bool + }{ + { + name: "retrieves OCIRepository object from Status", + rel: &v2.HelmRelease{ + Status: v2.HelmReleaseStatus{ + HelmChart: fmt.Sprintf("%s/some-namespace/some-repo-name", sourcev1.OCIRepositoryKind), + }, + }, + repo: repo, + expectRepo: true, + }, + { + name: "no OCIRepository found", + rel: &v2.HelmRelease{ + Status: v2.HelmReleaseStatus{ + HelmChart: fmt.Sprintf("%s/some-namespace/some-repo-name", sourcev1.OCIRepositoryKind), + }, + }, + repo: nil, + expectRepo: false, + wantErr: true, + }, + { + name: "ACL disallows cross namespace", + rel: &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + }, + Status: v2.HelmReleaseStatus{ + HelmChart: fmt.Sprintf("%s/some-namespace/some-repo-name", sourcev1.OCIRepositoryKind), + }, + }, + repo: repo, + expectRepo: false, + wantErr: true, + disallowCrossNS: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := fake.NewClientBuilder() + c.WithScheme(NewTestScheme()) + if tt.repo != nil { + c.WithObjects(tt.repo) + } + + r := &HelmReleaseReconciler{ + Client: c.Build(), + EventRecorder: record.NewFakeRecorder(32), + } + + curAllow := intacl.AllowCrossNamespaceRef + intacl.AllowCrossNamespaceRef = !tt.disallowCrossNS + t.Cleanup(func() { intacl.AllowCrossNamespaceRef = !curAllow }) + + got, err := r.getSource(context.TODO(), tt.rel) + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + g.Expect(got).To(BeNil()) + return + } + g.Expect(err).ToNot(HaveOccurred()) + or, ok := got.(*sourcev1.OCIRepository) + g.Expect(ok).To(BeTrue()) + expect := g.Expect(or.ObjectMeta) + if tt.expectRepo { + expect.To(BeEquivalentTo(tt.repo.ObjectMeta)) + } else { + expect.To(BeNil()) + } + }) + } +} + func TestHelmReleaseReconciler_getSourceClient(t *testing.T) { g := NewWithT(t) @@ -4021,7 +4142,7 @@ func TestValuesReferenceValidation(t *testing.T) { Chart: &v2.HelmChartTemplate{ Spec: v2.HelmChartTemplateSpec{ Chart: "mychart", - SourceRef: v2.CrossNamespaceObjectReference{ + SourceRef: &v2.CrossNamespaceObjectReference{ Name: "something", Kind: "HelmRepository", }, diff --git a/internal/controller/helmrelease_manager.go b/internal/controller/helmrelease_manager.go index aa1f6d537..809a4c43d 100644 --- a/internal/controller/helmrelease_manager.go +++ b/internal/controller/helmrelease_manager.go @@ -56,23 +56,16 @@ func (r *HelmReleaseReconciler) SetupWithManager(ctx context.Context, mgr ctrl.M if err := mgr.GetFieldIndexer().IndexField(ctx, &v2.HelmRelease{}, v2.SourceIndexKey, func(o client.Object) []string { obj := o.(*v2.HelmRelease) - var kind, name, namespace string + var ref *v2.HelmChartReference switch { case obj.HasChartRef() && !obj.HasChartTemplate(): - kind = obj.Spec.ChartRef.Kind - name = obj.Spec.ChartRef.Name - namespace = obj.Spec.ChartRef.Namespace - if namespace == "" { - namespace = obj.GetNamespace() - } + ref = obj.GetHelmChartReference() case !obj.HasChartRef() && obj.HasChartTemplate(): - kind = sourcev1.HelmChartKind - name = obj.GetHelmChartName() - namespace = obj.Spec.Chart.GetNamespace(obj.GetNamespace()) + ref = obj.GetHelmChartTemplateReference() default: return nil } - return []string{fmt.Sprintf("%s/%s/%s", kind, namespace, name)} + return []string{ref.String()} }, ); err != nil { return err diff --git a/internal/controller/helmrelease_validation_test.go b/internal/controller/helmrelease_validation_test.go new file mode 100644 index 000000000..a653f9b05 --- /dev/null +++ b/internal/controller/helmrelease_validation_test.go @@ -0,0 +1,246 @@ +/* +Copyright 2026 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +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 controller + +import ( + "context" + "fmt" + "testing" + "time" + + . "github.com/onsi/gomega" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + sourcev1 "github.com/fluxcd/source-controller/api/v1" + + v2 "github.com/fluxcd/helm-controller/api/v2" +) + +// TestHelmReleaseChartTemplateValidation verifies the CEL expressions on +// HelmRelease.spec.chart, which enforce the union between the HelmChart and +// OCIRepository chart templates. +func TestHelmReleaseChartTemplateValidation(t *testing.T) { + g := NewWithT(t) + + ns, err := testEnv.CreateNamespace(context.TODO(), "chart-template-validation") + g.Expect(err).ToNot(HaveOccurred()) + t.Cleanup(func() { + _ = testEnv.Delete(context.TODO(), ns) + }) + + helmSourceRef := &v2.CrossNamespaceObjectReference{ + Kind: sourcev1.HelmRepositoryKind, + Name: "podinfo", + } + + tests := []struct { + name string + chart v2.HelmChartTemplate + wantErr string + }{ + { + name: "HelmChart template with chart and sourceRef", + chart: v2.HelmChartTemplate{ + Spec: v2.HelmChartTemplateSpec{ + Chart: "podinfo", + SourceRef: helmSourceRef, + }, + }, + }, + { + name: "HelmChart kind explicitly set", + chart: v2.HelmChartTemplate{ + Kind: sourcev1.HelmChartKind, + Spec: v2.HelmChartTemplateSpec{ + Chart: "podinfo", + SourceRef: helmSourceRef, + }, + }, + }, + { + name: "HelmChart template without chart", + chart: v2.HelmChartTemplate{ + Spec: v2.HelmChartTemplateSpec{ + SourceRef: helmSourceRef, + }, + }, + wantErr: "chart.spec.chart and chart.spec.sourceRef must be set when chart.kind is not 'OCIRepository'", + }, + { + name: "HelmChart template without sourceRef", + chart: v2.HelmChartTemplate{ + Spec: v2.HelmChartTemplateSpec{ + Chart: "podinfo", + }, + }, + wantErr: "chart.spec.chart and chart.spec.sourceRef must be set when chart.kind is not 'OCIRepository'", + }, + { + name: "HelmChart template with url", + chart: v2.HelmChartTemplate{ + Kind: sourcev1.HelmChartKind, + Spec: v2.HelmChartTemplateSpec{ + OCIRepositorySpec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + }, + Chart: "podinfo", + SourceRef: helmSourceRef, + }, + }, + wantErr: "chart.spec.url requires chart.kind: 'OCIRepository'", + }, + { + name: "HelmChart template with url and default kind", + chart: v2.HelmChartTemplate{ + Spec: v2.HelmChartTemplateSpec{ + OCIRepositorySpec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + }, + Chart: "podinfo", + SourceRef: helmSourceRef, + }, + }, + wantErr: "chart.spec.url requires chart.kind: 'OCIRepository'", + }, + { + name: "OCIRepository template with url and ref", + chart: v2.HelmChartTemplate{ + Kind: sourcev1.OCIRepositoryKind, + Spec: v2.HelmChartTemplateSpec{ + OCIRepositorySpec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + Reference: &sourcev1.OCIRepositoryRef{Tag: "6.6.0"}, + }, + }, + }, + }, + { + name: "OCIRepository template without url", + chart: v2.HelmChartTemplate{ + Kind: sourcev1.OCIRepositoryKind, + Spec: v2.HelmChartTemplateSpec{}, + }, + wantErr: "chart.spec.url must be set when chart.kind is 'OCIRepository'", + }, + { + name: "OCIRepository template with chart", + chart: v2.HelmChartTemplate{ + Kind: sourcev1.OCIRepositoryKind, + Spec: v2.HelmChartTemplateSpec{ + OCIRepositorySpec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + }, + Chart: "podinfo", + }, + }, + wantErr: "chart.spec.chart cannot be set when chart.kind is 'OCIRepository'", + }, + { + name: "OCIRepository template with sourceRef", + chart: v2.HelmChartTemplate{ + Kind: sourcev1.OCIRepositoryKind, + Spec: v2.HelmChartTemplateSpec{ + OCIRepositorySpec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + }, + SourceRef: helmSourceRef, + }, + }, + wantErr: "chart.spec.sourceRef cannot be set when chart.kind is 'OCIRepository'", + }, + { + name: "OCIRepository template with version", + chart: v2.HelmChartTemplate{ + Kind: sourcev1.OCIRepositoryKind, + Spec: v2.HelmChartTemplateSpec{ + OCIRepositorySpec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + }, + Version: "1.2.3", + }, + }, + wantErr: "chart.spec.version cannot be set when chart.kind is 'OCIRepository'", + }, + { + name: "OCIRepository template with reconcileStrategy", + chart: v2.HelmChartTemplate{ + Kind: sourcev1.OCIRepositoryKind, + Spec: v2.HelmChartTemplateSpec{ + OCIRepositorySpec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + }, + ReconcileStrategy: sourcev1.ReconcileStrategyRevision, + }, + }, + wantErr: "chart.spec.reconcileStrategy cannot be set when chart.kind is 'OCIRepository'", + }, + { + name: "OCIRepository template with valuesFiles", + chart: v2.HelmChartTemplate{ + Kind: sourcev1.OCIRepositoryKind, + Spec: v2.HelmChartTemplateSpec{ + OCIRepositorySpec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + }, + ValuesFiles: []string{"values.yaml"}, + }, + }, + wantErr: "chart.spec.valuesFiles cannot be set when chart.kind is 'OCIRepository'", + }, + { + name: "OCIRepository template with ignoreMissingValuesFiles", + chart: v2.HelmChartTemplate{ + Kind: sourcev1.OCIRepositoryKind, + Spec: v2.HelmChartTemplateSpec{ + OCIRepositorySpec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + }, + IgnoreMissingValuesFiles: true, + }, + }, + wantErr: "chart.spec.ignoreMissingValuesFiles cannot be set when chart.kind is 'OCIRepository'", + }, + } + + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + chart := tt.chart + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("chart-template-%d", i), + Namespace: ns.Name, + }, + Spec: v2.HelmReleaseSpec{ + Interval: metav1.Duration{Duration: time.Minute}, + Chart: &chart, + }, + } + + err := testEnv.Create(context.TODO(), obj) + if tt.wantErr == "" { + g.Expect(err).ToNot(HaveOccurred()) + return + } + g.Expect(err).To(HaveOccurred()) + g.Expect(apierrors.IsInvalid(err)).To(BeTrue()) + g.Expect(err.Error()).To(ContainSubstring(tt.wantErr)) + }) + } +} diff --git a/internal/reconcile/helmchart_template.go b/internal/reconcile/helmchart_template.go index 31700205c..fc171e4e8 100644 --- a/internal/reconcile/helmchart_template.go +++ b/internal/reconcile/helmchart_template.go @@ -20,11 +20,11 @@ import ( "context" "fmt" + "helm.sh/helm/v4/pkg/registry" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -39,27 +39,26 @@ import ( "github.com/fluxcd/helm-controller/internal/strings" ) -// HelmChartTemplate attempts to create, update or delete a v1.HelmChart -// based on the given Request data. +// HelmChartTemplate attempts to create, update or delete a v1.HelmChart or +// v1.OCIRepository based on the given Request data. // -// It does this by building a v1.HelmChart from the template declared in -// the v2.HelmRelease, and then reconciling that v1.HelmChart using -// a server-side apply. +// It does this by building the chart from the template declared in the +// v2.HelmRelease, and then reconciling it using a server-side apply. // -// When the server-side apply succeeds, the namespaced name of the chart is -// written to the Status.HelmChart field of the v2.HelmRelease. If the -// server-side apply fails, the error is returned to the caller and indicates -// they should retry. +// When the server-side apply succeeds, the typed and namespaced name of the +// chart is written to the Status.HelmChart field of the v2.HelmRelease. If +// the server-side apply fails, the error is returned to the caller and +// indicates they should retry. // // When at the beginning of the reconciliation the deletion timestamp is set -// on the v2.HelmRelease, or the Status.HelmChart differs from the -// namespaced name of the chart to be applied, the existing chart is deleted. -// The deletion is observed, and when it completes, the Status.HelmChart is -// cleared. If the deletion fails, the error is returned to the caller and -// indicates they should retry. +// on the v2.HelmRelease, or the Status.HelmChart differs from the reference +// of the chart to be applied, the existing chart is deleted. The deletion is +// observed, and when it completes, the Status.HelmChart is cleared. If the +// deletion fails, the error is returned to the caller and indicates they +// should retry. // // In case the v2.HelmRelease is marked for deletion, the reconciler will -// not continue to attempt to create or update the v1.HelmChart. +// not continue to attempt to create or update the chart. type HelmChartTemplate struct { client client.Client eventRecorder record.EventRecorder @@ -77,59 +76,67 @@ func NewHelmChartTemplate(client client.Client, recorder record.EventRecorder, f } func (r *HelmChartTemplate) Reconcile(ctx context.Context, req *Request) error { - var ( - obj = req.Object - chartRef = types.NamespacedName{} - ) - - if obj.Spec.Chart != nil { - chartRef.Name = obj.GetHelmChartName() - chartRef.Namespace = obj.Spec.Chart.GetNamespace(obj.Namespace) - } + obj := req.Object + ref := obj.GetHelmChartTemplateReference() - // The HelmChart name and/or namespace diverges or the HelmRelease is - // being deleted, delete the HelmChart. - if (obj.Status.HelmChart != "" && obj.Status.HelmChart != chartRef.String()) || !obj.DeletionTimestamp.IsZero() { + // The chart reference diverges or the HelmRelease is being deleted, + // delete the chart. + if (obj.Status.HasChart() && !obj.Status.GetHelmChartReference().Matches(ref)) || !obj.DeletionTimestamp.IsZero() { // If the HelmRelease is being deleted, we need to short-circuit to - // avoid recreating the HelmChart. - if err := r.reconcileDelete(ctx, req.Object); err != nil || !obj.DeletionTimestamp.IsZero() { + // avoid recreating the chart. + if err := r.reconcileDelete(ctx, obj); err != nil || !obj.DeletionTimestamp.IsZero() { return err } } if mustCleanDeployedChart(obj) { - // If the HelmRelease has a ChartRef and no Chart template, and the - // HelmChart is present, we need to clean it up. - if err := r.reconcileDelete(ctx, req.Object); err != nil { + // If the HelmRelease has a ChartRef and no Chart template, but a + // chart is present in the status, we need to clean it up. + if err := r.reconcileDelete(ctx, obj); err != nil { return err } return nil } if obj.HasChartRef() { - // if a chartRef is present, we do not need to reconcile the HelmChart from the template. + // if a chartRef is present, we do not need to reconcile the chart from the template. return nil } - // Confirm we are allowed to fetch the HelmChart. - if err := acl.AllowsAccessTo(req.Object, sourcev1.HelmChartKind, chartRef); err != nil { + // Confirm we are allowed to fetch the chart. + if err := acl.AllowsAccessTo(obj, ref); err != nil { return err } - // Build new HelmChart based on the declared template. - newChart := buildHelmChartFromTemplate(req.Object) + // Build a new chart based on the declared template. + var newChart client.Object + var newChartDeepCopy any + var newChartWithSourceRef string + switch ref.Kind { + case sourcev1.HelmChartKind: + hc := buildHelmChartFromTemplate(obj, ref) + newChart = hc + newChartDeepCopy = hc.DeepCopy() + newChartWithSourceRef = fmt.Sprintf(" with SourceRef '%s/%s/%s'", + hc.Spec.SourceRef.Kind, hc.GetNamespace(), hc.Spec.SourceRef.Name) + case sourcev1.OCIRepositoryKind: + or := buildOCIRepositoryFromTemplate(obj, ref) + newChart = or + newChartDeepCopy = or.DeepCopy() + newChartWithSourceRef = "" + } // Convert to an unstructured object to please the SSA library. - uo, err := runtime.DefaultUnstructuredConverter.ToUnstructured(newChart.DeepCopy()) + uo, err := runtime.DefaultUnstructuredConverter.ToUnstructured(newChartDeepCopy) if err != nil { - return fmt.Errorf("failed to convert HelmChart to unstructured: %w", err) + return fmt.Errorf("failed to convert %s to unstructured: %w", ref.Kind, err) } u := &unstructured.Unstructured{Object: uo} // Get the GVK for the object according to the current scheme. gvk, err := apiutil.GVKForObject(newChart, r.client.Scheme()) if err != nil { - return fmt.Errorf("unable to get GVK for HelmChart: %w", err) + return fmt.Errorf("unable to get GVK for %s: %w", ref.Kind, err) } u.SetGroupVersionKind(gvk) @@ -145,24 +152,22 @@ func (r *HelmChartTemplate) Reconcile(ctx context.Context, req *Request) error { entry, err := rm.Apply(ctx, u, ssa.DefaultApplyOptions()) if err != nil { err = fmt.Errorf("failed to run server-side apply: %w", err) - r.eventRecorder.Eventf(req.Object, eventv1.EventTypeTrace, "HelmChartSyncErr", "%s", err.Error()) + reason := fmt.Sprintf("%sSyncErr", ref.Kind) + r.eventRecorder.Eventf(obj, eventv1.EventTypeTrace, reason, "%s", err.Error()) return err } // Consult the entry result and act accordingly. switch entry.Action { case ssa.CreatedAction, ssa.ConfiguredAction: - msg := strings.Normalize(fmt.Sprintf( - "%s %s with SourceRef '%s/%s/%s'", entry.Action.String(), entry.Subject, - newChart.Spec.SourceRef.Kind, newChart.GetNamespace(), newChart.Spec.SourceRef.Name, - )) + msg := strings.Normalize(fmt.Sprintf("%s %s%s", + entry.Action.String(), entry.Subject, newChartWithSourceRef)) ctrl.LoggerFrom(ctx).Info(msg) - r.eventRecorder.Eventf(req.Object, eventv1.EventTypeTrace, - fmt.Sprintf("HelmChart%s", strings.Title(entry.Action.String())), "%s", msg) + r.eventRecorder.Eventf(obj, eventv1.EventTypeTrace, + fmt.Sprintf("%s%s", ref.Kind, strings.Title(entry.Action.String())), "%s", msg) case ssa.UnchangedAction: - msg := fmt.Sprintf("%s with SourceRef '%s/%s/%s' is in-sync", entry.Subject, - newChart.Spec.SourceRef.Kind, newChart.GetNamespace(), newChart.Spec.SourceRef.Name) + msg := fmt.Sprintf("%s%s is in-sync", entry.Subject, newChartWithSourceRef) ctrl.LoggerFrom(ctx).Info(msg) default: @@ -170,89 +175,115 @@ func (r *HelmChartTemplate) Reconcile(ctx context.Context, req *Request) error { return err } - // From this moment on, we know the HelmChart spec is up-to-date. - obj.Status.HelmChart = chartRef.String() + // From this moment on, we know the chart spec is up-to-date. + obj.Status.SetChart(ref) return nil } -// reconcileDelete handles the garbage collection of the current HelmChart in -// the Status object of the given HelmRelease. +// reconcileDelete handles the garbage collection of the current chart +// referenced in the Status object of the given HelmRelease. func (r *HelmChartTemplate) reconcileDelete(ctx context.Context, obj *v2.HelmRelease) error { - if !obj.Spec.Suspend && obj.Status.HelmChart != "" { - ns, name := obj.Status.GetHelmChart() - namespacedName := types.NamespacedName{Namespace: ns, Name: name} + if !obj.Spec.Suspend && obj.Status.HasChart() { + ref := obj.Status.GetHelmChartReference() - // Confirm we are allowed to fetch the HelmChart. - if err := acl.AllowsAccessTo(obj, sourcev1.HelmChartKind, namespacedName); err != nil { + // Confirm we are allowed to fetch the chart. + if err := acl.AllowsAccessTo(obj, ref); err != nil { return err } - // Fetch the HelmChart. - var chart sourcev1.HelmChart - err := r.client.Get(ctx, namespacedName, &chart) + // Fetch the chart. + var chart client.Object + if ref.Kind == sourcev1.OCIRepositoryKind { + chart = &sourcev1.OCIRepository{} + } else { + chart = &sourcev1.HelmChart{} + } + err := r.client.Get(ctx, ref.GetObjectKey(), chart) if err != nil && !apierrors.IsNotFound(err) { // Return error to retry until we succeed. - err = fmt.Errorf("failed to delete HelmChart '%s': %w", obj.Status.HelmChart, err) + err = fmt.Errorf("failed to get '%s': %w", ref, err) return err } if err == nil { - // Delete the HelmChart. - if err = r.client.Delete(ctx, &chart); client.IgnoreNotFound(err) != nil { - err = fmt.Errorf("failed to delete HelmChart '%s': %w", obj.Status.HelmChart, err) + // Delete the chart. + if err = r.client.Delete(ctx, chart); client.IgnoreNotFound(err) != nil { + err = fmt.Errorf("failed to delete '%s': %w", ref, err) return err } - r.eventRecorder.Eventf(obj, eventv1.EventTypeTrace, "HelmChartDeleted", "deleted HelmChart '%s'", obj.Status.HelmChart) + reason := fmt.Sprintf("%sDeleted", ref.Kind) + r.eventRecorder.Eventf(obj, eventv1.EventTypeTrace, reason, "deleted '%s'", ref) } // Truncate the chart reference in the status object. - obj.Status.HelmChart = "" + obj.Status.SetChart(nil) } return nil } -// buildHelmChartFromTemplate builds a v1.HelmChart from the -// v2.HelmChartTemplate of the given v2.HelmRelease. -func buildHelmChartFromTemplate(obj *v2.HelmRelease) *sourcev1.HelmChart { +func buildHelmChartFromTemplate(obj *v2.HelmRelease, ref *v2.HelmChartReference) *sourcev1.HelmChart { template := obj.Spec.Chart.DeepCopy() result := &sourcev1.HelmChart{ ObjectMeta: metav1.ObjectMeta{ - Name: obj.GetHelmChartName(), - Namespace: template.GetNamespace(obj.Namespace), + Name: ref.Name, + Namespace: ref.Namespace, }, Spec: sourcev1.HelmChartSpec{ - Chart: template.Spec.Chart, - Version: template.Spec.Version, - SourceRef: sourcev1.LocalHelmChartSourceReference{ - Name: template.Spec.SourceRef.Name, - Kind: template.Spec.SourceRef.Kind, - }, - Interval: template.GetInterval(obj.Spec.Interval), - ReconcileStrategy: template.Spec.ReconcileStrategy, + Chart: template.Spec.Chart, + Version: template.Spec.GetVersion(), + Interval: obj.GetTemplateInterval(), + ReconcileStrategy: template.Spec.GetReconcileStrategy(), ValuesFiles: template.Spec.ValuesFiles, IgnoreMissingValuesFiles: template.Spec.IgnoreMissingValuesFiles, }, } + if sourceRef := template.Spec.SourceRef; sourceRef != nil { + result.Spec.SourceRef = sourcev1.LocalHelmChartSourceReference{ + Name: sourceRef.Name, + Kind: sourceRef.Kind, + } + } if verifyTpl := template.Spec.Verify; verifyTpl != nil { result.Spec.Verify = &sourcev1.HelmChartVerification{ Provider: verifyTpl.Provider, SecretRef: verifyTpl.SecretRef, } } - if metaTpl := template.ObjectMeta; metaTpl != nil { + if metaTpl := obj.Spec.Chart.ObjectMeta; metaTpl != nil { result.SetAnnotations(metaTpl.Annotations) result.SetLabels(metaTpl.Labels) } return result } -func mustCleanDeployedChart(obj *v2.HelmRelease) bool { - if obj.HasChartRef() && !obj.HasChartTemplate() { - if obj.Status.HelmChart != "" { - return true +func buildOCIRepositoryFromTemplate(obj *v2.HelmRelease, ref *v2.HelmChartReference) *sourcev1.OCIRepository { + template := obj.Spec.Chart + interval := obj.GetTemplateInterval() + spec := template.Spec.OCIRepositorySpec.DeepCopy() + spec.Interval = &interval + // If the layer selector is not explicitly specified, default to + // selecting the Helm chart layer and copying it as-is. + if spec.LayerSelector == nil { + spec.LayerSelector = &sourcev1.OCILayerSelector{ + MediaType: registry.ChartLayerMediaType, + Operation: sourcev1.OCILayerCopy, } } + result := &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: ref.Name, + Namespace: ref.Namespace, + }, + Spec: *spec, + } + if metaTpl := obj.Spec.Chart.ObjectMeta; metaTpl != nil { + result.SetAnnotations(metaTpl.Annotations) + result.SetLabels(metaTpl.Labels) + } + return result +} - return false +func mustCleanDeployedChart(obj *v2.HelmRelease) bool { + return obj.HasChartRef() && !obj.HasChartTemplate() && obj.Status.HasChart() } diff --git a/internal/reconcile/helmchart_template_test.go b/internal/reconcile/helmchart_template_test.go index 69ee1bc07..b92d1011d 100644 --- a/internal/reconcile/helmchart_template_test.go +++ b/internal/reconcile/helmchart_template_test.go @@ -23,6 +23,7 @@ import ( "time" . "github.com/onsi/gomega" + "helm.sh/helm/v4/pkg/registry" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -148,7 +149,7 @@ func TestHelmChartTemplate_Reconcile(t *testing.T) { Chart: &v2.HelmChartTemplate{ Spec: v2.HelmChartTemplateSpec{ Chart: "foo", - SourceRef: v2.CrossNamespaceObjectReference{ + SourceRef: &v2.CrossNamespaceObjectReference{ Kind: sourcev1.HelmRepositoryKind, Name: "foo-repository", }, @@ -197,7 +198,7 @@ func TestHelmChartTemplate_Reconcile(t *testing.T) { Interval: metav1.Duration{Duration: 1 * time.Hour}, Chart: &v2.HelmChartTemplate{ Spec: v2.HelmChartTemplateSpec{ - SourceRef: v2.CrossNamespaceObjectReference{ + SourceRef: &v2.CrossNamespaceObjectReference{ Kind: sourcev1.HelmRepositoryKind, Name: "mock", }, @@ -214,9 +215,7 @@ func TestHelmChartTemplate_Reconcile(t *testing.T) { expectChart := sourcev1.HelmChart{} g.Eventually(func(g Gomega) { - g.Expect(testEnv.Get(context.TODO(), types.NamespacedName{ - Namespace: obj.Spec.Chart.GetNamespace(obj.Namespace), - Name: obj.GetHelmChartName()}, + g.Expect(testEnv.Get(context.TODO(), obj.GetHelmChartTemplateReference().GetObjectKey(), &expectChart, )).To(Succeed()) }).Should(Succeed()) @@ -269,7 +268,7 @@ func TestHelmChartTemplate_Reconcile(t *testing.T) { Chart: &v2.HelmChartTemplate{ Spec: v2.HelmChartTemplateSpec{ Chart: "foo", - SourceRef: v2.CrossNamespaceObjectReference{ + SourceRef: &v2.CrossNamespaceObjectReference{ Kind: sourcev1.HelmRepositoryKind, Name: "foo-repository", }, @@ -286,9 +285,7 @@ func TestHelmChartTemplate_Reconcile(t *testing.T) { newChart := sourcev1.HelmChart{} g.Eventually(func(g Gomega) { - g.Expect(testEnv.Get(context.TODO(), types.NamespacedName{ - Namespace: obj.Spec.Chart.GetNamespace(obj.Namespace), - Name: obj.GetHelmChartName()}, &newChart)).To(Succeed()) + g.Expect(testEnv.Get(context.TODO(), obj.GetHelmChartTemplateReference().GetObjectKey(), &newChart)).To(Succeed()) g.Expect(newChart.Spec.Chart).To(Equal(obj.Spec.Chart.Spec.Chart)) g.Expect(newChart.Spec.SourceRef.Name).To(Equal(obj.Spec.Chart.Spec.SourceRef.Name)) @@ -340,7 +337,7 @@ func TestHelmChartTemplate_Reconcile(t *testing.T) { Chart: &v2.HelmChartTemplate{ Spec: v2.HelmChartTemplateSpec{ Chart: existingChart.Spec.Chart, - SourceRef: v2.CrossNamespaceObjectReference{ + SourceRef: &v2.CrossNamespaceObjectReference{ Kind: existingChart.Spec.SourceRef.Kind, Name: existingChart.Spec.SourceRef.Name, }, @@ -357,9 +354,7 @@ func TestHelmChartTemplate_Reconcile(t *testing.T) { g.Expect(obj.Status.HelmChart).ToNot(BeEmpty()) newChart := sourcev1.HelmChart{} - g.Expect(testEnv.Get(context.TODO(), types.NamespacedName{ - Namespace: obj.Spec.Chart.GetNamespace(obj.Namespace), - Name: obj.GetHelmChartName()}, &newChart)).To(Succeed()) + g.Expect(testEnv.Get(context.TODO(), obj.GetHelmChartTemplateReference().GetObjectKey(), &newChart)).To(Succeed()) g.Expect(newChart.ResourceVersion).To(Equal(existingChart.ResourceVersion), "HelmChart should not have been updated") }) @@ -383,7 +378,7 @@ func TestHelmChartTemplate_Reconcile(t *testing.T) { Interval: metav1.Duration{Duration: 1 * time.Hour}, Chart: &v2.HelmChartTemplate{ Spec: v2.HelmChartTemplateSpec{ - SourceRef: v2.CrossNamespaceObjectReference{ + SourceRef: &v2.CrossNamespaceObjectReference{ Kind: sourcev1.HelmRepositoryKind, Name: "mock", }, @@ -400,11 +395,7 @@ func TestHelmChartTemplate_Reconcile(t *testing.T) { expectChart := sourcev1.HelmChart{} g.Eventually(func(g Gomega) { - g.Expect(r.client.Get(context.TODO(), types.NamespacedName{ - Namespace: obj.Spec.Chart.GetNamespace(obj.Namespace), - Name: obj.GetHelmChartName()}, - &expectChart, - )).To(Succeed()) + g.Expect(r.client.Get(context.TODO(), obj.GetHelmChartTemplateReference().GetObjectKey(), &expectChart)).To(Succeed()) g.Expect(testEnv.Cleanup(context.Background(), &expectChart)).To(Succeed()) g.Expect(expectChart.GetLabels()).To(HaveKeyWithValue(v2.GroupVersion.Group+"/name", obj.GetName())) @@ -427,7 +418,7 @@ func TestHelmChartTemplate_Reconcile(t *testing.T) { Spec: v2.HelmReleaseSpec{ Chart: &v2.HelmChartTemplate{ Spec: v2.HelmChartTemplateSpec{ - SourceRef: v2.CrossNamespaceObjectReference{ + SourceRef: &v2.CrossNamespaceObjectReference{ Name: "chart", Namespace: "other", }, @@ -702,11 +693,13 @@ func Test_buildHelmChartFromTemplate(t *testing.T) { Spec: v2.HelmChartTemplateSpec{ Chart: "chart", Version: "1.0.0", - SourceRef: v2.CrossNamespaceObjectReference{ + SourceRef: &v2.CrossNamespaceObjectReference{ Name: "test-repository", Kind: "HelmRepository", }, - Interval: &metav1.Duration{Duration: 2 * time.Minute}, + OCIRepositorySpec: sourcev1.OCIRepositorySpec{ + Interval: &metav1.Duration{Duration: 2 * time.Minute}, + }, ValuesFiles: []string{"values.yaml"}, }, }, @@ -733,8 +726,55 @@ func Test_buildHelmChartFromTemplate(t *testing.T) { Name: "test-repository", Kind: "HelmRepository", }, - Interval: metav1.Duration{Duration: 2 * time.Minute}, - ValuesFiles: []string{"values.yaml"}, + ReconcileStrategy: sourcev1.ReconcileStrategyChartVersion, + Interval: metav1.Duration{Duration: 2 * time.Minute}, + ValuesFiles: []string{"values.yaml"}, + }, + }, + }, + { + name: "defaults version to latest", + modify: func(hr *v2.HelmRelease) { + hr.Spec.Chart.Spec.Version = "" + }, + want: &sourcev1.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default-test-release", + Namespace: "default", + }, + Spec: sourcev1.HelmChartSpec{ + Chart: "chart", + Version: "*", + SourceRef: sourcev1.LocalHelmChartSourceReference{ + Name: "test-repository", + Kind: "HelmRepository", + }, + ReconcileStrategy: sourcev1.ReconcileStrategyChartVersion, + Interval: metav1.Duration{Duration: 2 * time.Minute}, + ValuesFiles: []string{"values.yaml"}, + }, + }, + }, + { + name: "takes ReconcileStrategy into account", + modify: func(hr *v2.HelmRelease) { + hr.Spec.Chart.Spec.ReconcileStrategy = sourcev1.ReconcileStrategyRevision + }, + want: &sourcev1.HelmChart{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default-test-release", + Namespace: "default", + }, + Spec: sourcev1.HelmChartSpec{ + Chart: "chart", + Version: "1.0.0", + SourceRef: sourcev1.LocalHelmChartSourceReference{ + Name: "test-repository", + Kind: "HelmRepository", + }, + ReconcileStrategy: sourcev1.ReconcileStrategyRevision, + Interval: metav1.Duration{Duration: 2 * time.Minute}, + ValuesFiles: []string{"values.yaml"}, }, }, }, @@ -755,8 +795,9 @@ func Test_buildHelmChartFromTemplate(t *testing.T) { Name: "test-repository", Kind: "HelmRepository", }, - Interval: metav1.Duration{Duration: 2 * time.Minute}, - ValuesFiles: []string{"values.yaml"}, + ReconcileStrategy: sourcev1.ReconcileStrategyChartVersion, + Interval: metav1.Duration{Duration: 2 * time.Minute}, + ValuesFiles: []string{"values.yaml"}, }, }, }, @@ -777,15 +818,16 @@ func Test_buildHelmChartFromTemplate(t *testing.T) { Name: "test-repository", Kind: "HelmRepository", }, - Interval: metav1.Duration{Duration: time.Minute}, - ValuesFiles: []string{"values.yaml"}, + ReconcileStrategy: sourcev1.ReconcileStrategyChartVersion, + Interval: metav1.Duration{Duration: time.Minute}, + ValuesFiles: []string{"values.yaml"}, }, }, }, { name: "take cosign verification into account", modify: func(hr *v2.HelmRelease) { - hr.Spec.Chart.Spec.Verify = &v2.HelmChartTemplateVerification{ + hr.Spec.Chart.Spec.Verify = &sourcev1.OCIRepositoryVerification{ Provider: "cosign", SecretRef: &meta.LocalObjectReference{ Name: "cosign-key", @@ -804,8 +846,9 @@ func Test_buildHelmChartFromTemplate(t *testing.T) { Name: "test-repository", Kind: "HelmRepository", }, - Interval: metav1.Duration{Duration: 2 * time.Minute}, - ValuesFiles: []string{"values.yaml"}, + ReconcileStrategy: sourcev1.ReconcileStrategyChartVersion, + Interval: metav1.Duration{Duration: 2 * time.Minute}, + ValuesFiles: []string{"values.yaml"}, Verify: &sourcev1.HelmChartVerification{ Provider: "cosign", SecretRef: &meta.LocalObjectReference{ @@ -845,8 +888,183 @@ func Test_buildHelmChartFromTemplate(t *testing.T) { Name: "test-repository", Kind: "HelmRepository", }, - Interval: metav1.Duration{Duration: 2 * time.Minute}, - ValuesFiles: []string{"values.yaml"}, + ReconcileStrategy: sourcev1.ReconcileStrategyChartVersion, + Interval: metav1.Duration{Duration: 2 * time.Minute}, + ValuesFiles: []string{"values.yaml"}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + hr := hrWithChartTemplate.DeepCopy() + tt.modify(hr) + + g.Expect(buildHelmChartFromTemplate(hr, hr.GetHelmChartTemplateReference())).To(Equal(tt.want)) + }) + } +} + +func Test_buildOCIRepositoryFromTemplate(t *testing.T) { + defaultLayerSelector := &sourcev1.OCILayerSelector{ + MediaType: registry.ChartLayerMediaType, + Operation: sourcev1.OCILayerCopy, + } + + hrWithChartTemplate := v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-release", + Namespace: "default", + }, + Spec: v2.HelmReleaseSpec{ + Interval: metav1.Duration{Duration: time.Minute}, + Chart: &v2.HelmChartTemplate{ + Kind: sourcev1.OCIRepositoryKind, + Spec: v2.HelmChartTemplateSpec{ + OCIRepositorySpec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + Reference: &sourcev1.OCIRepositoryRef{ + SemVer: ">= 6.0.0", + }, + Interval: &metav1.Duration{Duration: 2 * time.Minute}, + }, + }, + }, + }, + } + + tests := []struct { + name string + modify func(release *v2.HelmRelease) + want *sourcev1.OCIRepository + }{ + { + name: "builds OCIRepository from HelmChartTemplate", + modify: func(*v2.HelmRelease) {}, + want: &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default-test-release", + Namespace: "default", + }, + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + Reference: &sourcev1.OCIRepositoryRef{ + SemVer: ">= 6.0.0", + }, + LayerSelector: defaultLayerSelector, + Interval: &metav1.Duration{Duration: 2 * time.Minute}, + }, + }, + }, + { + name: "falls back to HelmRelease interval", + modify: func(hr *v2.HelmRelease) { + hr.Spec.Chart.Spec.Interval = nil + }, + want: &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default-test-release", + Namespace: "default", + }, + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + Reference: &sourcev1.OCIRepositoryRef{ + SemVer: ">= 6.0.0", + }, + LayerSelector: defaultLayerSelector, + Interval: &metav1.Duration{Duration: time.Minute}, + }, + }, + }, + { + name: "respects explicitly specified layer selector", + modify: func(hr *v2.HelmRelease) { + hr.Spec.Chart.Spec.LayerSelector = &sourcev1.OCILayerSelector{ + MediaType: "application/vnd.example.custom.layer", + Operation: sourcev1.OCILayerExtract, + } + }, + want: &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default-test-release", + Namespace: "default", + }, + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + Reference: &sourcev1.OCIRepositoryRef{ + SemVer: ">= 6.0.0", + }, + LayerSelector: &sourcev1.OCILayerSelector{ + MediaType: "application/vnd.example.custom.layer", + Operation: sourcev1.OCILayerExtract, + }, + Interval: &metav1.Duration{Duration: 2 * time.Minute}, + }, + }, + }, + { + name: "take cosign verification into account", + modify: func(hr *v2.HelmRelease) { + hr.Spec.Chart.Spec.Verify = &sourcev1.OCIRepositoryVerification{ + Provider: "cosign", + SecretRef: &meta.LocalObjectReference{ + Name: "cosign-key", + }, + } + }, + want: &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default-test-release", + Namespace: "default", + }, + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + Reference: &sourcev1.OCIRepositoryRef{ + SemVer: ">= 6.0.0", + }, + LayerSelector: defaultLayerSelector, + Interval: &metav1.Duration{Duration: 2 * time.Minute}, + Verify: &sourcev1.OCIRepositoryVerification{ + Provider: "cosign", + SecretRef: &meta.LocalObjectReference{ + Name: "cosign-key", + }, + }, + }, + }, + }, + { + name: "takes object meta into account", + modify: func(hr *v2.HelmRelease) { + hr.Spec.Chart.ObjectMeta = &v2.HelmChartTemplateObjectMeta{ + Labels: map[string]string{ + "foo": "bar", + }, + Annotations: map[string]string{ + "bar": "baz", + }, + } + }, + want: &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default-test-release", + Namespace: "default", + Labels: map[string]string{ + "foo": "bar", + }, + Annotations: map[string]string{ + "bar": "baz", + }, + }, + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + Reference: &sourcev1.OCIRepositoryRef{ + SemVer: ">= 6.0.0", + }, + LayerSelector: defaultLayerSelector, + Interval: &metav1.Duration{Duration: 2 * time.Minute}, }, }, }, @@ -858,7 +1076,591 @@ func Test_buildHelmChartFromTemplate(t *testing.T) { hr := hrWithChartTemplate.DeepCopy() tt.modify(hr) - g.Expect(buildHelmChartFromTemplate(hr)).To(Equal(tt.want)) + g.Expect(buildOCIRepositoryFromTemplate(hr, hr.GetHelmChartTemplateReference())).To(Equal(tt.want)) }) } } + +func TestHelmChartTemplate_reconcileDelete_OCIRepository(t *testing.T) { + now := metav1.Now() + + t.Run("Status.OCIRepository is deleted", func(t *testing.T) { + g := NewWithT(t) + + builder := fake.NewClientBuilder(). + WithScheme(NewTestScheme()). + WithObjects(&sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "chart", + }, + }) + + recorder := record.NewFakeRecorder(32) + r := &HelmChartTemplate{ + client: builder.Build(), + eventRecorder: recorder, + } + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "release", + Namespace: "default", + }, + Status: v2.HelmReleaseStatus{ + HelmChart: fmt.Sprintf("%s/default/chart", sourcev1.OCIRepositoryKind), + }, + } + err := r.reconcileDelete(context.TODO(), obj) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(obj.Status.HelmChart).To(BeEmpty()) + + err = r.client.Get(context.TODO(), types.NamespacedName{Namespace: "default", Name: "chart"}, &sourcev1.OCIRepository{}) + g.Expect(err).To(HaveOccurred()) + g.Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }) + + t.Run("Status.OCIRepository is cleared when delete returns NotFound", func(t *testing.T) { + g := NewWithT(t) + + builder := fake.NewClientBuilder(). + WithScheme(NewTestScheme()). + WithObjects(&sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "chart", + }, + }). + WithInterceptorFuncs(interceptor.Funcs{ + Delete: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.DeleteOption) error { + return apierrors.NewNotFound(sourcev1.GroupVersion.WithResource("ocirepositories").GroupResource(), obj.GetName()) + }, + }) + + r := &HelmChartTemplate{ + client: builder.Build(), + eventRecorder: record.NewFakeRecorder(32), + } + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "release", + Namespace: "default", + }, + Status: v2.HelmReleaseStatus{ + HelmChart: fmt.Sprintf("%s/default/chart", sourcev1.OCIRepositoryKind), + }, + } + err := r.reconcileDelete(context.TODO(), obj) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(obj.Status.HelmChart).To(BeEmpty()) + }) + + t.Run("Status.OCIRepository already deleted", func(t *testing.T) { + g := NewWithT(t) + + r := &HelmChartTemplate{ + client: fake.NewClientBuilder().WithScheme(NewTestScheme()).Build(), + } + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "release", + Namespace: "default", + }, + Status: v2.HelmReleaseStatus{ + HelmChart: fmt.Sprintf("%s/default/chart", sourcev1.OCIRepositoryKind), + }, + } + err := r.reconcileDelete(context.TODO(), obj) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(obj.Status.HelmChart).To(BeEmpty()) + }) + + t.Run("Spec.Suspend is respected", func(t *testing.T) { + g := NewWithT(t) + + builder := fake.NewClientBuilder(). + WithScheme(NewTestScheme()). + WithObjects(&sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "chart", + }, + }) + + recorder := record.NewFakeRecorder(32) + r := &HelmChartTemplate{ + client: builder.Build(), + eventRecorder: recorder, + } + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: "release", + Namespace: "default", + DeletionTimestamp: &now, + }, + Spec: v2.HelmReleaseSpec{ + Suspend: true, + }, + Status: v2.HelmReleaseStatus{ + HelmChart: fmt.Sprintf("%s/default/chart", sourcev1.OCIRepositoryKind), + }, + } + err := r.reconcileDelete(context.TODO(), obj) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(obj.Status.HelmChart).ToNot(BeEmpty()) + + g.Consistently(func(g Gomega) { + err = r.client.Get(context.TODO(), types.NamespacedName{Namespace: "default", Name: "chart"}, &sourcev1.OCIRepository{}) + g.Expect(err).ToNot(HaveOccurred()) + }).Should(Succeed()) + }) + + t.Run("cross namespace allow is respected", func(t *testing.T) { + g := NewWithT(t) + + repo := &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "other", + Name: "chart", + }, + } + builder := fake.NewClientBuilder(). + WithScheme(NewTestScheme()). + WithObjects(repo) + + r := &HelmChartTemplate{ + client: builder.Build(), + } + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + }, + Status: v2.HelmReleaseStatus{ + HelmChart: fmt.Sprintf("%s/other/chart", sourcev1.OCIRepositoryKind), + }, + } + + currentAllow := acl.AllowCrossNamespaceRef + acl.AllowCrossNamespaceRef = false + t.Cleanup(func() { acl.AllowCrossNamespaceRef = currentAllow }) + + err := r.reconcileDelete(context.TODO(), obj) + g.Expect(err).To(HaveOccurred()) + g.Expect(obj.Status.HelmChart).ToNot(BeEmpty()) + + g.Expect(r.client.Get(context.TODO(), + types.NamespacedName{Namespace: repo.Namespace, Name: repo.Name}, + &sourcev1.OCIRepository{}), + ).To(Succeed()) + }) +} + +func TestHelmChartTemplate_Reconcile_OCIRepository(t *testing.T) { + g := NewWithT(t) + + namespace := corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "helm-release-chart-reconciler-", + }, + } + g.Expect(testEnv.CreateAndWait(context.Background(), &namespace)).To(Succeed()) + t.Cleanup(func() { + g.Expect(testEnv.Cleanup(context.Background(), &namespace)).To(Succeed()) + }) + + t.Run("DeletionTimestamp triggers delete", func(t *testing.T) { + g := NewWithT(t) + + releaseName := "oci-deletion-timestamp" + existingRepo := sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace.GetName(), + Name: fmt.Sprintf("%s-%s", namespace.GetName(), releaseName), + Labels: map[string]string{ + v2.GroupVersion.Group + "/name": releaseName, + v2.GroupVersion.Group + "/namespace": namespace.GetName(), + }, + }, + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + Interval: &metav1.Duration{Duration: 1 * time.Hour}, + }, + } + g.Expect(testEnv.CreateAndWait(context.Background(), &existingRepo)).To(Succeed()) + t.Cleanup(func() { + g.Expect(testEnv.Cleanup(context.Background(), &existingRepo)).To(Succeed()) + }) + + recorder := record.NewFakeRecorder(32) + r := &HelmChartTemplate{ + client: testEnv, + eventRecorder: recorder, + fieldManager: testFieldManager, + } + + ts := metav1.Now() + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace.GetName(), + Name: releaseName, + DeletionTimestamp: &ts, + }, + Status: v2.HelmReleaseStatus{ + HelmChart: fmt.Sprintf("%s/%s/%s", sourcev1.OCIRepositoryKind, existingRepo.GetNamespace(), existingRepo.GetName()), + }, + } + + err := r.Reconcile(context.TODO(), &Request{Object: obj}) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(obj.Status.HelmChart).To(BeEmpty()) + + g.Eventually(func(g Gomega) { + g.Expect(apierrors.IsNotFound(testEnv.Get(context.TODO(), + types.NamespacedName{ + Namespace: existingRepo.GetNamespace(), + Name: existingRepo.GetName(), + }, + &existingRepo, + ))).To(BeTrue()) + }).Should(Succeed()) + }) + + t.Run("Status.OCIRepository divergence triggers delete and creates chart", func(t *testing.T) { + g := NewWithT(t) + + existingRepo := sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace.GetName(), + GenerateName: "existing-oci-repo-", + }, + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + Interval: &metav1.Duration{Duration: 1 * time.Hour}, + }, + } + g.Expect(testEnv.CreateAndWait(context.TODO(), &existingRepo)).To(Succeed()) + t.Cleanup(func() { + g.Expect(testEnv.Cleanup(context.Background(), &existingRepo)).To(Succeed()) + }) + + r := &HelmChartTemplate{ + client: testEnv, + eventRecorder: record.NewFakeRecorder(32), + fieldManager: testFieldManager, + } + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace.GetName(), + Name: "oci-release-with-existing-repo", + }, + Spec: v2.HelmReleaseSpec{ + Interval: metav1.Duration{Duration: 1 * time.Hour}, + Chart: &v2.HelmChartTemplate{ + Kind: sourcev1.OCIRepositoryKind, + Spec: v2.HelmChartTemplateSpec{ + OCIRepositorySpec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + Interval: &metav1.Duration{Duration: 1 * time.Hour}, + }, + }, + }, + }, + Status: v2.HelmReleaseStatus{ + HelmChart: fmt.Sprintf("%s/%s/%s", sourcev1.OCIRepositoryKind, existingRepo.GetNamespace(), existingRepo.GetName()), + }, + } + + err := r.Reconcile(context.TODO(), &Request{Object: obj}) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(obj.Status.HelmChart).To(Equal( + fmt.Sprintf("%s/%s/%s", sourcev1.OCIRepositoryKind, namespace.GetName(), namespace.GetName()+"-"+obj.GetName()), + )) + + g.Eventually(func(g Gomega) { + g.Expect(apierrors.IsNotFound(testEnv.Get(context.TODO(), + types.NamespacedName{ + Namespace: existingRepo.GetNamespace(), + Name: existingRepo.GetName(), + }, + &existingRepo, + ))).To(BeTrue()) + }).Should(Succeed()) + }) + + t.Run("OCIRepository NotFound creates OCIRepository", func(t *testing.T) { + g := NewWithT(t) + + recorder := record.NewFakeRecorder(32) + r := &HelmChartTemplate{ + client: testEnv, + eventRecorder: recorder, + fieldManager: testFieldManager, + } + + releaseName := "oci-not-found" + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace.GetName(), + Name: releaseName, + }, + Spec: v2.HelmReleaseSpec{ + Interval: metav1.Duration{Duration: 1 * time.Hour}, + Chart: &v2.HelmChartTemplate{ + Kind: sourcev1.OCIRepositoryKind, + Spec: v2.HelmChartTemplateSpec{ + OCIRepositorySpec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + Interval: &metav1.Duration{Duration: 1 * time.Hour}, + }, + }, + }, + }, + Status: v2.HelmReleaseStatus{ + HelmChart: fmt.Sprintf("%s/%s/%s", sourcev1.OCIRepositoryKind, namespace.GetName(), namespace.GetName()+"-"+releaseName), + }, + } + err := r.Reconcile(context.TODO(), &Request{Object: obj}) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(obj.Status.HelmChart).ToNot(BeEmpty()) + + expectRepo := sourcev1.OCIRepository{} + g.Eventually(func(g Gomega) { + g.Expect(testEnv.Get(context.TODO(), obj.GetHelmChartTemplateReference().GetObjectKey(), + &expectRepo, + )).To(Succeed()) + }).Should(Succeed()) + + t.Cleanup(func() { + g.Expect(testEnv.Cleanup(context.Background(), &expectRepo)).To(Succeed()) + }) + }) + + t.Run("Spec divergence updates OCIRepository", func(t *testing.T) { + g := NewWithT(t) + + releaseName := "oci-divergence" + existingRepo := sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace.GetName(), + Name: fmt.Sprintf("%s-%s", namespace.GetName(), releaseName), + Labels: map[string]string{ + v2.GroupVersion.Group + "/name": releaseName, + v2.GroupVersion.Group + "/namespace": namespace.GetName(), + }, + }, + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/bar", + Interval: &metav1.Duration{Duration: 1 * time.Hour}, + }, + } + g.Expect(testEnv.CreateAndWait(context.TODO(), &existingRepo)).To(Succeed()) + t.Cleanup(func() { + g.Expect(testEnv.Cleanup(context.Background(), &existingRepo)).To(Succeed()) + }) + + recorder := record.NewFakeRecorder(32) + r := &HelmChartTemplate{ + client: testEnv, + eventRecorder: recorder, + fieldManager: testFieldManager, + } + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace.GetName(), + Name: releaseName, + }, + Spec: v2.HelmReleaseSpec{ + Interval: metav1.Duration{Duration: 1 * time.Hour}, + Chart: &v2.HelmChartTemplate{ + Kind: sourcev1.OCIRepositoryKind, + Spec: v2.HelmChartTemplateSpec{ + OCIRepositorySpec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/foo", + Interval: &metav1.Duration{Duration: 1 * time.Hour}, + }, + }, + }, + }, + Status: v2.HelmReleaseStatus{ + HelmChart: fmt.Sprintf("%s/%s/%s", sourcev1.OCIRepositoryKind, existingRepo.GetNamespace(), existingRepo.GetName()), + }, + } + err := r.Reconcile(context.TODO(), &Request{Object: obj}) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(obj.Status.HelmChart).ToNot(BeEmpty()) + + newRepo := sourcev1.OCIRepository{} + g.Eventually(func(g Gomega) { + g.Expect(testEnv.Get(context.TODO(), obj.GetHelmChartTemplateReference().GetObjectKey(), &newRepo)).To(Succeed()) + + g.Expect(newRepo.Spec.URL).To(Equal(obj.Spec.Chart.Spec.OCIRepositorySpec.URL)) + }).Should(Succeed()) + }) + + t.Run("no OCIRepository divergence", func(t *testing.T) { + g := NewWithT(t) + + releaseName := "oci-no-divergence" + existingRepo := &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace.GetName(), + Name: fmt.Sprintf("%s-%s", namespace.GetName(), releaseName), + Labels: map[string]string{ + v2.GroupVersion.Group + "/name": releaseName, + v2.GroupVersion.Group + "/namespace": namespace.GetName(), + }, + }, + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + LayerSelector: &sourcev1.OCILayerSelector{ + MediaType: registry.ChartLayerMediaType, + Operation: sourcev1.OCILayerCopy, + }, + Interval: &metav1.Duration{Duration: 1 * time.Hour}, + }, + } + g.Expect(testEnv.CreateAndWait(context.Background(), existingRepo)).To(Succeed()) + t.Cleanup(func() { + g.Expect(testEnv.Cleanup(context.Background(), existingRepo)).To(Succeed()) + }) + + recorder := record.NewFakeRecorder(32) + r := &HelmChartTemplate{ + client: testEnv, + eventRecorder: recorder, + fieldManager: testFieldManager, + } + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace.GetName(), + Name: releaseName, + }, + Spec: v2.HelmReleaseSpec{ + Interval: *existingRepo.Spec.Interval, + Chart: &v2.HelmChartTemplate{ + Kind: sourcev1.OCIRepositoryKind, + Spec: v2.HelmChartTemplateSpec{ + OCIRepositorySpec: sourcev1.OCIRepositorySpec{ + URL: existingRepo.Spec.URL, + Interval: existingRepo.Spec.Interval, + }, + }, + }, + }, + Status: v2.HelmReleaseStatus{ + HelmChart: fmt.Sprintf("%s/%s/%s", sourcev1.OCIRepositoryKind, existingRepo.GetNamespace(), existingRepo.GetName()), + }, + } + + err := r.Reconcile(context.TODO(), &Request{Object: obj}) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(obj.Status.HelmChart).ToNot(BeEmpty()) + + newRepo := sourcev1.OCIRepository{} + g.Expect(testEnv.Get(context.TODO(), obj.GetHelmChartTemplateReference().GetObjectKey(), &newRepo)).To(Succeed()) + g.Expect(newRepo.ResourceVersion).To(Equal(existingRepo.ResourceVersion), "OCIRepository should not have been updated") + }) + + t.Run("sets owner labels on OCIRepository", func(t *testing.T) { + g := NewWithT(t) + + recorder := record.NewFakeRecorder(32) + r := &HelmChartTemplate{ + client: testEnv, + eventRecorder: recorder, + fieldManager: testFieldManager, + } + + releaseName := "oci-owner-labels" + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace.GetName(), + Name: releaseName, + }, + Spec: v2.HelmReleaseSpec{ + Interval: metav1.Duration{Duration: 1 * time.Hour}, + Chart: &v2.HelmChartTemplate{ + Kind: sourcev1.OCIRepositoryKind, + Spec: v2.HelmChartTemplateSpec{ + OCIRepositorySpec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/podinfo", + Interval: &metav1.Duration{Duration: 1 * time.Hour}, + }, + }, + }, + }, + Status: v2.HelmReleaseStatus{ + HelmChart: fmt.Sprintf("%s/%s/%s", sourcev1.OCIRepositoryKind, namespace.GetName(), namespace.GetName()+"-"+releaseName), + }, + } + err := r.Reconcile(context.TODO(), &Request{Object: obj}) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(obj.Status.HelmChart).ToNot(BeEmpty()) + + expectRepo := sourcev1.OCIRepository{} + g.Eventually(func(g Gomega) { + g.Expect(r.client.Get(context.TODO(), obj.GetHelmChartTemplateReference().GetObjectKey(), &expectRepo)).To(Succeed()) + g.Expect(testEnv.Cleanup(context.Background(), &expectRepo)).To(Succeed()) + + g.Expect(expectRepo.GetLabels()).To(HaveKeyWithValue(v2.GroupVersion.Group+"/name", obj.GetName())) + g.Expect(expectRepo.GetLabels()).To(HaveKeyWithValue(v2.GroupVersion.Group+"/namespace", obj.GetNamespace())) + }).Should(Succeed()) + }) + + t.Run("Spec ChartRef and existing repo trigger delete", func(t *testing.T) { + g := NewWithT(t) + + releaseName := "oci-garbage-collection" + existingRepo := sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace.GetName(), + Name: fmt.Sprintf("%s-%s", namespace.GetName(), releaseName), + Labels: map[string]string{ + v2.GroupVersion.Group + "/name": releaseName, + v2.GroupVersion.Group + "/namespace": namespace.GetName(), + }, + }, + Spec: sourcev1.OCIRepositorySpec{ + URL: "oci://ghcr.io/stefanprodan/charts/bar", + Interval: &metav1.Duration{Duration: 1 * time.Hour}, + }, + } + g.Expect(testEnv.CreateAndWait(context.TODO(), &existingRepo)).To(Succeed()) + t.Cleanup(func() { + g.Expect(testEnv.Cleanup(context.Background(), &existingRepo)).To(Succeed()) + }) + + recorder := record.NewFakeRecorder(32) + r := &HelmChartTemplate{ + client: testEnv, + eventRecorder: recorder, + fieldManager: testFieldManager, + } + + obj := &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace.GetName(), + Name: releaseName, + }, + Spec: v2.HelmReleaseSpec{ + Interval: metav1.Duration{Duration: 1 * time.Hour}, + ChartRef: &v2.CrossNamespaceSourceReference{ + Kind: sourcev1.OCIRepositoryKind, + Name: "oci-repository", + }, + }, + Status: v2.HelmReleaseStatus{ + HelmChart: fmt.Sprintf("%s/%s/%s", sourcev1.OCIRepositoryKind, existingRepo.GetNamespace(), existingRepo.GetName()), + }, + } + err := r.Reconcile(context.TODO(), &Request{Object: obj}) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(obj.Status.HelmChart).To(BeEmpty()) + }) +}