Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce forceAt and resetAt annotations #823

Merged
merged 2 commits into from
Dec 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions api/v2beta2/annotations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
Copyright 2023 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package v2beta2

import "github.com/fluxcd/pkg/apis/meta"

const (
// ForceRequestAnnotation is the annotation used for triggering a one-off forced
// Helm release, even when there are no new changes in the HelmRelease.
// The value is interpreted as a token, and must equal the value of
// meta.ReconcileRequestAnnotation in order to trigger a release.
ForceRequestAnnotation string = "reconcile.fluxcd.io/forceAt"

// ResetRequestAnnotation is the annotation used for resetting the failure counts
// of a HelmRelease, so that it can be retried again.
// The value is interpreted as a token, and must equal the value of
// meta.ReconcileRequestAnnotation in order to reset the failure counts.
ResetRequestAnnotation string = "reconcile.fluxcd.io/resetAt"
)

// ShouldHandleResetRequest returns true if the HelmRelease has a reset request
// annotation, and the value of the annotation matches the value of the
// meta.ReconcileRequestAnnotation annotation.
//
// To ensure that the reset request is handled only once, the value of
// HelmReleaseStatus.LastHandledResetAt is updated to match the value of the
// reset request annotation (even if the reset request is not handled because
// the value of the meta.ReconcileRequestAnnotation annotation does not match).
func ShouldHandleResetRequest(obj *HelmRelease) bool {
return handleRequest(obj, ResetRequestAnnotation, &obj.Status.LastHandledResetAt)
}

// ShouldHandleForceRequest returns true if the HelmRelease has a force request
// annotation, and the value of the annotation matches the value of the
// meta.ReconcileRequestAnnotation annotation.
//
// To ensure that the force request is handled only once, the value of
// HelmReleaseStatus.LastHandledForceAt is updated to match the value of the
// force request annotation (even if the force request is not handled because
// the value of the meta.ReconcileRequestAnnotation annotation does not match).
func ShouldHandleForceRequest(obj *HelmRelease) bool {
return handleRequest(obj, ForceRequestAnnotation, &obj.Status.LastHandledForceAt)
}

// handleRequest returns true if the HelmRelease has a request annotation, and
// the value of the annotation matches the value of the meta.ReconcileRequestAnnotation
// annotation.
//
// The lastHandled argument is used to ensure that the request is handled only
// once, and is updated to match the value of the request annotation (even if
// the request is not handled because the value of the meta.ReconcileRequestAnnotation
// annotation does not match).
func handleRequest(obj *HelmRelease, annotation string, lastHandled *string) bool {
requestAt, requestOk := obj.GetAnnotations()[annotation]
reconcileAt, reconcileOk := meta.ReconcileAnnotationValue(obj.GetAnnotations())

var lastHandledRequest string
if requestOk {
lastHandledRequest = *lastHandled
*lastHandled = requestAt
}

if requestOk && reconcileOk && requestAt == reconcileAt {
lastHandledReconcile := obj.Status.GetLastHandledReconcileRequest()
if lastHandledReconcile != reconcileAt && lastHandledRequest != requestAt {
return true
}
}
return false
}
165 changes: 165 additions & 0 deletions api/v2beta2/annotations_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/*
Copyright 2023 The Flux authors

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package v2beta2

import (
"testing"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/fluxcd/pkg/apis/meta"
)

func TestShouldHandleResetRequest(t *testing.T) {
t.Run("should handle reset request", func(t *testing.T) {
obj := &HelmRelease{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
meta.ReconcileRequestAnnotation: "b",
ResetRequestAnnotation: "b",
},
},
Status: HelmReleaseStatus{
LastHandledResetAt: "a",
ReconcileRequestStatus: meta.ReconcileRequestStatus{
LastHandledReconcileAt: "a",
},
},
}

if !ShouldHandleResetRequest(obj) {
t.Error("ShouldHandleResetRequest() = false")
}

if obj.Status.LastHandledResetAt != "b" {
t.Error("ShouldHandleResetRequest did not update LastHandledResetAt")
}
})
}

func TestShouldHandleForceRequest(t *testing.T) {
t.Run("should handle force request", func(t *testing.T) {
obj := &HelmRelease{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
meta.ReconcileRequestAnnotation: "b",
ForceRequestAnnotation: "b",
},
},
Status: HelmReleaseStatus{
LastHandledForceAt: "a",
ReconcileRequestStatus: meta.ReconcileRequestStatus{
LastHandledReconcileAt: "a",
},
},
}

if !ShouldHandleForceRequest(obj) {
t.Error("ShouldHandleForceRequest() = false")
}

if obj.Status.LastHandledForceAt != "b" {
t.Error("ShouldHandleForceRequest did not update LastHandledForceAt")
}
})
}

func Test_handleRequest(t *testing.T) {
const requestAnnotation = "requestAnnotation"

tests := []struct {
name string
annotations map[string]string
lastHandledReconcile string
lastHandledRequest string
want bool
expectLastHandledRequest string
}{
{
name: "valid request and reconcile annotations",
annotations: map[string]string{
meta.ReconcileRequestAnnotation: "b",
requestAnnotation: "b",
},
want: true,
expectLastHandledRequest: "b",
},
{
name: "mismatched annotations",
annotations: map[string]string{
meta.ReconcileRequestAnnotation: "b",
requestAnnotation: "c",
},
want: false,
expectLastHandledRequest: "c",
},
{
name: "reconcile matches previous request",
annotations: map[string]string{
meta.ReconcileRequestAnnotation: "b",
requestAnnotation: "b",
},
lastHandledReconcile: "a",
lastHandledRequest: "b",
want: false,
expectLastHandledRequest: "b",
},
{
name: "request matches previous reconcile",
annotations: map[string]string{
meta.ReconcileRequestAnnotation: "b",
requestAnnotation: "b",
},
lastHandledReconcile: "b",
lastHandledRequest: "a",
want: false,
expectLastHandledRequest: "b",
},
{
name: "missing annotations",
annotations: map[string]string{},
lastHandledRequest: "a",
want: false,
expectLastHandledRequest: "a",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
obj := &HelmRelease{
ObjectMeta: metav1.ObjectMeta{
Annotations: tt.annotations,
},
Status: HelmReleaseStatus{
ReconcileRequestStatus: meta.ReconcileRequestStatus{
LastHandledReconcileAt: tt.lastHandledReconcile,
},
},
}

lastHandled := tt.lastHandledRequest
result := handleRequest(obj, requestAnnotation, &lastHandled)

if result != tt.want {
t.Errorf("handleRequest() = %v, want %v", result, tt.want)
}
if lastHandled != tt.expectLastHandledRequest {
t.Errorf("lastHandledRequest = %v, want %v", lastHandled, tt.expectLastHandledRequest)
}
})
}
}
10 changes: 10 additions & 0 deletions api/v2beta2/helmrelease_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,16 @@ type HelmReleaseStatus struct {
// +optional
LastAttemptedConfigDigest string `json:"lastAttemptedConfigDigest,omitempty"`

// LastHandledForceAt holds the value of the most recent force request
// value, so a change of the annotation value can be detected.
// +optional
LastHandledForceAt string `json:"lastHandledForceAt,omitempty"`

// LastHandledResetAt holds the value of the most recent reset request
// value, so a change of the annotation value can be detected.
// +optional
LastHandledResetAt string `json:"lastHandledResetAt,omitempty"`

meta.ReconcileRequestStatus `json:",inline"`
}

Expand Down
10 changes: 10 additions & 0 deletions config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2126,11 +2126,21 @@ spec:
the values of the last reconciliation attempt. Deprecated: Use LastAttemptedConfigDigest
instead.'
type: string
lastHandledForceAt:
description: LastHandledForceAt holds the value of the most recent
force request value, so a change of the annotation value can be
detected.
type: string
lastHandledReconcileAt:
description: LastHandledReconcileAt holds the value of the most recent
reconcile request value, so a change of the annotation value can
be detected.
type: string
lastHandledResetAt:
description: LastHandledResetAt holds the value of the most recent
reset 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.'
Expand Down
26 changes: 26 additions & 0 deletions docs/api/v2beta2/helm.md
Original file line number Diff line number Diff line change
Expand Up @@ -1528,6 +1528,32 @@ string
</tr>
<tr>
<td>
<code>lastHandledForceAt</code><br>
<em>
string
</em>
</td>
<td>
<em>(Optional)</em>
<p>LastHandledForceAt holds the value of the most recent force request
value, so a change of the annotation value can be detected.</p>
</td>
</tr>
<tr>
<td>
<code>lastHandledResetAt</code><br>
<em>
string
</em>
</td>
<td>
<em>(Optional)</em>
<p>LastHandledResetAt holds the value of the most recent reset request
value, so a change of the annotation value can be detected.</p>
</td>
</tr>
<tr>
<td>
<code>ReconcileRequestStatus</code><br>
<em>
<a href="https://godoc.org/github.com/fluxcd/pkg/apis/meta#ReconcileRequestStatus">
Expand Down
12 changes: 12 additions & 0 deletions internal/action/reset.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -53,5 +60,10 @@ func MustResetFailures(obj *v2.HelmRelease, chart *chart.Metadata, values chartu
return differentValuesReason, true
}
}

if resetRequested {
return resetRequestedReason, true
}

return "", false
}
27 changes: 27 additions & 0 deletions internal/action/reset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import (
"helm.sh/helm/v3/pkg/chartutil"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/fluxcd/pkg/apis/meta"

v2 "github.com/fluxcd/helm-controller/api/v2beta2"
)

Expand Down Expand Up @@ -108,6 +110,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{
Expand Down
Loading