From 2a5a56cc32d4e1eb6fcbe2e4edeb1d29f45336cc Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Wed, 29 Nov 2023 10:14:03 +0100 Subject: [PATCH] Roughly implement `forceAt` and `resetAt` Signed-off-by: Hidde Beydals --- internal/action/reset.go | 12 +++++++ internal/action/reset_test.go | 26 ++++++++++++++++ internal/controller/helmrelease_controller.go | 4 +++ internal/reconcile/atomic_release.go | 31 ++++++++++++++++++- 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/internal/action/reset.go b/internal/action/reset.go index da0caf69f..a293b7386 100644 --- a/internal/action/reset.go +++ b/internal/action/reset.go @@ -29,6 +29,7 @@ const ( differentGenerationReason = "generation differs from last attempt" differentRevisionReason = "chart version differs from last attempt" differentValuesReason = "values differ from last attempt" + resetRequestedReason = "reset requested through annotation" ) // MustResetFailures returns a reason and true if the HelmRelease's status @@ -38,6 +39,12 @@ const ( // For example, a change in generation, chart version, or values. // If no change is detected, an empty string is returned along with false. func MustResetFailures(obj *v2.HelmRelease, chart *chart.Metadata, values chartutil.Values) (string, bool) { + // Always check if a reset is requested. + // This is done first, so that the HelmReleaseStatus.LastHandledResetAt + // field is updated even if the reset request is not handled due to other + // diverging data. + resetRequested := v2.ShouldHandleResetRequest(obj) + switch { case obj.Status.LastAttemptedGeneration != obj.Generation: return differentGenerationReason, true @@ -53,5 +60,10 @@ func MustResetFailures(obj *v2.HelmRelease, chart *chart.Metadata, values chartu return differentValuesReason, true } } + + if resetRequested { + return resetRequestedReason, true + } + return "", false } diff --git a/internal/action/reset_test.go b/internal/action/reset_test.go index 465df0e81..17ba75bb0 100644 --- a/internal/action/reset_test.go +++ b/internal/action/reset_test.go @@ -19,6 +19,7 @@ package action import ( "testing" + "github.com/fluxcd/pkg/apis/meta" . "github.com/onsi/gomega" "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/chartutil" @@ -108,6 +109,31 @@ func TestMustResetFailures(t *testing.T) { want: true, wantReason: differentValuesReason, }, + { + name: "on reset request through annotation", + obj: &v2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + Annotations: map[string]string{ + meta.ReconcileRequestAnnotation: "a", + v2.ResetRequestAnnotation: "a", + }, + }, + Status: v2.HelmReleaseStatus{ + LastAttemptedGeneration: 1, + LastAttemptedRevision: "1.0.0", + LastAttemptedConfigDigest: "sha256:1dabc4e3cbbd6a0818bd460f3a6c9855bfe95d506c74726bc0f2edb0aecb1f4e", + }, + }, + chart: &chart.Metadata{ + Version: "1.0.0", + }, + values: chartutil.Values{ + "foo": "bar", + }, + want: true, + wantReason: resetRequestedReason, + }, { name: "without change no reset", obj: &v2.HelmRelease{ diff --git a/internal/controller/helmrelease_controller.go b/internal/controller/helmrelease_controller.go index 1c3c8f6da..d10911653 100644 --- a/internal/controller/helmrelease_controller.go +++ b/internal/controller/helmrelease_controller.go @@ -154,6 +154,10 @@ func (r *HelmReleaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) // Always attempt to patch the object after each reconciliation. defer func() { + if v, ok := meta.ReconcileAnnotationValue(obj.GetAnnotations()); ok { + obj.Status.SetLastHandledReconcileRequest(v) + } + patchOpts := []patch.Option{ patch.WithFieldOwner(r.FieldManager), patch.WithOwnedConditions{Conditions: intreconcile.OwnedConditions}, diff --git a/internal/reconcile/atomic_release.go b/internal/reconcile/atomic_release.go index 3949cc0b2..3e1f4660e 100644 --- a/internal/reconcile/atomic_release.go +++ b/internal/reconcile/atomic_release.go @@ -174,7 +174,7 @@ func (r *AtomicRelease) Reconcile(ctx context.Context, req *Request) error { } return fmt.Errorf("atomic release canceled: %w", ctx.Err()) default: - // Determine the next action to run based on the current state. + // Determine the current state of the Helm release. log.V(logger.DebugLevel).Info("determining current state of Helm release") state, err := DetermineReleaseState(ctx, r.configFactory, req) if err != nil { @@ -272,6 +272,13 @@ func (r *AtomicRelease) Reconcile(ctx context.Context, req *Request) error { func (r *AtomicRelease) actionForState(ctx context.Context, req *Request, state ReleaseState) (ActionReconciler, error) { log := ctrl.LoggerFrom(ctx) + // Determine whether we may need to force a release action. + // We do this before determining the next action to run, as otherwise we may + // end up running a Helm upgrade (due to e.g. ReleaseStatusUnmanaged) and + // then forcing an upgrade (due to the release now being in + // ReleaseStatusInSync with a yet unhandled force request). + forceRequested := v2.ShouldHandleForceRequest(req.Object) + switch state.Status { case ReleaseStatusInSync: log.Info("release in-sync with desired state") @@ -290,6 +297,11 @@ func (r *AtomicRelease) actionForState(ctx context.Context, req *Request, state // field, but should be removed in a future release. req.Object.Status.LastAppliedRevision = req.Object.Status.History.Latest().ChartVersion + if forceRequested { + log.Info(msgWithReason("forcing upgrade for in-sync release", "force requested through annotation")) + return NewUpgrade(r.configFactory, r.eventRecorder), nil + } + return nil, nil case ReleaseStatusLocked: log.Info(msgWithReason("release locked", state.Reason)) @@ -298,6 +310,11 @@ func (r *AtomicRelease) actionForState(ctx context.Context, req *Request, state log.Info(msgWithReason("release not installed", state.Reason)) if req.Object.GetInstall().GetRemediation().RetriesExhausted(req.Object) { + if forceRequested { + log.Info(msgWithReason("forcing install while out of retries", "force requested through annotation")) + return NewInstall(r.configFactory, r.eventRecorder), nil + } + return nil, fmt.Errorf("%w: cannot install release", ErrExceededMaxRetries) } @@ -313,6 +330,11 @@ func (r *AtomicRelease) actionForState(ctx context.Context, req *Request, state log.Info(msgWithReason("release out-of-sync with desired state", state.Reason)) if req.Object.GetUpgrade().GetRemediation().RetriesExhausted(req.Object) { + if forceRequested { + log.Info(msgWithReason("forcing upgrade while out of retries", "force requested through annotation")) + return NewInstall(r.configFactory, r.eventRecorder), nil + } + return nil, fmt.Errorf("%w: cannot upgrade release", ErrExceededMaxRetries) } @@ -360,6 +382,13 @@ func (r *AtomicRelease) actionForState(ctx context.Context, req *Request, state return NewUpgrade(r.configFactory, r.eventRecorder), nil } + // If the force annotation is set, we can attempt to upgrade the release + // without any further checks. + if forceRequested { + log.Info(msgWithReason("forcing upgrade for failed release", "force requested through annotation")) + return NewUpgrade(r.configFactory, r.eventRecorder), nil + } + // We have exhausted the number of retries for the remediation // strategy. if remediation.RetriesExhausted(req.Object) && !remediation.MustRemediateLastFailure() {