diff --git a/api/v2beta2/annotations.go b/api/v2beta2/annotations.go new file mode 100644 index 000000000..bcf4664be --- /dev/null +++ b/api/v2beta2/annotations.go @@ -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 +} diff --git a/api/v2beta2/annotations_test.go b/api/v2beta2/annotations_test.go new file mode 100644 index 000000000..44b593005 --- /dev/null +++ b/api/v2beta2/annotations_test.go @@ -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) + } + }) + } +} diff --git a/api/v2beta2/helmrelease_types.go b/api/v2beta2/helmrelease_types.go index 19d49937f..95fd10c8f 100644 --- a/api/v2beta2/helmrelease_types.go +++ b/api/v2beta2/helmrelease_types.go @@ -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"` } @@ -1225,6 +1235,22 @@ func (in *HelmRelease) GetStatusConditions() *[]metav1.Condition { return &in.Status.Conditions } +// ForceAnnotationValue returns a value for the reset annotation, which can be +// used to detect changes; and, a boolean indicating whether the annotation was +// set. +func (in HelmRelease) ForceAnnotationValue() (string, bool) { + forcedAt, ok := in.GetAnnotations()[ForceRequestAnnotation] + return forcedAt, ok +} + +// ResetAnnotationValue returns a value for the reset annotation, which can be +// used to detect changes; and, a boolean indicating whether the annotation was +// set. +func (in HelmRelease) ResetAnnotationValue() (string, bool) { + resetAt, ok := in.GetAnnotations()[ResetRequestAnnotation] + return resetAt, ok +} + // +kubebuilder:object:root=true // HelmReleaseList contains a list of HelmRelease objects. diff --git a/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml b/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml index 24ae9cc4e..fd970a64c 100644 --- a/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml +++ b/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml @@ -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.' diff --git a/docs/api/v2beta2/helm.md b/docs/api/v2beta2/helm.md index 49216b3a8..3a62206c3 100644 --- a/docs/api/v2beta2/helm.md +++ b/docs/api/v2beta2/helm.md @@ -1528,6 +1528,32 @@ string +lastHandledForceAt
+ +string + + + +(Optional) +

LastHandledForceAt holds the value of the most recent force request +value, so a change of the annotation value can be detected.

+ + + + +lastHandledResetAt
+ +string + + + +(Optional) +

LastHandledResetAt holds the value of the most recent reset request +value, so a change of the annotation value can be detected.

+ + + + ReconcileRequestStatus