Skip to content

Commit a40fd92

Browse files
committed
feat: Add preflight checks framework
1 parent 7511f49 commit a40fd92

File tree

4 files changed

+166
-0
lines changed

4 files changed

+166
-0
lines changed

charts/cluster-api-runtime-extensions-nutanix/templates/webhooks.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,23 @@ webhooks:
5656
resources:
5757
- clusters
5858
sideEffects: None
59+
- admissionReviewVersions:
60+
- v1
61+
clientConfig:
62+
service:
63+
name: '{{ include "chart.name" . }}-admission'
64+
namespace: '{{ .Release.Namespace }}'
65+
path: /preflight-v1beta1
66+
failurePolicy: Fail
67+
name: preflight.caren.nutanix.com
68+
rules:
69+
- apiGroups:
70+
- cluster.x-k8s.io
71+
apiVersions:
72+
- '*'
73+
operations:
74+
- CREATE
75+
- UPDATE
76+
resources:
77+
- clusters
78+
sideEffects: None

cmd/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import (
4141
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/nutanix"
4242
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/options"
4343
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/cluster"
44+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/preflight"
4445
)
4546

4647
func main() {
@@ -219,6 +220,13 @@ func main() {
219220
Handler: cluster.NewValidator(mgr.GetClient(), admission.NewDecoder(mgr.GetScheme())),
220221
})
221222

223+
mgr.GetWebhookServer().Register("/preflight-v1beta1", &webhook.Admission{
224+
Handler: preflight.New(mgr.GetClient(), admission.NewDecoder(mgr.GetScheme()),
225+
[]preflight.Checker{
226+
// Add your preflight checkers here.
227+
}...,
228+
),
229+
})
222230
if err := mgr.Start(signalCtx); err != nil {
223231
setupLog.Error(err, "unable to start controller manager")
224232
os.Exit(1)

pkg/webhook/preflight/doc.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Copyright 2025 Nutanix. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package preflight
4+
5+
// +kubebuilder:webhook:path=/preflight-v1beta1,mutating=false,failurePolicy=fail,groups="cluster.x-k8s.io",resources=clusters,verbs=create;update,versions=*,name=preflight.caren.nutanix.com,admissionReviewVersions=v1,sideEffects=None

pkg/webhook/preflight/preflight.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright 2025 Nutanix. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package preflight
4+
5+
import (
6+
"context"
7+
"net/http"
8+
9+
admissionv1 "k8s.io/api/admission/v1"
10+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11+
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
12+
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
13+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
14+
15+
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/common/pkg/capi/utils"
16+
)
17+
18+
type CheckResult struct {
19+
Allowed bool
20+
Field string
21+
Message string
22+
Warning string
23+
Error bool
24+
}
25+
26+
type Check = func(ctx context.Context) CheckResult
27+
28+
type Checker interface {
29+
Checks(ctx context.Context, client ctrlclient.Client, cluster *clusterv1.Cluster) ([]Check, error)
30+
Provider() string
31+
}
32+
33+
type WebhookHandler struct {
34+
client ctrlclient.Client
35+
decoder admission.Decoder
36+
checkersByProvider map[string]Checker
37+
}
38+
39+
func New(client ctrlclient.Client, decoder admission.Decoder, checkers ...Checker) *WebhookHandler {
40+
h := &WebhookHandler{
41+
client: client,
42+
decoder: decoder,
43+
checkersByProvider: make(map[string]Checker, len(checkers)),
44+
}
45+
for _, checker := range checkers {
46+
h.checkersByProvider[checker.Provider()] = checker
47+
}
48+
return h
49+
}
50+
51+
func (h *WebhookHandler) Handle(ctx context.Context, req admission.Request) admission.Response {
52+
if req.Operation == admissionv1.Delete {
53+
return admission.Allowed("")
54+
}
55+
56+
cluster := &clusterv1.Cluster{}
57+
err := h.decoder.Decode(req, cluster)
58+
if err != nil {
59+
return admission.Errored(http.StatusBadRequest, err)
60+
}
61+
62+
// Checks run only for ClusterClass-based clusters.
63+
if cluster.Spec.Topology == nil {
64+
return admission.Allowed("")
65+
}
66+
67+
// Checks run only for the known infrastructure providers.
68+
checker, ok := h.checkersByProvider[utils.GetProvider(cluster)]
69+
if !ok {
70+
return admission.Allowed("")
71+
}
72+
73+
resp := admission.Response{
74+
AdmissionResponse: admissionv1.AdmissionResponse{
75+
Allowed: true,
76+
Result: &metav1.Status{
77+
Details: &metav1.StatusDetails{},
78+
},
79+
},
80+
}
81+
82+
checks, err := checker.Checks(ctx, h.client, cluster)
83+
if err != nil {
84+
resp.Allowed = false
85+
resp.Result.Code = http.StatusInternalServerError
86+
resp.Result.Message = "failed to initialize preflight checks"
87+
resp.Result.Details.Causes = append(resp.Result.Details.Causes, metav1.StatusCause{
88+
Type: metav1.CauseTypeInternal,
89+
Message: err.Error(),
90+
Field: "", // This concerns the whole cluster.
91+
})
92+
return resp
93+
}
94+
95+
if len(checks) == 0 {
96+
return admission.Allowed("")
97+
}
98+
99+
// Run all checks and collect results.
100+
// TODO Parallelize checks.
101+
for _, check := range checks {
102+
result := check(ctx)
103+
104+
if result.Error {
105+
resp.Allowed = false
106+
resp.Result.Code = http.StatusForbidden
107+
resp.Result.Message = "preflight checks failed"
108+
resp.Result.Details.Causes = append(resp.Result.Details.Causes, metav1.StatusCause{
109+
Type: metav1.CauseTypeInternal,
110+
Field: result.Field,
111+
Message: result.Message,
112+
})
113+
continue
114+
}
115+
116+
if !result.Allowed {
117+
resp.Allowed = false
118+
resp.Result.Code = http.StatusForbidden
119+
resp.Result.Message = "preflight checks failed"
120+
resp.Result.Details.Causes = append(resp.Result.Details.Causes, metav1.StatusCause{
121+
Type: metav1.CauseTypeFieldValueInvalid,
122+
Field: result.Field,
123+
Message: result.Message,
124+
})
125+
}
126+
127+
if result.Warning != "" {
128+
resp.Warnings = append(resp.Warnings, result.Warning)
129+
}
130+
}
131+
132+
return resp
133+
}

0 commit comments

Comments
 (0)