Skip to content

Commit 246bc82

Browse files
authored
Merge pull request #942 from fluxcd/force-annotation
Introduce `reconcile.fluxcd.io/forceAt` annotation
2 parents 9be33b3 + 86ef319 commit 246bc82

File tree

3 files changed

+215
-4
lines changed

3 files changed

+215
-4
lines changed

apis/meta/annotations.go

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,22 @@ package meta
1818

1919
const (
2020
// ReconcileRequestAnnotation is the annotation used for triggering a reconciliation
21-
// outside of a defined schedule. The value is interpreted as a token, and any change
21+
// outside of a defined interval. The value is interpreted as a token, and any change
2222
// in value SHOULD trigger a reconciliation.
2323
ReconcileRequestAnnotation string = "reconcile.fluxcd.io/requestedAt"
24+
25+
// ForceRequestAnnotation is the annotation used for triggering a one-off forced
26+
// reconciliation, for example, of a HelmRelease when there are no new changes,
27+
// or of something that runs on a schedule when the schedule is not due at the moment.
28+
// The specific conditions for triggering a forced reconciliation depend on the
29+
// specific controller implementation, but the annotation is used to standardize
30+
// the mechanism across controllers. The value is interpreted as a token, and must
31+
// equal the value of ReconcileRequestAnnotation in order to trigger a release.
32+
ForceRequestAnnotation string = "reconcile.fluxcd.io/forceAt"
2433
)
2534

2635
// ReconcileAnnotationValue returns a value for the reconciliation request annotation, which can be used to detect
27-
// changes; and, a boolean indicating whether the annotation was set.
36+
// changes, and a boolean indicating whether the annotation was set.
2837
func ReconcileAnnotationValue(annotations map[string]string) (string, bool) {
2938
requestedAt, ok := annotations[ReconcileRequestAnnotation]
3039
return requestedAt, ok
@@ -67,3 +76,68 @@ type StatusWithHandledReconcileRequest interface {
6776
type StatusWithHandledReconcileRequestSetter interface {
6877
SetLastHandledReconcileRequest(token string)
6978
}
79+
80+
// ForceRequestStatus is a struct to embed in a status type, so that all types using the mechanism have the same
81+
// field. Use it like this:
82+
//
83+
// type FooStatus struct {
84+
// meta.ForceRequestStatus `json:",inline"`
85+
// // other status fields...
86+
// }
87+
type ForceRequestStatus struct {
88+
// LastHandledForceAt holds the value of the most recent
89+
// force request value, so a change of the annotation value
90+
// can be detected.
91+
// +optional
92+
LastHandledForceAt string `json:"lastHandledForceAt,omitempty"`
93+
}
94+
95+
// ShouldHandleForceRequest returns true if the object has a force request
96+
// annotation, and the value of the annotation matches the value of the
97+
// ReconcileRequestAnnotation annotation.
98+
//
99+
// To ensure that the force request is handled only once, the value of
100+
// <ObjectType>Status.LastHandledForceAt is updated to match the value of the
101+
// force request annotation (even if the force request is not handled because
102+
// the value of the ReconcileRequestAnnotation annotation does not match).
103+
func ShouldHandleForceRequest(obj interface {
104+
ObjectWithAnnotationRequests
105+
GetLastHandledForceRequestStatus() *string
106+
}) bool {
107+
return HandleAnnotationRequest(obj, ForceRequestAnnotation, obj.GetLastHandledForceRequestStatus())
108+
}
109+
110+
// ObjectWithAnnotationRequests is an interface that describes an object
111+
// that has annotations and a status with a last handled reconcile request.
112+
// +k8s:deepcopy-gen=false
113+
type ObjectWithAnnotationRequests interface {
114+
GetAnnotations() map[string]string
115+
StatusWithHandledReconcileRequest
116+
}
117+
118+
// HandleAnnotationRequest returns true if the object has a request annotation, and
119+
// the value of the annotation matches the value of the ReconcileRequestAnnotation
120+
// annotation.
121+
//
122+
// The lastHandled argument is used to ensure that the request is handled only
123+
// once, and is updated to match the value of the request annotation (even if
124+
// the request is not handled because the value of the ReconcileRequestAnnotation
125+
// annotation does not match).
126+
func HandleAnnotationRequest(obj ObjectWithAnnotationRequests, annotation string, lastHandled *string) bool {
127+
requestAt, requestOk := obj.GetAnnotations()[annotation]
128+
reconcileAt, reconcileOk := ReconcileAnnotationValue(obj.GetAnnotations())
129+
130+
var lastHandledRequest string
131+
if requestOk {
132+
lastHandledRequest = *lastHandled
133+
*lastHandled = requestAt
134+
}
135+
136+
if requestOk && reconcileOk && requestAt == reconcileAt {
137+
lastHandledReconcile := obj.GetLastHandledReconcileRequest()
138+
if lastHandledReconcile != reconcileAt && lastHandledRequest != requestAt {
139+
return true
140+
}
141+
}
142+
return false
143+
}

apis/meta/annotations_test.go

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,27 @@ import (
2323

2424
type whateverStatus struct {
2525
ReconcileRequestStatus `json:",inline"`
26+
ForceRequestStatus `json:",inline"`
2627
}
2728

2829
type whatever struct {
2930
Annotations map[string]string
30-
Status whateverStatus `json:"status,omitempty"`
31+
Status whateverStatus `json:"status"`
3132
}
3233

33-
func TestGetAnnotationValue(t *testing.T) {
34+
func (w *whatever) GetAnnotations() map[string]string {
35+
return w.Annotations
36+
}
37+
38+
func (w *whatever) GetLastHandledReconcileRequest() string {
39+
return w.Status.GetLastHandledReconcileRequest()
40+
}
41+
42+
func (w *whatever) GetLastHandledForceRequestStatus() *string {
43+
return &w.Status.LastHandledForceAt
44+
}
45+
46+
func TestGetReconcileAnnotationValue(t *testing.T) {
3447
obj := whatever{
3548
Annotations: map[string]string{},
3649
}
@@ -65,3 +78,112 @@ func TestGetAnnotationValue(t *testing.T) {
6578
t.Error("expected to detect change in annotation value")
6679
}
6780
}
81+
82+
func TestShouldHandleForceRequest(t *testing.T) {
83+
obj := &whatever{
84+
Annotations: map[string]string{
85+
ReconcileRequestAnnotation: "b",
86+
ForceRequestAnnotation: "b",
87+
},
88+
Status: whateverStatus{
89+
ReconcileRequestStatus: ReconcileRequestStatus{
90+
LastHandledReconcileAt: "a",
91+
},
92+
ForceRequestStatus: ForceRequestStatus{
93+
LastHandledForceAt: "a",
94+
},
95+
},
96+
}
97+
98+
if !ShouldHandleForceRequest(obj) {
99+
t.Error("ShouldHandleForceRequest() = false")
100+
}
101+
102+
if obj.Status.LastHandledForceAt != "b" {
103+
t.Error("ShouldHandleForceRequest did not update LastHandledForceAt")
104+
}
105+
}
106+
107+
func TestHandleAnnotationRequest(t *testing.T) {
108+
const requestAnnotation = "requestAnnotation"
109+
110+
tests := []struct {
111+
name string
112+
annotations map[string]string
113+
lastHandledReconcile string
114+
lastHandledRequest string
115+
want bool
116+
expectLastHandledRequest string
117+
}{
118+
{
119+
name: "valid request and reconcile annotations",
120+
annotations: map[string]string{
121+
ReconcileRequestAnnotation: "b",
122+
requestAnnotation: "b",
123+
},
124+
want: true,
125+
expectLastHandledRequest: "b",
126+
},
127+
{
128+
name: "mismatched annotations",
129+
annotations: map[string]string{
130+
ReconcileRequestAnnotation: "b",
131+
requestAnnotation: "c",
132+
},
133+
want: false,
134+
expectLastHandledRequest: "c",
135+
},
136+
{
137+
name: "reconcile matches previous request",
138+
annotations: map[string]string{
139+
ReconcileRequestAnnotation: "b",
140+
requestAnnotation: "b",
141+
},
142+
lastHandledReconcile: "a",
143+
lastHandledRequest: "b",
144+
want: false,
145+
expectLastHandledRequest: "b",
146+
},
147+
{
148+
name: "request matches previous reconcile",
149+
annotations: map[string]string{
150+
ReconcileRequestAnnotation: "b",
151+
requestAnnotation: "b",
152+
},
153+
lastHandledReconcile: "b",
154+
lastHandledRequest: "a",
155+
want: false,
156+
expectLastHandledRequest: "b",
157+
},
158+
{
159+
name: "missing annotations",
160+
annotations: map[string]string{},
161+
lastHandledRequest: "a",
162+
want: false,
163+
expectLastHandledRequest: "a",
164+
},
165+
}
166+
167+
for _, tt := range tests {
168+
t.Run(tt.name, func(t *testing.T) {
169+
obj := &whatever{
170+
Annotations: tt.annotations,
171+
Status: whateverStatus{
172+
ReconcileRequestStatus: ReconcileRequestStatus{
173+
LastHandledReconcileAt: tt.lastHandledReconcile,
174+
},
175+
},
176+
}
177+
178+
lastHandled := tt.lastHandledRequest
179+
result := HandleAnnotationRequest(obj, requestAnnotation, &lastHandled)
180+
181+
if result != tt.want {
182+
t.Errorf("HandleAnnotationRequest() = %v, want %v", result, tt.want)
183+
}
184+
if lastHandled != tt.expectLastHandledRequest {
185+
t.Errorf("lastHandledRequest = %v, want %v", lastHandled, tt.expectLastHandledRequest)
186+
}
187+
})
188+
}
189+
}

apis/meta/zz_generated.deepcopy.go

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)