Skip to content

Commit 7b0da50

Browse files
committed
mockkubeapiserver: Initial support for webhooks
Starting with just MutatingWebhooks
1 parent fb467ab commit 7b0da50

File tree

9 files changed

+2028
-3
lines changed

9 files changed

+2028
-3
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
1+
/*
2+
Copyright 2024 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package admissionhooks
18+
19+
import (
20+
"bytes"
21+
"context"
22+
"crypto/tls"
23+
"crypto/x509"
24+
"encoding/json"
25+
"fmt"
26+
"io"
27+
"net/http"
28+
"strings"
29+
"sync"
30+
31+
jsonpatch "github.com/evanphx/json-patch/v5"
32+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
33+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
34+
"k8s.io/apimachinery/pkg/runtime"
35+
"k8s.io/apimachinery/pkg/runtime/schema"
36+
"k8s.io/klog/v2"
37+
admissionv1 "sigs.k8s.io/kubebuilder-declarative-pattern/mockkubeapiserver/internal/api/admission/v1"
38+
admissionregistrationv1 "sigs.k8s.io/kubebuilder-declarative-pattern/mockkubeapiserver/internal/api/admissionregistration/v1"
39+
"sigs.k8s.io/kubebuilder-declarative-pattern/mockkubeapiserver/storage"
40+
)
41+
42+
// Webhooks manages our kubernetes admission webhooks (both validating and mutating)
43+
type Webhooks struct {
44+
// TODO: Replace with a copy-on-write mechanism
45+
mutex sync.Mutex
46+
mutatingByName map[string]*mutatingWebhookRecord
47+
}
48+
49+
// New constructs an instance of Webhooks.
50+
func New() *Webhooks {
51+
h := &Webhooks{}
52+
h.mutatingByName = make(map[string]*mutatingWebhookRecord)
53+
return h
54+
}
55+
56+
// OnWatchEvent is called by the storage system for any change.
57+
// We observe changes to webhook objects and set up webhooks
58+
func (s *Webhooks) OnWatchEvent(ev *storage.WatchEvent) {
59+
switch ev.GroupKind() {
60+
case schema.GroupKind{Group: "admissionregistration.k8s.io", Kind: "MutatingWebhookConfiguration"}:
61+
62+
// TODO: Deleted / changed webhooks
63+
64+
u := ev.Unstructured()
65+
66+
webhook := &admissionregistrationv1.MutatingWebhookConfiguration{}
67+
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, webhook); err != nil {
68+
klog.Fatalf("failed to parse webhook: %v", err)
69+
}
70+
71+
if err := s.update(webhook); err != nil {
72+
klog.Fatalf("failed to update webhook: %v", err)
73+
}
74+
}
75+
}
76+
77+
// update is called when a mutating webhook changes; we record the webhook details.
78+
func (w *Webhooks) update(obj *admissionregistrationv1.MutatingWebhookConfiguration) error {
79+
w.mutex.Lock()
80+
defer w.mutex.Unlock()
81+
82+
name := obj.GetName()
83+
existing := w.mutatingByName[name]
84+
if existing != nil {
85+
existing.obj = obj
86+
} else {
87+
existing = &mutatingWebhookRecord{obj: obj}
88+
w.mutatingByName[name] = existing
89+
}
90+
91+
existing.webhooks = make([]*mutatingWebhook, 0, len(obj.Webhooks))
92+
for i := range obj.Webhooks {
93+
webhookObj := &obj.Webhooks[i]
94+
existing.webhooks = append(existing.webhooks, &mutatingWebhook{webhook: webhookObj})
95+
}
96+
97+
return nil
98+
}
99+
100+
// BeforeCreate should be invoked before any object is created.
101+
// We will invoke validating and mutating webhooks on the object.
102+
func (w *Webhooks) BeforeCreate(ctx context.Context, resource storage.ResourceInfo, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) {
103+
subresource := ""
104+
gvr := resource.GVR()
105+
106+
matchingWebhooks, err := w.findMatchingWebhooks(admissionregistrationv1.Create, gvr, subresource)
107+
if err != nil {
108+
return nil, err
109+
}
110+
if len(matchingWebhooks) == 0 {
111+
return obj, nil
112+
}
113+
114+
req := newAdmissionRequest(obj, admissionv1.Create, gvr)
115+
updated, err := w.invoke(ctx, matchingWebhooks, req, obj)
116+
if err != nil {
117+
return nil, err
118+
}
119+
if updated != nil {
120+
obj = updated
121+
// TODO: Looping if object changes
122+
}
123+
return obj, nil
124+
}
125+
126+
// findMatchingWebhooks returns the webhooks that we need to call.
127+
func (w *Webhooks) findMatchingWebhooks(operation admissionregistrationv1.OperationType, gvr schema.GroupVersionResource, subresource string) ([]*mutatingWebhook, error) {
128+
w.mutex.Lock()
129+
defer w.mutex.Unlock()
130+
131+
var allMatches []*mutatingWebhook
132+
for _, webhookSet := range w.mutatingByName {
133+
for _, webhook := range webhookSet.webhooks {
134+
isMatch, err := webhook.isMatch(operation, gvr, subresource)
135+
if err != nil {
136+
return nil, err
137+
}
138+
if isMatch {
139+
allMatches = append(allMatches, webhook)
140+
}
141+
}
142+
}
143+
return allMatches, nil
144+
}
145+
146+
// invoke makes the webhook requests to a chain of webhooks.
147+
func (w *Webhooks) invoke(ctx context.Context, matches []*mutatingWebhook, req *admissionRequest, original *unstructured.Unstructured) (*unstructured.Unstructured, error) {
148+
for _, match := range matches {
149+
updated, err := match.invoke(ctx, req, original)
150+
if err != nil {
151+
return nil, err
152+
}
153+
if updated != nil {
154+
return updated, nil
155+
}
156+
}
157+
return nil, nil
158+
}
159+
160+
// mutatingWebhookRecord is our tracking data structure for a mutatingWebhook
161+
type mutatingWebhookRecord struct {
162+
obj *admissionregistrationv1.MutatingWebhookConfiguration
163+
webhooks []*mutatingWebhook
164+
}
165+
166+
type mutatingWebhook struct {
167+
webhook *admissionregistrationv1.MutatingWebhook
168+
}
169+
170+
func (w *mutatingWebhook) isMatch(operation admissionregistrationv1.OperationType, gvr schema.GroupVersionResource, subresource string) (bool, error) {
171+
webhook := w.webhook
172+
if webhook.NamespaceSelector != nil {
173+
return false, fmt.Errorf("webhook namespaceSelector not implemented")
174+
}
175+
if webhook.ObjectSelector != nil {
176+
return false, fmt.Errorf("webhook objectSelector not implemented")
177+
}
178+
if webhook.MatchPolicy != nil {
179+
return false, fmt.Errorf("webhook matchPolicy not implemented")
180+
}
181+
for _, rule := range webhook.Rules {
182+
if rule.Scope != nil {
183+
return false, fmt.Errorf("webhook scope not implemented")
184+
}
185+
186+
matchOperations := false
187+
for _, op := range rule.Operations {
188+
if op == "*" {
189+
matchOperations = true
190+
} else if op == operation {
191+
matchOperations = true
192+
}
193+
}
194+
if !matchOperations {
195+
continue
196+
}
197+
198+
matchGroup := false
199+
for _, group := range rule.APIGroups {
200+
if group == "*" {
201+
matchGroup = true
202+
} else if group == gvr.Group {
203+
matchGroup = true
204+
}
205+
}
206+
if !matchGroup {
207+
continue
208+
}
209+
matchResource := false
210+
for _, resource := range rule.Resources {
211+
tokens := strings.Split(resource, "/")
212+
if len(tokens) == 1 {
213+
if resource == "" {
214+
// Empty-string ("") means "all resources, but not subresources"
215+
matchResource = subresource == ""
216+
} else if tokens[0] == gvr.Resource {
217+
matchResource = subresource == ""
218+
}
219+
} else if len(tokens) == 2 {
220+
if resource == "/*" {
221+
// `/*` means "all resources, and their subresources"
222+
matchResource = true
223+
} else if tokens[0] == gvr.Resource {
224+
if tokens[1] == "" || tokens[1] == gvr.Resource {
225+
matchResource = true
226+
}
227+
} else if tokens[0] == "" {
228+
if tokens[1] == subresource {
229+
matchResource = true
230+
}
231+
}
232+
}
233+
}
234+
if !matchResource {
235+
continue
236+
}
237+
238+
return true, nil
239+
}
240+
241+
return false, nil
242+
}
243+
244+
// admissionRequest holds the data for an admission webhook call.
245+
type admissionRequest struct {
246+
req *admissionv1.AdmissionReview
247+
}
248+
249+
// newAdmissionRequest constructs an admissionRequest object.
250+
func newAdmissionRequest(obj *unstructured.Unstructured, op admissionv1.Operation, gvr schema.GroupVersionResource) *admissionRequest {
251+
gvk := obj.GroupVersionKind()
252+
253+
req := &admissionv1.AdmissionReview{}
254+
req.APIVersion = "admission.k8s.io/v1"
255+
req.Kind = "AdmissionReview"
256+
req.Request = &admissionv1.AdmissionRequest{}
257+
req.Request.Kind = metav1.GroupVersionKind{
258+
Group: gvk.Group,
259+
Version: gvk.Version,
260+
Kind: gvk.Kind,
261+
}
262+
req.Request.Resource = metav1.GroupVersionResource{
263+
Group: gvk.Group,
264+
Version: gvk.Version,
265+
Resource: gvr.Resource,
266+
}
267+
req.Request.Name = obj.GetName()
268+
req.Request.Namespace = obj.GetNamespace()
269+
req.Request.Operation = op
270+
req.Request.Object = runtime.RawExtension{Object: obj}
271+
272+
r := &admissionRequest{req: req}
273+
274+
return r
275+
}
276+
277+
func (r *admissionRequest) requestJSON() ([]byte, error) {
278+
body, err := json.Marshal(r.req)
279+
if err != nil {
280+
return nil, fmt.Errorf("building webhook request: %w", err)
281+
}
282+
return body, nil
283+
}
284+
285+
// invoke makes the webhook request to a specific webhook.
286+
func (c *mutatingWebhook) invoke(ctx context.Context, req *admissionRequest, u *unstructured.Unstructured) (*unstructured.Unstructured, error) {
287+
clientConfig := c.webhook.ClientConfig
288+
289+
tlsConfig := &tls.Config{}
290+
if len(clientConfig.CABundle) != 0 {
291+
caBundle := x509.NewCertPool()
292+
if ok := caBundle.AppendCertsFromPEM(clientConfig.CABundle); !ok {
293+
return nil, fmt.Errorf("no CA certificates found in caBundle")
294+
}
295+
tlsConfig.RootCAs = caBundle
296+
}
297+
298+
url := ""
299+
if clientConfig.URL != nil {
300+
url = *clientConfig.URL
301+
}
302+
if clientConfig.Service != nil {
303+
return nil, fmt.Errorf("webhook clientConfig.Service not implemented")
304+
}
305+
if url == "" {
306+
return nil, fmt.Errorf("cannot determine URL for webhook")
307+
}
308+
309+
client := http.Client{
310+
Transport: &http.Transport{
311+
TLSClientConfig: tlsConfig,
312+
},
313+
}
314+
httpRequestBody, err := req.requestJSON()
315+
if err != nil {
316+
return nil, fmt.Errorf("building webhook request: %w", err)
317+
}
318+
klog.Infof("sending webhook request: %v", string(httpRequestBody))
319+
320+
httpRequest, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(httpRequestBody))
321+
if err != nil {
322+
return nil, fmt.Errorf("building http request: %w", err)
323+
}
324+
httpRequest.Header.Set("Content-Type", "application/json")
325+
httpRequest.Header.Set("Accept", "application/json")
326+
327+
httpResponse, err := client.Do(httpRequest)
328+
if err != nil {
329+
return nil, fmt.Errorf("calling webhook: %w", err)
330+
}
331+
defer httpResponse.Body.Close()
332+
333+
if httpResponse.StatusCode != http.StatusOK {
334+
return nil, fmt.Errorf("webhook returned unexpected status %q", httpResponse.Status)
335+
}
336+
337+
httpResponseBody, err := io.ReadAll(httpResponse.Body)
338+
if err != nil {
339+
return nil, fmt.Errorf("reading webhook response body: %w", err)
340+
}
341+
342+
admissionResponse := admissionv1.AdmissionReview{}
343+
if err := json.Unmarshal(httpResponseBody, &admissionResponse); err != nil {
344+
return nil, fmt.Errorf("parsing webhook response: %w", err)
345+
}
346+
if admissionResponse.Response == nil {
347+
return nil, fmt.Errorf("webhook response is nil")
348+
}
349+
klog.Infof("admission response: %v", string(httpResponseBody))
350+
if !admissionResponse.Response.Allowed {
351+
return nil, fmt.Errorf("webhook blocked request")
352+
}
353+
354+
if admissionResponse.Response.Patch != nil {
355+
if admissionResponse.Response.PatchType == nil || *admissionResponse.Response.PatchType != admissionv1.PatchTypeJSONPatch {
356+
return nil, fmt.Errorf("unhandled webhook patchType %q", *admissionResponse.Response.PatchType)
357+
}
358+
patch, err := jsonpatch.DecodePatch(admissionResponse.Response.Patch)
359+
if err != nil {
360+
return nil, fmt.Errorf("decoding webhook patch: %w", err)
361+
}
362+
beforePatch, err := json.Marshal(u)
363+
if err != nil {
364+
return nil, fmt.Errorf("building json for object: %w", err)
365+
}
366+
afterPatch, err := patch.Apply(beforePatch)
367+
if err != nil {
368+
return nil, fmt.Errorf("applying webhook patch: %w", err)
369+
}
370+
371+
u2 := &unstructured.Unstructured{}
372+
if err := json.Unmarshal(afterPatch, u2); err != nil {
373+
return nil, fmt.Errorf("unmarshalling patched object: %w", err)
374+
}
375+
klog.Infof("after patch: %v", u2)
376+
return u2, nil
377+
}
378+
return nil, nil
379+
}

0 commit comments

Comments
 (0)