Skip to content

Commit 38aebfc

Browse files
committed
Implement forceAt and resetAt annotations
This makes the controller actually take the `reconcile.fluxcd.io/forceAt` and `reconcile.fluxcd.io/resetAt` into account. For `reconcile.fluxcd.io/resetAt`, this means that the failure counts on the `HelmRelease` object are reset when the token value of the annotation equals `reconcile.fluxcd.io/requestedAt`. Allowing the controller to start over with attempting to install or upgrade the release until the retries count has been reached again. For `reconcile.fluxcd.io/forceAt`, this means that a one-off Helm install or upgrade is allowed to take place even if the object is out of retries, in a failed state where it should be remediated, or in-sync. Signed-off-by: Hidde Beydals <[email protected]>
1 parent 6f593c9 commit 38aebfc

File tree

5 files changed

+146
-9
lines changed

5 files changed

+146
-9
lines changed

internal/action/reset.go

+12
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const (
2929
differentGenerationReason = "generation differs from last attempt"
3030
differentRevisionReason = "chart version differs from last attempt"
3131
differentValuesReason = "values differ from last attempt"
32+
resetRequestedReason = "reset requested through annotation"
3233
)
3334

3435
// MustResetFailures returns a reason and true if the HelmRelease's status
@@ -38,6 +39,12 @@ const (
3839
// For example, a change in generation, chart version, or values.
3940
// If no change is detected, an empty string is returned along with false.
4041
func MustResetFailures(obj *v2.HelmRelease, chart *chart.Metadata, values chartutil.Values) (string, bool) {
42+
// Always check if a reset is requested.
43+
// This is done first, so that the HelmReleaseStatus.LastHandledResetAt
44+
// field is updated even if the reset request is not handled due to other
45+
// diverging data.
46+
resetRequested := v2.ShouldHandleResetRequest(obj)
47+
4148
switch {
4249
case obj.Status.LastAttemptedGeneration != obj.Generation:
4350
return differentGenerationReason, true
@@ -53,5 +60,10 @@ func MustResetFailures(obj *v2.HelmRelease, chart *chart.Metadata, values chartu
5360
return differentValuesReason, true
5461
}
5562
}
63+
64+
if resetRequested {
65+
return resetRequestedReason, true
66+
}
67+
5668
return "", false
5769
}

internal/action/reset_test.go

+27
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import (
2424
"helm.sh/helm/v3/pkg/chartutil"
2525
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2626

27+
"github.com/fluxcd/pkg/apis/meta"
28+
2729
v2 "github.com/fluxcd/helm-controller/api/v2beta2"
2830
)
2931

@@ -108,6 +110,31 @@ func TestMustResetFailures(t *testing.T) {
108110
want: true,
109111
wantReason: differentValuesReason,
110112
},
113+
{
114+
name: "on reset request through annotation",
115+
obj: &v2.HelmRelease{
116+
ObjectMeta: metav1.ObjectMeta{
117+
Generation: 1,
118+
Annotations: map[string]string{
119+
meta.ReconcileRequestAnnotation: "a",
120+
v2.ResetRequestAnnotation: "a",
121+
},
122+
},
123+
Status: v2.HelmReleaseStatus{
124+
LastAttemptedGeneration: 1,
125+
LastAttemptedRevision: "1.0.0",
126+
LastAttemptedConfigDigest: "sha256:1dabc4e3cbbd6a0818bd460f3a6c9855bfe95d506c74726bc0f2edb0aecb1f4e",
127+
},
128+
},
129+
chart: &chart.Metadata{
130+
Version: "1.0.0",
131+
},
132+
values: chartutil.Values{
133+
"foo": "bar",
134+
},
135+
want: true,
136+
wantReason: resetRequestedReason,
137+
},
111138
{
112139
name: "without change no reset",
113140
obj: &v2.HelmRelease{

internal/controller/helmrelease_controller.go

+4
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,10 @@ func (r *HelmReleaseReconciler) Reconcile(ctx context.Context, req ctrl.Request)
154154

155155
// Always attempt to patch the object after each reconciliation.
156156
defer func() {
157+
if v, ok := meta.ReconcileAnnotationValue(obj.GetAnnotations()); ok {
158+
obj.Status.SetLastHandledReconcileRequest(v)
159+
}
160+
157161
patchOpts := []patch.Option{
158162
patch.WithFieldOwner(r.FieldManager),
159163
patch.WithOwnedConditions{Conditions: intreconcile.OwnedConditions},

internal/reconcile/atomic_release.go

+30-1
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ func (r *AtomicRelease) Reconcile(ctx context.Context, req *Request) error {
174174
}
175175
return fmt.Errorf("atomic release canceled: %w", ctx.Err())
176176
default:
177-
// Determine the next action to run based on the current state.
177+
// Determine the current state of the Helm release.
178178
log.V(logger.DebugLevel).Info("determining current state of Helm release")
179179
state, err := DetermineReleaseState(ctx, r.configFactory, req)
180180
if err != nil {
@@ -272,6 +272,13 @@ func (r *AtomicRelease) Reconcile(ctx context.Context, req *Request) error {
272272
func (r *AtomicRelease) actionForState(ctx context.Context, req *Request, state ReleaseState) (ActionReconciler, error) {
273273
log := ctrl.LoggerFrom(ctx)
274274

275+
// Determine whether we may need to force a release action.
276+
// We do this before determining the next action to run, as otherwise we may
277+
// end up running a Helm upgrade (due to e.g. ReleaseStatusUnmanaged) and
278+
// then forcing an upgrade (due to the release now being in
279+
// ReleaseStatusInSync with a yet unhandled force request).
280+
forceRequested := v2.ShouldHandleForceRequest(req.Object)
281+
275282
switch state.Status {
276283
case ReleaseStatusInSync:
277284
log.Info("release in-sync with desired state")
@@ -290,6 +297,11 @@ func (r *AtomicRelease) actionForState(ctx context.Context, req *Request, state
290297
// field, but should be removed in a future release.
291298
req.Object.Status.LastAppliedRevision = req.Object.Status.History.Latest().ChartVersion
292299

300+
if forceRequested {
301+
log.Info(msgWithReason("forcing upgrade for in-sync release", "force requested through annotation"))
302+
return NewUpgrade(r.configFactory, r.eventRecorder), nil
303+
}
304+
293305
return nil, nil
294306
case ReleaseStatusLocked:
295307
log.Info(msgWithReason("release locked", state.Reason))
@@ -298,6 +310,11 @@ func (r *AtomicRelease) actionForState(ctx context.Context, req *Request, state
298310
log.Info(msgWithReason("release not installed", state.Reason))
299311

300312
if req.Object.GetInstall().GetRemediation().RetriesExhausted(req.Object) {
313+
if forceRequested {
314+
log.Info(msgWithReason("forcing install while out of retries", "force requested through annotation"))
315+
return NewInstall(r.configFactory, r.eventRecorder), nil
316+
}
317+
301318
return nil, fmt.Errorf("%w: cannot install release", ErrExceededMaxRetries)
302319
}
303320

@@ -313,6 +330,11 @@ func (r *AtomicRelease) actionForState(ctx context.Context, req *Request, state
313330
log.Info(msgWithReason("release out-of-sync with desired state", state.Reason))
314331

315332
if req.Object.GetUpgrade().GetRemediation().RetriesExhausted(req.Object) {
333+
if forceRequested {
334+
log.Info(msgWithReason("forcing upgrade while out of retries", "force requested through annotation"))
335+
return NewUpgrade(r.configFactory, r.eventRecorder), nil
336+
}
337+
316338
return nil, fmt.Errorf("%w: cannot upgrade release", ErrExceededMaxRetries)
317339
}
318340

@@ -360,6 +382,13 @@ func (r *AtomicRelease) actionForState(ctx context.Context, req *Request, state
360382
return NewUpgrade(r.configFactory, r.eventRecorder), nil
361383
}
362384

385+
// If the force annotation is set, we can attempt to upgrade the release
386+
// without any further checks.
387+
if forceRequested {
388+
log.Info(msgWithReason("forcing upgrade for failed release", "force requested through annotation"))
389+
return NewUpgrade(r.configFactory, r.eventRecorder), nil
390+
}
391+
363392
// We have exhausted the number of retries for the remediation
364393
// strategy.
365394
if remediation.RetriesExhausted(req.Object) && !remediation.MustRemediateLastFailure() {

internal/reconcile/atomic_release_test.go

+73-8
Original file line numberDiff line numberDiff line change
@@ -1015,14 +1015,15 @@ func TestAtomicRelease_Reconcile_Scenarios(t *testing.T) {
10151015

10161016
func TestAtomicRelease_actionForState(t *testing.T) {
10171017
tests := []struct {
1018-
name string
1019-
releases []*helmrelease.Release
1020-
spec func(spec *v2.HelmReleaseSpec)
1021-
status func(releases []*helmrelease.Release) v2.HelmReleaseStatus
1022-
state ReleaseState
1023-
want ActionReconciler
1024-
wantEvent *corev1.Event
1025-
wantErr error
1018+
name string
1019+
releases []*helmrelease.Release
1020+
annotations map[string]string
1021+
spec func(spec *v2.HelmReleaseSpec)
1022+
status func(releases []*helmrelease.Release) v2.HelmReleaseStatus
1023+
state ReleaseState
1024+
want ActionReconciler
1025+
wantEvent *corev1.Event
1026+
wantErr error
10261027
}{
10271028
{
10281029
name: "in-sync release does not trigger any action",
@@ -1036,6 +1037,22 @@ func TestAtomicRelease_actionForState(t *testing.T) {
10361037
state: ReleaseState{Status: ReleaseStatusInSync},
10371038
want: nil,
10381039
},
1040+
{
1041+
name: "in-sync release with force annotation triggers upgrade action",
1042+
state: ReleaseState{Status: ReleaseStatusInSync},
1043+
annotations: map[string]string{
1044+
meta.ReconcileRequestAnnotation: "force",
1045+
v2.ForceRequestAnnotation: "force",
1046+
},
1047+
status: func(releases []*helmrelease.Release) v2.HelmReleaseStatus {
1048+
return v2.HelmReleaseStatus{
1049+
History: v2.Snapshots{
1050+
{Version: 1},
1051+
},
1052+
}
1053+
},
1054+
want: &Upgrade{},
1055+
},
10391056
{
10401057
name: "locked release triggers unlock action",
10411058
state: ReleaseState{Status: ReleaseStatusLocked},
@@ -1046,6 +1063,20 @@ func TestAtomicRelease_actionForState(t *testing.T) {
10461063
state: ReleaseState{Status: ReleaseStatusAbsent},
10471064
want: &Install{},
10481065
},
1066+
{
1067+
name: "absent release without remaining retries and force annotation triggers install",
1068+
annotations: map[string]string{
1069+
meta.ReconcileRequestAnnotation: "force",
1070+
v2.ForceRequestAnnotation: "force",
1071+
},
1072+
state: ReleaseState{Status: ReleaseStatusAbsent},
1073+
status: func(releases []*helmrelease.Release) v2.HelmReleaseStatus {
1074+
return v2.HelmReleaseStatus{
1075+
InstallFailures: 1,
1076+
}
1077+
},
1078+
want: &Install{},
1079+
},
10491080
{
10501081
name: "absent release without remaining retries returns error",
10511082
state: ReleaseState{Status: ReleaseStatusAbsent},
@@ -1159,6 +1190,22 @@ func TestAtomicRelease_actionForState(t *testing.T) {
11591190
},
11601191
want: &Upgrade{},
11611192
},
1193+
{
1194+
name: "out-of-sync release with no remaining retries and force annotation triggers upgrade",
1195+
state: ReleaseState{
1196+
Status: ReleaseStatusOutOfSync,
1197+
},
1198+
annotations: map[string]string{
1199+
meta.ReconcileRequestAnnotation: "force",
1200+
v2.ForceRequestAnnotation: "force",
1201+
},
1202+
status: func(releases []*helmrelease.Release) v2.HelmReleaseStatus {
1203+
return v2.HelmReleaseStatus{
1204+
UpgradeFailures: 1,
1205+
}
1206+
},
1207+
want: &Upgrade{},
1208+
},
11621209
{
11631210
name: "out-of-sync release with no remaining retries returns error",
11641211
state: ReleaseState{
@@ -1198,6 +1245,21 @@ func TestAtomicRelease_actionForState(t *testing.T) {
11981245
},
11991246
want: &Upgrade{},
12001247
},
1248+
{
1249+
name: "failed release with exhausted retries and force annotation triggers upgrade",
1250+
state: ReleaseState{Status: ReleaseStatusFailed},
1251+
annotations: map[string]string{
1252+
meta.ReconcileRequestAnnotation: "force",
1253+
v2.ForceRequestAnnotation: "force",
1254+
},
1255+
status: func(releases []*helmrelease.Release) v2.HelmReleaseStatus {
1256+
return v2.HelmReleaseStatus{
1257+
LastAttemptedReleaseAction: v2.ReleaseActionUpgrade,
1258+
UpgradeFailures: 1,
1259+
}
1260+
},
1261+
want: &Upgrade{},
1262+
},
12011263
{
12021264
name: "failed release with exhausted retries returns error",
12031265
state: ReleaseState{Status: ReleaseStatusFailed},
@@ -1348,6 +1410,9 @@ func TestAtomicRelease_actionForState(t *testing.T) {
13481410
g := NewWithT(t)
13491411

13501412
obj := &v2.HelmRelease{
1413+
ObjectMeta: metav1.ObjectMeta{
1414+
Annotations: tt.annotations,
1415+
},
13511416
Spec: v2.HelmReleaseSpec{
13521417
ReleaseName: mockReleaseName,
13531418
TargetNamespace: mockReleaseNamespace,

0 commit comments

Comments
 (0)