diff --git a/api/v2beta2/helmrelease_types.go b/api/v2beta2/helmrelease_types.go
index 67ed0ae3e..ac7a8524f 100644
--- a/api/v2beta2/helmrelease_types.go
+++ b/api/v2beta2/helmrelease_types.go
@@ -908,6 +908,11 @@ type HelmReleaseStatus struct {
// +optional
LastAttemptedValuesChecksum string `json:"lastAttemptedValuesChecksum,omitempty"`
+ // LastReleaseRevision is the revision of the last successful Helm release.
+ // Deprecated: Use History instead.
+ // +optional
+ LastReleaseRevision int `json:"lastReleaseRevision,omitempty"`
+
// LastAttemptedConfigDigest is the digest for the config (better known as
// "values") of the last reconciliation attempt.
// +optional
diff --git a/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml b/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml
index bad1de58f..61270e2cb 100644
--- a/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml
+++ b/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml
@@ -2049,6 +2049,10 @@ spec:
reconcile request value, so a change of the annotation value can
be detected.
type: string
+ lastReleaseRevision:
+ description: 'LastReleaseRevision is the revision of the last successful
+ Helm release. Deprecated: Use History instead.'
+ type: integer
observedGeneration:
description: ObservedGeneration is the last observed generation.
format: int64
diff --git a/docs/api/v2beta2/helm.md b/docs/api/v2beta2/helm.md
index b92b55ac2..bd2677ab2 100644
--- a/docs/api/v2beta2/helm.md
+++ b/docs/api/v2beta2/helm.md
@@ -1407,6 +1407,19 @@ Deprecated: Use LastAttemptedConfigDigest instead.
+
lastAttemptedConfigDigest
string
diff --git a/internal/controller/helmrelease_controller.go b/internal/controller/helmrelease_controller.go
index 3bef30141..450419d22 100644
--- a/internal/controller/helmrelease_controller.go
+++ b/internal/controller/helmrelease_controller.go
@@ -60,10 +60,12 @@ import (
"github.com/fluxcd/helm-controller/internal/action"
"github.com/fluxcd/helm-controller/internal/chartutil"
"github.com/fluxcd/helm-controller/internal/digest"
+ "github.com/fluxcd/helm-controller/internal/features"
"github.com/fluxcd/helm-controller/internal/kube"
"github.com/fluxcd/helm-controller/internal/loader"
intpredicates "github.com/fluxcd/helm-controller/internal/predicates"
intreconcile "github.com/fluxcd/helm-controller/internal/reconcile"
+ "github.com/fluxcd/helm-controller/internal/release"
)
// +kubebuilder:rbac:groups=helm.toolkit.fluxcd.io,resources=helmreleases,verbs=get;list;watch;create;update;patch;delete
@@ -288,6 +290,16 @@ func (r *HelmReleaseReconciler) reconcileRelease(ctx context.Context, patchHelpe
return ctrl.Result{}, err
}
+ // Attempt to adopt "legacy" v2beta1 release state on a best-effort basis.
+ // If this fails, the controller will fall back to performing an upgrade
+ // to settle on the desired state.
+ // TODO(hidde): remove this in a future release.
+ if ok, _ := features.Enabled(features.AdoptLegacyReleases); ok {
+ if err := r.adoptLegacyRelease(ctx, getter, obj); err != nil {
+ log.Error(err, "failed to adopt v2beta1 release state")
+ }
+ }
+
// If the release target configuration has changed, we need to uninstall the
// previous release target first. If we did not do this, the installation would
// fail due to resources already existing.
@@ -315,6 +327,7 @@ func (r *HelmReleaseReconciler) reconcileRelease(ctx context.Context, patchHelpe
obj.Status.LastAttemptedRevision = loadedChart.Metadata.Version
obj.Status.LastAttemptedConfigDigest = chartutil.DigestValues(digest.Canonical, values).String()
obj.Status.LastAttemptedValuesChecksum = ""
+ obj.Status.LastReleaseRevision = 0
// Construct config factory for any further Helm actions.
cfg, err := action.NewConfigFactory(getter,
@@ -508,6 +521,57 @@ func (r *HelmReleaseReconciler) checkDependencies(ctx context.Context, obj *v2.H
return nil
}
+// adoptLegacyRelease attempts to adopt a v2beta1 release into a v2beta2
+// release.
+// This is done by retrieving the last successful release from the Helm storage
+// and converting it to a v2beta2 release snapshot.
+// If the v2beta1 release has already been adopted, this function is a no-op.
+func (r *HelmReleaseReconciler) adoptLegacyRelease(ctx context.Context, getter genericclioptions.RESTClientGetter, obj *v2.HelmRelease) error {
+ if obj.Status.LastReleaseRevision < 1 || len(obj.Status.History) > 0 {
+ return nil
+ }
+
+ var (
+ log = ctrl.LoggerFrom(ctx).V(logger.DebugLevel)
+ storageNamespace = obj.GetStorageNamespace()
+ releaseNamespace = obj.GetReleaseNamespace()
+ releaseName = obj.GetReleaseName()
+ version = obj.Status.LastReleaseRevision
+ )
+
+ log.Info("adopting %s/%s.v%d release from v2beta1 state", releaseNamespace, releaseName, version)
+
+ // Construct config factory for current release.
+ cfg, err := action.NewConfigFactory(getter,
+ action.WithStorage(action.DefaultStorageDriver, storageNamespace),
+ action.WithStorageLog(action.NewDebugLog(ctrl.LoggerFrom(ctx).V(logger.TraceLevel))),
+ )
+
+ // Get the last successful release based on the observation for the v2beta1
+ // object.
+ rls, err := cfg.NewStorage().Get(releaseName, version)
+ if err != nil {
+ return err
+ }
+
+ // Convert it to a v2beta2 release snapshot.
+ snap := release.ObservedToSnapshot(release.ObserveRelease(rls))
+
+ // If tests are enabled, include them as well.
+ if obj.GetTest().Enable {
+ snap.SetTestHooks(release.TestHooksFromRelease(rls))
+ }
+
+ // Adopt it as the current release in the history.
+ obj.Status.History = append(obj.Status.History, snap)
+ obj.Status.StorageNamespace = storageNamespace
+
+ // Erase the last release revision from the status.
+ obj.Status.LastReleaseRevision = 0
+
+ return nil
+}
+
func (r *HelmReleaseReconciler) buildRESTClientGetter(ctx context.Context, obj *v2.HelmRelease) (genericclioptions.RESTClientGetter, error) {
opts := []kube.Option{
kube.WithNamespace(obj.GetReleaseNamespace()),
diff --git a/internal/controller/helmrelease_controller_test.go b/internal/controller/helmrelease_controller_test.go
index 47f07848d..86ebf5fa3 100644
--- a/internal/controller/helmrelease_controller_test.go
+++ b/internal/controller/helmrelease_controller_test.go
@@ -46,6 +46,7 @@ import (
"github.com/fluxcd/pkg/apis/acl"
"github.com/fluxcd/pkg/apis/meta"
"github.com/fluxcd/pkg/runtime/conditions"
+ feathelper "github.com/fluxcd/pkg/runtime/features"
"github.com/fluxcd/pkg/runtime/patch"
sourcev1 "github.com/fluxcd/source-controller/api/v1"
sourcev1b2 "github.com/fluxcd/source-controller/api/v1beta2"
@@ -53,6 +54,8 @@ import (
v2 "github.com/fluxcd/helm-controller/api/v2beta2"
intacl "github.com/fluxcd/helm-controller/internal/acl"
"github.com/fluxcd/helm-controller/internal/action"
+ "github.com/fluxcd/helm-controller/internal/chartutil"
+ "github.com/fluxcd/helm-controller/internal/features"
"github.com/fluxcd/helm-controller/internal/kube"
intreconcile "github.com/fluxcd/helm-controller/internal/reconcile"
"github.com/fluxcd/helm-controller/internal/release"
@@ -430,6 +433,110 @@ func TestHelmReleaseReconciler_reconcileRelease(t *testing.T) {
}))
})
+ t.Run("attempts to adopt v2beta1 release state", func(t *testing.T) {
+ g := NewWithT(t)
+
+ // Initialize feature gates.
+ g.Expect((&feathelper.FeatureGates{}).SupportedFeatures(features.FeatureGates())).To(Succeed())
+
+ // Create a test namespace for storing the Helm release mock.
+ ns, err := testEnv.CreateNamespace(context.TODO(), "adopt-release")
+ g.Expect(err).ToNot(HaveOccurred())
+ t.Cleanup(func() {
+ _ = testEnv.Delete(context.TODO(), ns)
+ })
+
+ // Create HelmChart mock.
+ chartMock := testutil.BuildChart()
+ chartArtifact, err := testutil.SaveChartAsArtifact(chartMock, digest.SHA256, testServer.URL(), testServer.Root())
+ g.Expect(err).ToNot(HaveOccurred())
+
+ chart := &sourcev1b2.HelmChart{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "adopt-release",
+ Namespace: ns.Name,
+ Generation: 1,
+ },
+ Spec: sourcev1b2.HelmChartSpec{
+ Chart: "testdata/test-helmrepo",
+ Version: "0.1.0",
+ SourceRef: sourcev1b2.LocalHelmChartSourceReference{
+ Kind: sourcev1b2.HelmRepositoryKind,
+ Name: "reconcile-delete",
+ },
+ },
+ Status: sourcev1b2.HelmChartStatus{
+ ObservedGeneration: 1,
+ Artifact: chartArtifact,
+ Conditions: []metav1.Condition{
+ {
+ Type: meta.ReadyCondition,
+ Status: metav1.ConditionTrue,
+ },
+ },
+ },
+ }
+
+ // Create a test Helm release storage mock.
+ rls := testutil.BuildRelease(&helmrelease.MockReleaseOptions{
+ Name: "adopt-release",
+ Namespace: ns.Name,
+ Version: 1,
+ Chart: chartMock,
+ Status: helmrelease.StatusDeployed,
+ }, testutil.ReleaseWithConfig(nil))
+ valChecksum := chartutil.DigestValues("sha1", rls.Config)
+
+ obj := &v2.HelmRelease{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "adopt-release",
+ Namespace: ns.Name,
+ },
+ Spec: v2.HelmReleaseSpec{
+ StorageNamespace: ns.Name,
+ },
+ Status: v2.HelmReleaseStatus{
+ HelmChart: chart.Namespace + "/" + chart.Name,
+ LastReleaseRevision: rls.Version,
+ LastAttemptedValuesChecksum: valChecksum.Hex(),
+ },
+ }
+
+ c := fake.NewClientBuilder().
+ WithScheme(NewTestScheme()).
+ WithObjects(chart).
+ Build()
+
+ r := &HelmReleaseReconciler{
+ Client: c,
+ GetClusterConfig: GetTestClusterConfig,
+ EventRecorder: record.NewFakeRecorder(32),
+ httpClient: retryablehttp.NewClient(),
+ }
+
+ // Store the Helm release mock in the test namespace.
+ getter, err := r.buildRESTClientGetter(context.TODO(), obj)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ cfg, err := action.NewConfigFactory(getter, action.WithStorage(helmdriver.SecretsDriverName, obj.GetStorageNamespace()))
+ g.Expect(err).ToNot(HaveOccurred())
+
+ store := helmstorage.Init(cfg.Driver)
+ g.Expect(store.Create(rls)).To(Succeed())
+
+ // Reconcile the Helm release.
+ _, err = r.reconcileRelease(context.TODO(), nil, obj)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ // Assert that the Helm release has been adopted.
+ g.Expect(obj.Status.History).To(testutil.Equal(v2.Snapshots{
+ release.ObservedToSnapshot(release.ObserveRelease(rls)),
+ }))
+ g.Expect(obj.Status.StorageNamespace).To(Equal(ns.Name))
+ g.Expect(obj.Status.LastAttemptedConfigDigest).ToNot(BeEmpty())
+ g.Expect(obj.Status.LastReleaseRevision).To(Equal(0))
+ })
+
t.Run("uninstalls HelmRelease if target has changed", func(t *testing.T) {
g := NewWithT(t)
@@ -607,7 +714,7 @@ func TestHelmReleaseReconciler_reconcileRelease(t *testing.T) {
Spec: v2.HelmReleaseSpec{
// Trigger a failure by setting an invalid storage namespace,
// preventing the release from actually being installed.
- // This allows us to just test the , without
+ // This allows us to just test the values being set, without
// having to facilitate a full install.
StorageNamespace: "not-exist",
Values: &apiextensionsv1.JSON{
@@ -681,7 +788,7 @@ func TestHelmReleaseReconciler_reconcileDelete(t *testing.T) {
// Create a test Helm release storage mock.
rls := testutil.BuildRelease(&helmrelease.MockReleaseOptions{
Name: "reconcile-delete",
- Namespace: ns.Namespace,
+ Namespace: ns.Name,
Version: 1,
Chart: testutil.BuildChart(testutil.ChartWithTestHook()),
Status: helmrelease.StatusDeployed,
@@ -791,7 +898,7 @@ func TestHelmReleaseReconciler_reconileReleaseDeletion(t *testing.T) {
// Create a test Helm release storage mock.
rls := testutil.BuildRelease(&helmrelease.MockReleaseOptions{
Name: "reconcile-delete",
- Namespace: ns.Namespace,
+ Namespace: ns.Name,
Version: 1,
Chart: testutil.BuildChart(testutil.ChartWithTestHook()),
Status: helmrelease.StatusDeployed,
@@ -853,7 +960,7 @@ func TestHelmReleaseReconciler_reconileReleaseDeletion(t *testing.T) {
// Create a test Helm release storage mock.
rls := testutil.BuildRelease(&helmrelease.MockReleaseOptions{
Name: "reconcile-delete",
- Namespace: ns.Namespace,
+ Namespace: ns.Name,
Version: 1,
Chart: testutil.BuildChart(testutil.ChartWithTestHook()),
Status: helmrelease.StatusDeployed,
@@ -953,7 +1060,7 @@ func TestHelmReleaseReconciler_reconileReleaseDeletion(t *testing.T) {
// Create a test Helm release storage mock.
rls := testutil.BuildRelease(&helmrelease.MockReleaseOptions{
Name: "reconcile-delete",
- Namespace: ns.Namespace,
+ Namespace: ns.Name,
Version: 1,
Chart: testutil.BuildChart(testutil.ChartWithTestHook()),
Status: helmrelease.StatusDeployed,
@@ -1404,6 +1511,174 @@ func TestHelmReleaseReconciler_checkDependencies(t *testing.T) {
}
}
+func TestHelmReleaseReconciler_adoptLegacyRelease(t *testing.T) {
+ tests := []struct {
+ name string
+ releases func(namespace string) []*helmrelease.Release
+ spec func(spec *v2.HelmReleaseSpec)
+ status v2.HelmReleaseStatus
+ expectHistory func(releases []*helmrelease.Release) v2.Snapshots
+ expectLastReleaseRevision int
+ wantErr bool
+ }{
+ {
+ name: "adopts last release revision",
+ releases: func(namespace string) []*helmrelease.Release {
+ return []*helmrelease.Release{
+ testutil.BuildRelease(&helmrelease.MockReleaseOptions{
+ Name: "orphaned",
+ Namespace: namespace,
+ Version: 6,
+ Chart: testutil.BuildChart(),
+ Status: helmrelease.StatusDeployed,
+ }, testutil.ReleaseWithTestHook()),
+ }
+ },
+ spec: func(spec *v2.HelmReleaseSpec) {
+ spec.ReleaseName = "orphaned"
+ },
+ status: v2.HelmReleaseStatus{
+ LastReleaseRevision: 6,
+ },
+ expectHistory: func(releases []*helmrelease.Release) v2.Snapshots {
+ return v2.Snapshots{
+ release.ObservedToSnapshot(release.ObserveRelease(releases[0])),
+ }
+ },
+ expectLastReleaseRevision: 0,
+ },
+ {
+ name: "includes test hooks if enabled",
+ releases: func(namespace string) []*helmrelease.Release {
+ return []*helmrelease.Release{
+ testutil.BuildRelease(&helmrelease.MockReleaseOptions{
+ Name: "orphaned-with-hooks",
+ Namespace: namespace,
+ Version: 3,
+ Chart: testutil.BuildChart(testutil.ChartWithTestHook()),
+ Status: helmrelease.StatusDeployed,
+ }, testutil.ReleaseWithTestHook()),
+ }
+ },
+ spec: func(spec *v2.HelmReleaseSpec) {
+ spec.ReleaseName = "orphaned-with-hooks"
+ spec.Test = &v2.Test{
+ Enable: true,
+ }
+ },
+ status: v2.HelmReleaseStatus{
+ LastReleaseRevision: 3,
+ },
+ expectHistory: func(releases []*helmrelease.Release) v2.Snapshots {
+ snap := release.ObservedToSnapshot(release.ObserveRelease(releases[0]))
+ snap.SetTestHooks(release.TestHooksFromRelease(releases[0]))
+
+ return v2.Snapshots{
+ snap,
+ }
+ },
+ expectLastReleaseRevision: 0,
+ },
+ {
+ name: "non-existing release",
+ spec: func(spec *v2.HelmReleaseSpec) {
+ spec.ReleaseName = "non-existing"
+ },
+ status: v2.HelmReleaseStatus{
+ LastReleaseRevision: 2,
+ },
+ expectLastReleaseRevision: 2,
+ wantErr: true,
+ },
+ {
+ name: "without last release revision",
+ status: v2.HelmReleaseStatus{
+ LastReleaseRevision: 0,
+ },
+ expectHistory: func(releases []*helmrelease.Release) v2.Snapshots {
+ return nil
+ },
+ expectLastReleaseRevision: 0,
+ },
+ {
+ name: "with existing history",
+ status: v2.HelmReleaseStatus{
+ History: v2.Snapshots{
+ {
+ Name: "something",
+ },
+ },
+ LastReleaseRevision: 5,
+ },
+ expectHistory: func(releases []*helmrelease.Release) v2.Snapshots {
+ return v2.Snapshots{
+ {
+ Name: "something",
+ },
+ }
+ },
+ expectLastReleaseRevision: 5,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ g := NewWithT(t)
+
+ // Create a test namespace for storing the Helm release mock.
+ ns, err := testEnv.CreateNamespace(context.TODO(), "adopt-release")
+ g.Expect(err).ToNot(HaveOccurred())
+ t.Cleanup(func() {
+ _ = testEnv.Delete(context.TODO(), ns)
+ })
+
+ // Mock a HelmRelease object.
+ obj := &v2.HelmRelease{
+ Spec: v2.HelmReleaseSpec{
+ StorageNamespace: ns.Name,
+ },
+ Status: tt.status,
+ }
+ if tt.spec != nil {
+ tt.spec(&obj.Spec)
+ }
+
+ r := &HelmReleaseReconciler{
+ Client: testEnv.Client,
+ GetClusterConfig: GetTestClusterConfig,
+ }
+
+ // Store the Helm release mock in the test namespace.
+ getter, err := r.buildRESTClientGetter(context.TODO(), obj)
+ g.Expect(err).ToNot(HaveOccurred())
+
+ cfg, err := action.NewConfigFactory(getter, action.WithStorage(helmdriver.SecretsDriverName, obj.GetStorageNamespace()))
+ g.Expect(err).ToNot(HaveOccurred())
+
+ var releases []*helmrelease.Release
+ if tt.releases != nil {
+ releases = tt.releases(ns.Name)
+ }
+ store := helmstorage.Init(cfg.Driver)
+ for _, rls := range releases {
+ g.Expect(store.Create(rls)).To(Succeed())
+ }
+
+ // Adopt the Helm release mock.
+ err = r.adoptLegacyRelease(context.TODO(), getter, obj)
+ g.Expect(err != nil).To(Equal(tt.wantErr), "unexpected error: %s", err)
+
+ // Verify the Helm release mock has been adopted.
+ var expectHistory v2.Snapshots
+ if tt.expectHistory != nil {
+ expectHistory = tt.expectHistory(releases)
+ }
+ g.Expect(obj.Status.History).To(Equal(expectHistory))
+ g.Expect(obj.Status.LastReleaseRevision).To(Equal(tt.expectLastReleaseRevision))
+ })
+ }
+}
+
func TestHelmReleaseReconciler_buildRESTClientGetter(t *testing.T) {
const (
namespace = "some-namespace"
diff --git a/internal/features/features.go b/internal/features/features.go
index 43e7a4425..4d39cad0f 100644
--- a/internal/features/features.go
+++ b/internal/features/features.go
@@ -47,6 +47,13 @@ const (
// OOMWatch enables the OOM watcher, which will gracefully shut down the controller
// when the memory usage exceeds the configured limit. This is disabled by default.
OOMWatch = "OOMWatch"
+
+ // AdoptLegacyReleases enables the adoption of the historical Helm release
+ // based on the status fields from a v2beta1 HelmRelease object.
+ // This is enabled by default to support an upgrade path from v2beta1 to v2beta2
+ // without the need to upgrade the Helm release. But it can be disabled to
+ // avoid potential abuse of the adoption mechanism.
+ AdoptLegacyReleases = "AdoptLegacyReleases"
)
var features = map[string]bool{
@@ -65,6 +72,9 @@ var features = map[string]bool{
// OOMWatch
// opt-in from v0.31
OOMWatch: false,
+ // AdoptLegacyReleases
+ // opt-out from v0.37
+ AdoptLegacyReleases: true,
}
// FeatureGates contains a list of all supported feature gates and
|