Skip to content

Commit c9da77e

Browse files
committed
feat: Preflight check opt-out
1 parent a067941 commit c9da77e

File tree

4 files changed

+371
-0
lines changed

4 files changed

+371
-0
lines changed
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright 2025 Nutanix. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package optout
4+
5+
import (
6+
"strings"
7+
8+
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
9+
)
10+
11+
const (
12+
// AnnotationKey is the key of the annotation on the Cluster used to opt out preflight checks.
13+
AnnotationKey = "preflight.cluster.caren.nutanix.com/opt-out"
14+
15+
// OptOutAllChecksAnnotationValue is the value used in the cluster's annotations to indicate
16+
// that all checks are opted out.
17+
OptOutAllChecksAnnotationValue = "all"
18+
)
19+
20+
type Evaluator struct {
21+
normalizedCheckNames map[string]struct{}
22+
all bool
23+
}
24+
25+
// New creates a new Evaluator from the cluster's annotations.
26+
func New(cluster *clusterv1.Cluster) *Evaluator {
27+
o := &Evaluator{
28+
normalizedCheckNames: make(map[string]struct{}),
29+
}
30+
31+
annotations := cluster.GetAnnotations()
32+
if annotations == nil {
33+
// If there are no annotations, return an Evaluator with no prefixes.
34+
return o
35+
}
36+
37+
value, exists := annotations[AnnotationKey]
38+
if !exists {
39+
// If the annotation does not exist, return an Evaluator with no prefixes.
40+
return o
41+
}
42+
43+
for _, checkName := range strings.Split(value, ",") {
44+
if checkName == "" {
45+
// Ignore whitespace between commas.
46+
continue
47+
}
48+
normalizedCheckName := strings.TrimSpace(strings.ToLower(checkName))
49+
o.normalizedCheckNames[normalizedCheckName] = struct{}{}
50+
}
51+
if _, exists := o.normalizedCheckNames[OptOutAllChecksAnnotationValue]; exists && len(o.normalizedCheckNames) == 1 {
52+
o.all = true
53+
}
54+
return o
55+
}
56+
57+
// For checks if the cluster has opted out of a specific check.
58+
// It returns true if the cluster has opted out of the check with the given name.
59+
// The check name is case-insensitive, so "CheckName1" and "checkname1" will both match.
60+
// If the cluster has opted out of all checks, For will return true for any check name.
61+
//
62+
// For example, if the cluster has opted out of "CheckName1", then calling
63+
// For("CheckName1") or For("checkname1") will return true, but For("CheckName2") will
64+
// return false.
65+
func (o *Evaluator) For(checkName string) bool {
66+
if o.all {
67+
// If the cluster has opted out of all checks, return true for any check name.
68+
return true
69+
}
70+
normalizedCheckName := strings.TrimSpace(strings.ToLower(checkName))
71+
_, exists := o.normalizedCheckNames[normalizedCheckName]
72+
return exists
73+
}
74+
75+
// ForAll checks if the cluster has opted out of all checks.
76+
// It returns true if the cluster has a single prefix "all" in its opt-out annotations.
77+
// The check is case-insensitive, so "all", "ALL", and "All" will all match.
78+
func (o *Evaluator) ForAll() bool {
79+
return o.all
80+
}
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
// Copyright 2025 Nutanix. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package optout
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11+
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
12+
)
13+
14+
func TestNew(t *testing.T) {
15+
testCases := []struct {
16+
name string
17+
annotations map[string]string
18+
expectCheckNames map[string]struct{}
19+
}{
20+
{
21+
name: "ignores nil annotations",
22+
annotations: nil,
23+
expectCheckNames: map[string]struct{}{},
24+
},
25+
{
26+
name: "ignores empty annotations",
27+
annotations: map[string]string{},
28+
expectCheckNames: map[string]struct{}{},
29+
},
30+
{
31+
name: "ignores missing annotation key",
32+
annotations: map[string]string{
33+
"some-other-key": "value",
34+
},
35+
expectCheckNames: map[string]struct{}{},
36+
},
37+
{
38+
name: "ignores empty opt-out value",
39+
annotations: map[string]string{
40+
AnnotationKey: "",
41+
},
42+
expectCheckNames: map[string]struct{}{},
43+
},
44+
{
45+
name: "ignores empty check names",
46+
annotations: map[string]string{
47+
AnnotationKey: "InfraVMImage,,InfraCredentials,",
48+
},
49+
expectCheckNames: map[string]struct{}{
50+
"infravmimage": {},
51+
"infracredentials": {},
52+
},
53+
},
54+
{
55+
name: "ignores value of only commas",
56+
annotations: map[string]string{
57+
AnnotationKey: ",,,",
58+
},
59+
expectCheckNames: map[string]struct{}{},
60+
},
61+
{
62+
name: "accepts single check name",
63+
annotations: map[string]string{
64+
AnnotationKey: "InfraVMImage",
65+
},
66+
expectCheckNames: map[string]struct{}{
67+
"infravmimage": {},
68+
},
69+
},
70+
{
71+
name: "accepts multiple check names",
72+
annotations: map[string]string{
73+
AnnotationKey: "InfraVMImage,InfraCredentials",
74+
},
75+
expectCheckNames: map[string]struct{}{
76+
"infravmimage": {},
77+
"infracredentials": {},
78+
},
79+
},
80+
{
81+
name: "trims spaces from check names",
82+
annotations: map[string]string{
83+
AnnotationKey: " InfraVMImage , InfraCredentials ",
84+
},
85+
expectCheckNames: map[string]struct{}{
86+
"infravmimage": {},
87+
"infracredentials": {},
88+
},
89+
},
90+
}
91+
92+
for _, tc := range testCases {
93+
t.Run(tc.name, func(t *testing.T) {
94+
cluster := &clusterv1.Cluster{
95+
ObjectMeta: metav1.ObjectMeta{
96+
Annotations: tc.annotations,
97+
},
98+
}
99+
100+
evaluator := New(cluster)
101+
assert.Equal(t, tc.expectCheckNames, evaluator.normalizedCheckNames)
102+
})
103+
}
104+
}
105+
106+
func TestEvaluator_For(t *testing.T) {
107+
testCases := []struct {
108+
name string
109+
annotations map[string]string
110+
checkName string
111+
expectMatch bool
112+
}{
113+
{
114+
name: "no annotations",
115+
annotations: nil,
116+
checkName: "InfraVMImage",
117+
expectMatch: false,
118+
},
119+
{
120+
name: "no opt-out annotation",
121+
annotations: map[string]string{},
122+
checkName: "InfraVMImage",
123+
expectMatch: false,
124+
},
125+
{
126+
name: "empty annotation value",
127+
annotations: map[string]string{
128+
AnnotationKey: "",
129+
},
130+
checkName: "InfraVMImage",
131+
expectMatch: false,
132+
},
133+
{
134+
name: "opt out of InfraVMImage check",
135+
annotations: map[string]string{
136+
AnnotationKey: "InfraVMImage,InfraCredentials",
137+
},
138+
checkName: "InfraVMImage",
139+
expectMatch: true,
140+
},
141+
{
142+
name: "opt out of credentials, but not image",
143+
annotations: map[string]string{
144+
AnnotationKey: "InfraCredentials",
145+
},
146+
checkName: "InfraVMImage",
147+
expectMatch: false,
148+
},
149+
{
150+
name: "opt out with spaces and case mixing",
151+
annotations: map[string]string{
152+
AnnotationKey: " infraVMImage , InfraCredentials ",
153+
},
154+
checkName: "InfraVMImage",
155+
expectMatch: true,
156+
},
157+
{
158+
name: "extra commas do not affect matching",
159+
annotations: map[string]string{
160+
AnnotationKey: "InfraVMImage,,InfraCredentials,",
161+
},
162+
checkName: "InfraVMImage",
163+
expectMatch: true,
164+
},
165+
{
166+
name: "opt out of all checks",
167+
annotations: map[string]string{
168+
AnnotationKey: "all",
169+
},
170+
checkName: "InfraVMImage",
171+
expectMatch: true,
172+
},
173+
}
174+
175+
for _, tc := range testCases {
176+
t.Run(tc.name, func(t *testing.T) {
177+
cluster := &clusterv1.Cluster{
178+
ObjectMeta: metav1.ObjectMeta{
179+
Annotations: tc.annotations,
180+
},
181+
}
182+
183+
evaluator := New(cluster)
184+
result := evaluator.For(tc.checkName)
185+
assert.Equal(t, tc.expectMatch, result)
186+
})
187+
}
188+
}
189+
190+
func TestEvaluator_ForAll(t *testing.T) {
191+
testCases := []struct {
192+
name string
193+
annotations map[string]string
194+
expectMatch bool
195+
}{
196+
{
197+
name: "no annotations",
198+
annotations: nil,
199+
expectMatch: false,
200+
},
201+
{
202+
name: "no opt-out annotation",
203+
annotations: map[string]string{},
204+
expectMatch: false,
205+
},
206+
{
207+
name: "empty annotation value",
208+
annotations: map[string]string{
209+
AnnotationKey: "",
210+
},
211+
},
212+
{
213+
name: "opt out of all checks with spaces and case mixing",
214+
annotations: map[string]string{
215+
AnnotationKey: " aLL ",
216+
},
217+
expectMatch: true,
218+
},
219+
{
220+
name: "opt out of all checks with extra commas",
221+
annotations: map[string]string{
222+
AnnotationKey: ",all,,",
223+
},
224+
expectMatch: true,
225+
},
226+
{
227+
name: "opt out of all checks",
228+
annotations: map[string]string{
229+
AnnotationKey: "all",
230+
},
231+
expectMatch: true,
232+
},
233+
{
234+
name: "opt out of some checks, but not all",
235+
annotations: map[string]string{
236+
AnnotationKey: "OneCheck,AnotherCheck",
237+
},
238+
expectMatch: false,
239+
},
240+
}
241+
242+
for _, tc := range testCases {
243+
t.Run(tc.name, func(t *testing.T) {
244+
cluster := &clusterv1.Cluster{
245+
ObjectMeta: metav1.ObjectMeta{
246+
Annotations: tc.annotations,
247+
},
248+
}
249+
250+
evaluator := New(cluster)
251+
result := evaluator.ForAll()
252+
assert.Equal(t, tc.expectMatch, result)
253+
})
254+
}
255+
}

pkg/webhook/preflight/preflight.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import (
1515
ctrl "sigs.k8s.io/controller-runtime"
1616
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
1717
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
18+
19+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/preflight/optout"
1820
)
1921

2022
type (
@@ -93,6 +95,11 @@ func (h *WebhookHandler) Handle(ctx context.Context, req admission.Request) admi
9395
return admission.Allowed("")
9496
}
9597

98+
if optout.New(cluster).ForAll() {
99+
// If the cluster has opted out of all checks, return allowed.
100+
return admission.Allowed("Cluster has opted out of all preflight checks")
101+
}
102+
96103
resultsOrderedByCheckerAndCheck := run(ctx, h.client, cluster, h.checkers)
97104

98105
// Summarize the results.

0 commit comments

Comments
 (0)