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.

+lastReleaseRevision
+ +int + + + +(Optional) +

LastReleaseRevision is the revision of the last successful Helm release. +Deprecated: Use History 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