diff --git a/pkg/reconciler/reconciler.go b/pkg/reconciler/reconciler.go index 1debb28d..330e8bdd 100644 --- a/pkg/reconciler/reconciler.go +++ b/pkg/reconciler/reconciler.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "helm.sh/helm/v3/pkg/postrender" "strings" "sync" "time" @@ -86,6 +87,8 @@ type Reconciler struct { installAnnotations map[string]annotation.Install upgradeAnnotations map[string]annotation.Upgrade uninstallAnnotations map[string]annotation.Uninstall + + postRendererFn PostRendererFn } // New creates a new Reconciler that reconciles custom resources that define a @@ -482,6 +485,20 @@ func WithSelector(s metav1.LabelSelector) Option { } } +type PostRendererContext struct { + Obj *unstructured.Unstructured + Vals map[string]interface{} +} + +type PostRendererFn func(ctx context.Context, renderCtx PostRendererContext) (postrender.PostRenderer, error) + +func WithPostRenderer(f PostRendererFn) Option { + return func(r *Reconciler) error { + r.postRendererFn = f + return nil + } +} + // Reconcile reconciles a CR that defines a Helm v3 release. // // - If a release does not exist for this CR, a new release is installed. @@ -580,7 +597,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (res ctrl. return ctrl.Result{}, err } - rel, state, err := r.getReleaseState(actionClient, obj, vals.AsMap()) + rel, state, err := r.getReleaseState(ctx, actionClient, obj, vals.AsMap()) if err != nil { u.UpdateStatus( updater.EnsureCondition(conditions.Irreconcilable(corev1.ConditionTrue, conditions.ReasonErrorGettingReleaseState, err)), @@ -600,13 +617,13 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (res ctrl. switch state { case stateNeedsInstall: - rel, err = r.doInstall(actionClient, &u, obj, vals.AsMap(), log) + rel, err = r.doInstall(ctx, actionClient, &u, obj, vals.AsMap(), log) if err != nil { return ctrl.Result{}, err } case stateNeedsUpgrade: - rel, err = r.doUpgrade(actionClient, &u, obj, vals.AsMap(), log) + rel, err = r.doUpgrade(ctx, actionClient, &u, obj, vals.AsMap(), log) if err != nil { return ctrl.Result{}, err } @@ -694,7 +711,7 @@ func (r *Reconciler) handleDeletion(ctx context.Context, actionClient helmclient return nil } -func (r *Reconciler) getReleaseState(client helmclient.ActionInterface, obj metav1.Object, vals map[string]interface{}) (*release.Release, helmReleaseState, error) { +func (r *Reconciler) getReleaseState(ctx context.Context, client helmclient.ActionInterface, obj *unstructured.Unstructured, vals map[string]interface{}) (*release.Release, helmReleaseState, error) { currentRelease, err := client.Get(obj.GetName()) if err != nil && !errors.Is(err, driver.ErrReleaseNotFound) { return nil, stateError, err @@ -720,6 +737,12 @@ func (r *Reconciler) getReleaseState(client helmclient.ActionInterface, obj meta u.DryRun = true return nil }) + + opts, err = r.appendUpgradePostRendererOption(ctx, obj, opts, vals) + if err != nil { + return nil, stateError, err + } + specRelease, err := client.Upgrade(obj.GetName(), obj.GetNamespace(), r.chrt, vals, opts...) if err != nil { return currentRelease, stateError, err @@ -732,13 +755,20 @@ func (r *Reconciler) getReleaseState(client helmclient.ActionInterface, obj meta return currentRelease, stateUnchanged, nil } -func (r *Reconciler) doInstall(actionClient helmclient.ActionInterface, u *updater.Updater, obj *unstructured.Unstructured, vals map[string]interface{}, log logr.Logger) (*release.Release, error) { +func (r *Reconciler) doInstall(ctx context.Context, actionClient helmclient.ActionInterface, u *updater.Updater, obj *unstructured.Unstructured, vals map[string]interface{}, log logr.Logger) (*release.Release, error) { var opts []helmclient.InstallOption for name, annot := range r.installAnnotations { if v, ok := obj.GetAnnotations()[name]; ok { opts = append(opts, annot.InstallOption(v)) } } + + var err error + opts, err = r.appendInstallPostRendererOption(ctx, obj, opts, vals) + if err != nil { + return nil, err + } + rel, err := actionClient.Install(obj.GetName(), obj.GetNamespace(), r.chrt, vals, opts...) if err != nil { u.UpdateStatus( @@ -759,7 +789,7 @@ func (r *Reconciler) doInstall(actionClient helmclient.ActionInterface, u *updat return rel, nil } -func (r *Reconciler) doUpgrade(actionClient helmclient.ActionInterface, u *updater.Updater, obj *unstructured.Unstructured, vals map[string]interface{}, log logr.Logger) (*release.Release, error) { +func (r *Reconciler) doUpgrade(ctx context.Context, actionClient helmclient.ActionInterface, u *updater.Updater, obj *unstructured.Unstructured, vals map[string]interface{}, log logr.Logger) (*release.Release, error) { var opts []helmclient.UpgradeOption if r.maxHistory > 0 { opts = append(opts, func(u *action.Upgrade) error { @@ -779,6 +809,11 @@ func (r *Reconciler) doUpgrade(actionClient helmclient.ActionInterface, u *updat return nil, fmt.Errorf("could not get the current Helm Release: %w", err) } + opts, err = r.appendUpgradePostRendererOption(ctx, obj, opts, vals) + if err != nil { + return nil, err + } + rel, err := actionClient.Upgrade(obj.GetName(), obj.GetNamespace(), r.chrt, vals, opts...) if err != nil { u.UpdateStatus( @@ -959,3 +994,30 @@ func ensureDeployedRelease(u *updater.Updater, rel *release.Release) { updater.EnsureDeployedRelease(rel), ) } + +func (r *Reconciler) getPostRenderer(ctx context.Context, obj *unstructured.Unstructured, vals map[string]interface{}) (postrender.PostRenderer, error) { + if r.postRendererFn == nil { + return nil, nil + } + var postRendererContext = PostRendererContext{ + Obj: obj, + Vals: vals, + } + return r.postRendererFn(ctx, postRendererContext) +} + +func (r *Reconciler) appendInstallPostRendererOption(ctx context.Context, obj *unstructured.Unstructured, opts []helmclient.InstallOption, vals map[string]interface{}) ([]helmclient.InstallOption, error) { + postRenderer, err := r.getPostRenderer(ctx, obj, vals) + if err != nil || postRenderer == nil { + return opts, err + } + return append(opts, helmclient.AppendInstallPostRenderer(postRenderer)), nil +} + +func (r *Reconciler) appendUpgradePostRendererOption(ctx context.Context, obj *unstructured.Unstructured, opts []helmclient.UpgradeOption, vals map[string]interface{}) ([]helmclient.UpgradeOption, error) { + postRenderer, err := r.getPostRenderer(ctx, obj, vals) + if err != nil || postRenderer == nil { + return opts, err + } + return append(opts, helmclient.AppendUpgradePostRenderer(postRenderer)), nil +} diff --git a/pkg/reconciler/reconciler_test.go b/pkg/reconciler/reconciler_test.go index c02899de..9b207c10 100644 --- a/pkg/reconciler/reconciler_test.go +++ b/pkg/reconciler/reconciler_test.go @@ -21,6 +21,8 @@ import ( "context" "errors" "fmt" + "helm.sh/helm/v3/pkg/postrender" + "sort" "strconv" "time" @@ -441,6 +443,78 @@ var _ = Describe("Reconciler", func() { Expect(r.selectorPredicate.Generic(event.GenericEvent{Object: objUnlabeled})).To(BeFalse()) }) }) + var _ = Describe("WithPostRenderer", func() { + It("should set the reconciler post renderer", func() { + Expect(WithPostRenderer(withAnnotatingPostRenderer)(r)).To(Succeed()) + Expect(r.postRendererFn).NotTo(BeNil()) + }) + It("should unset the reconciler if nil", func() { + Expect(WithPostRenderer(nil)(r)).To(Succeed()) + Expect(r.postRendererFn).To(BeNil()) + }) + When("No PostRenderer function is configured", func() { + BeforeEach(func() { + Expect(WithPostRenderer(nil)(r)).To(Succeed()) + Expect(r.postRendererFn).To(BeNil()) + }) + It("should not append the post renderer to the upgrade options", func() { + var opts []helmclient.UpgradeOption + var obj = &unstructured.Unstructured{} + newOptions, err := r.appendUpgradePostRendererOption(context.Background(), obj, opts, map[string]interface{}{}) + Expect(err).NotTo(HaveOccurred()) + Expect(newOptions).To(HaveLen(0)) + }) + It("should not append the post renderer to the install options", func() { + var opts []helmclient.InstallOption + var obj = &unstructured.Unstructured{} + newOptions, err := r.appendInstallPostRendererOption(context.Background(), obj, opts, map[string]interface{}{}) + Expect(err).NotTo(HaveOccurred()) + Expect(newOptions).To(HaveLen(0)) + }) + }) + When("A PostRenderer function is configured", func() { + BeforeEach(func() { + Expect(WithPostRenderer(withAnnotatingPostRenderer)(r)).To(Succeed()) + Expect(r.postRendererFn).NotTo(BeNil()) + }) + It("Should return the InstallOptions with the PostRenderer", func() { + var opts []helmclient.InstallOption + var obj = &unstructured.Unstructured{} + newOptions, err := r.appendInstallPostRendererOption(context.Background(), obj, opts, map[string]interface{}{}) + Expect(err).NotTo(HaveOccurred()) + Expect(newOptions).To(HaveLen(1)) + }) + It("Should return the UpgradeOptions with the PostRenderer", func() { + var opts []helmclient.UpgradeOption + var obj = &unstructured.Unstructured{} + newOptions, err := r.appendUpgradePostRendererOption(context.Background(), obj, opts, map[string]interface{}{}) + Expect(err).NotTo(HaveOccurred()) + Expect(newOptions).To(HaveLen(1)) + }) + }) + When("A PostRenderer function is configured but returns nil", func() { + BeforeEach(func() { + Expect(WithPostRenderer(func(_ context.Context, _ PostRendererContext) (postrender.PostRenderer, error) { + return nil, nil + })(r)).To(Succeed()) + Expect(r.postRendererFn).NotTo(BeNil()) + }) + It("Should return the InstallOptions with the PostRenderer", func() { + var opts []helmclient.InstallOption + var obj = &unstructured.Unstructured{} + newOptions, err := r.appendInstallPostRendererOption(context.Background(), obj, opts, map[string]interface{}{}) + Expect(err).NotTo(HaveOccurred()) + Expect(newOptions).To(HaveLen(0)) + }) + It("Should return the UpgradeOptions with the PostRenderer", func() { + var opts []helmclient.UpgradeOption + var obj = &unstructured.Unstructured{} + newOptions, err := r.appendUpgradePostRendererOption(context.Background(), obj, opts, map[string]interface{}{}) + Expect(err).NotTo(HaveOccurred()) + Expect(newOptions).To(HaveLen(0)) + }) + }) + }) }) var _ = Describe("Reconcile", func() { @@ -514,6 +588,7 @@ var _ = Describe("Reconciler", func() { WithInstallAnnotations(annotation.InstallDescription{}), WithUpgradeAnnotations(annotation.UpgradeDescription{}), WithUninstallAnnotations(annotation.UninstallDescription{}), + WithPostRenderer(withAnnotatingPostRenderer), WithOverrideValues(map[string]string{ "image.repository": "custom-nginx", }), @@ -528,6 +603,7 @@ var _ = Describe("Reconciler", func() { WithInstallAnnotations(annotation.InstallDescription{}), WithUpgradeAnnotations(annotation.UpgradeDescription{}), WithUninstallAnnotations(annotation.UninstallDescription{}), + WithPostRenderer(withAnnotatingPostRenderer), WithOverrideValues(map[string]string{ "image.repository": "custom-nginx", }), @@ -780,6 +856,13 @@ var _ = Describe("Reconciler", func() { `Chart value "image.repository" overridden to "custom-nginx" by operator`) }) + By("verifying the postRenderer was called", func() { + objs := manifestToObjects(rel.Manifest) + for _, obj := range objs { + Expect(obj.GetAnnotations()["foo"]).To(Equal("bar")) + } + }) + By("ensuring the uninstall finalizer is present", func() { Expect(obj.GetFinalizers()).To(ContainElement(uninstallFinalizer)) }) @@ -1111,6 +1194,13 @@ var _ = Describe("Reconciler", func() { `Chart value "image.repository" overridden to "custom-nginx" by operator`) }) + By("verifying the postRenderer was called", func() { + objs := manifestToObjects(rel.Manifest) + for _, obj := range objs { + Expect(obj.GetAnnotations()["foo"]).To(Equal("bar")) + } + }) + By("ensuring the uninstall finalizer is present", func() { Expect(obj.GetFinalizers()).To(ContainElement(uninstallFinalizer)) }) @@ -1190,10 +1280,10 @@ var _ = Describe("Reconciler", func() { labels := u.GetLabels() labels["app.kubernetes.io/managed-by"] = "Unmanaged" - u.SetLabels(labels) err = mgr.GetClient().Update(ctx, u) Expect(err).To(BeNil()) + time.Sleep(1 * time.Second) } }) @@ -1348,12 +1438,20 @@ type objStatus struct { } func manifestToObjects(manifest string) []client.Object { - objs := []client.Object{} - for _, m := range releaseutil.SplitManifests(manifest) { + objMap := map[string]client.Object{} + var keys []string + for key, m := range releaseutil.SplitManifests(manifest) { u := &unstructured.Unstructured{} err := yaml.Unmarshal([]byte(m), u) Expect(err).To(BeNil()) - objs = append(objs, u) + objMap[key] = u + keys = append(keys, key) + } + // ensure that the objects are returned in a deterministic order + sort.Strings(keys) + objs := make([]client.Object, len(keys)) + for i, key := range keys { + objs[i] = objMap[key] } return objs } @@ -1459,6 +1557,39 @@ func verifyHooksCalled(ctx context.Context, r *Reconciler, req reconcile.Request }) } +type annotatingPostRenderer struct { +} + +func withAnnotatingPostRenderer(_ context.Context, _ PostRendererContext) (postrender.PostRenderer, error) { + return &annotatingPostRenderer{}, nil +} + +func (a annotatingPostRenderer) Run(renderedManifests *bytes.Buffer) (modifiedManifests *bytes.Buffer, err error) { + // Add foo=bar annotation to the rendered manifests. + objs := manifestToObjects(renderedManifests.String()) + var manifests = make([]string, len(objs)) + for i, obj := range objs { + var annotations = obj.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations["foo"] = "bar" + obj.SetAnnotations(annotations) + manifest, err := yaml.Marshal(obj) + if err != nil { + return nil, err + } + manifests[i] = string(manifest) + } + var buf bytes.Buffer + for _, manifest := range manifests { + buf.WriteString("---\n") + buf.WriteString(manifest) + } + + return &buf, nil +} + func verifyEvent(ctx context.Context, cl client.Reader, obj metav1.Object, eventType, reason, message string) { events := &v1.EventList{} Expect(cl.List(ctx, events, client.InNamespace(obj.GetNamespace()))).To(Succeed())