Skip to content

Commit 6b7789a

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 <hidde@hhh.computer>
1 parent 7a15000 commit 6b7789a

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
@@ -175,7 +175,7 @@ func (r *AtomicRelease) Reconcile(ctx context.Context, req *Request) error {
175175
}
176176
return fmt.Errorf("atomic release canceled: %w", ctx.Err())
177177
default:
178-
// Determine the next action to run based on the current state.
178+
// Determine the current state of the Helm release.
179179
log.V(logger.DebugLevel).Info("determining current state of Helm release")
180180
state, err := DetermineReleaseState(ctx, r.configFactory, req)
181181
if err != nil {
@@ -273,6 +273,13 @@ func (r *AtomicRelease) Reconcile(ctx context.Context, req *Request) error {
273273
func (r *AtomicRelease) actionForState(ctx context.Context, req *Request, state ReleaseState) (ActionReconciler, error) {
274274
log := ctrl.LoggerFrom(ctx)
275275

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

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

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

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

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

@@ -367,6 +389,13 @@ func (r *AtomicRelease) actionForState(ctx context.Context, req *Request, state
367389
return NewUpgrade(r.configFactory, r.eventRecorder), nil
368390
}
369391

392+
// If the force annotation is set, we can attempt to upgrade the release
393+
// without any further checks.
394+
if forceRequested {
395+
log.Info(msgWithReason("forcing upgrade for failed release", "force requested through annotation"))
396+
return NewUpgrade(r.configFactory, r.eventRecorder), nil
397+
}
398+
370399
// We have exhausted the number of retries for the remediation
371400
// strategy.
372401
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},
@@ -1181,6 +1212,22 @@ func TestAtomicRelease_actionForState(t *testing.T) {
11811212
},
11821213
want: &Upgrade{},
11831214
},
1215+
{
1216+
name: "out-of-sync release with no remaining retries and force annotation triggers upgrade",
1217+
state: ReleaseState{
1218+
Status: ReleaseStatusOutOfSync,
1219+
},
1220+
annotations: map[string]string{
1221+
meta.ReconcileRequestAnnotation: "force",
1222+
v2.ForceRequestAnnotation: "force",
1223+
},
1224+
status: func(releases []*helmrelease.Release) v2.HelmReleaseStatus {
1225+
return v2.HelmReleaseStatus{
1226+
UpgradeFailures: 1,
1227+
}
1228+
},
1229+
want: &Upgrade{},
1230+
},
11841231
{
11851232
name: "out-of-sync release with no remaining retries returns error",
11861233
state: ReleaseState{
@@ -1220,6 +1267,21 @@ func TestAtomicRelease_actionForState(t *testing.T) {
12201267
},
12211268
want: &Upgrade{},
12221269
},
1270+
{
1271+
name: "failed release with exhausted retries and force annotation triggers upgrade",
1272+
state: ReleaseState{Status: ReleaseStatusFailed},
1273+
annotations: map[string]string{
1274+
meta.ReconcileRequestAnnotation: "force",
1275+
v2.ForceRequestAnnotation: "force",
1276+
},
1277+
status: func(releases []*helmrelease.Release) v2.HelmReleaseStatus {
1278+
return v2.HelmReleaseStatus{
1279+
LastAttemptedReleaseAction: v2.ReleaseActionUpgrade,
1280+
UpgradeFailures: 1,
1281+
}
1282+
},
1283+
want: &Upgrade{},
1284+
},
12231285
{
12241286
name: "failed release with exhausted retries returns error",
12251287
state: ReleaseState{Status: ReleaseStatusFailed},
@@ -1370,6 +1432,9 @@ func TestAtomicRelease_actionForState(t *testing.T) {
13701432
g := NewWithT(t)
13711433

13721434
obj := &v2.HelmRelease{
1435+
ObjectMeta: metav1.ObjectMeta{
1436+
Annotations: tt.annotations,
1437+
},
13731438
Spec: v2.HelmReleaseSpec{
13741439
ReleaseName: mockReleaseName,
13751440
TargetNamespace: mockReleaseNamespace,

0 commit comments

Comments
 (0)