AdmissionReview: Allow webhook admission to dispatch v1 or v1beta1

This commit is contained in:
Jordan Liggitt
2019-07-12 08:44:24 -04:00
parent 44930fc939
commit dda9bcb082
10 changed files with 1117 additions and 78 deletions

View File

@@ -30,7 +30,9 @@ import (
"testing"
"time"
admissionreviewv1 "k8s.io/api/admission/v1"
"k8s.io/api/admission/v1beta1"
admissionv1 "k8s.io/api/admissionregistration/v1"
admissionv1beta1 "k8s.io/api/admissionregistration/v1beta1"
appsv1beta1 "k8s.io/api/apps/v1beta1"
corev1 "k8s.io/api/core/v1"
@@ -151,6 +153,8 @@ var (
)
type webhookOptions struct {
version string
// phase indicates whether this is a mutating or validating webhook
phase string
// converted indicates if this webhook makes use of matchPolicy:equivalent and expects conversion.
@@ -165,7 +169,7 @@ type holder struct {
t *testing.T
recordGVR metav1.GroupVersionResource
recordOperation v1beta1.Operation
recordOperation string
recordNamespace string
recordName string
@@ -182,7 +186,7 @@ type holder struct {
// When a converted request is recorded, gvrToConvertedGVR[expectGVK] is compared to the GVK seen by the webhook.
gvrToConvertedGVK map[metav1.GroupVersionResource]schema.GroupVersionKind
recorded map[webhookOptions]*v1beta1.AdmissionRequest
recorded map[webhookOptions]*admissionRequest
}
func (h *holder) reset(t *testing.T) {
@@ -200,10 +204,12 @@ func (h *holder) reset(t *testing.T) {
h.expectOptions = false
// Set up the recorded map with nil records for all combinations
h.recorded = map[webhookOptions]*v1beta1.AdmissionRequest{}
h.recorded = map[webhookOptions]*admissionRequest{}
for _, phase := range []string{mutation, validation} {
for _, converted := range []bool{true, false} {
h.recorded[webhookOptions{phase: phase, converted: converted}] = nil
for _, version := range []string{"v1", "v1beta1"} {
h.recorded[webhookOptions{version: version, phase: phase, converted: converted}] = nil
}
}
}
}
@@ -217,7 +223,7 @@ func (h *holder) expect(gvr schema.GroupVersionResource, gvk, optionsGVK schema.
defer h.lock.Unlock()
h.recordGVR = metav1.GroupVersionResource{Group: gvr.Group, Version: gvr.Version, Resource: gvr.Resource}
h.expectGVK = gvk
h.recordOperation = operation
h.recordOperation = string(operation)
h.recordName = name
h.recordNamespace = namespace
h.expectObject = object
@@ -226,14 +232,28 @@ func (h *holder) expect(gvr schema.GroupVersionResource, gvk, optionsGVK schema.
h.expectOptions = options
// Set up the recorded map with nil records for all combinations
h.recorded = map[webhookOptions]*v1beta1.AdmissionRequest{}
h.recorded = map[webhookOptions]*admissionRequest{}
for _, phase := range []string{mutation, validation} {
for _, converted := range []bool{true, false} {
h.recorded[webhookOptions{phase: phase, converted: converted}] = nil
for _, version := range []string{"v1", "v1beta1"} {
h.recorded[webhookOptions{version: version, phase: phase, converted: converted}] = nil
}
}
}
}
func (h *holder) record(phase string, converted bool, request *v1beta1.AdmissionRequest) {
type admissionRequest struct {
Operation string
Resource metav1.GroupVersionResource
SubResource string
Namespace string
Name string
Object runtime.RawExtension
OldObject runtime.RawExtension
Options runtime.RawExtension
}
func (h *holder) record(version string, phase string, converted bool, request *admissionRequest) {
h.lock.Lock()
defer h.lock.Unlock()
@@ -286,9 +306,9 @@ func (h *holder) record(phase string, converted bool, request *v1beta1.Admission
}
if debug {
h.t.Logf("recording: %#v = %s %#v %v", webhookOptions{phase: phase, converted: converted}, request.Operation, request.Resource, request.SubResource)
h.t.Logf("recording: %#v = %s %#v %v", webhookOptions{version: version, phase: phase, converted: converted}, request.Operation, request.Resource, request.SubResource)
}
h.recorded[webhookOptions{phase: phase, converted: converted}] = request
h.recorded[webhookOptions{version: version, phase: phase, converted: converted}] = request
}
func (h *holder) verify(t *testing.T) {
@@ -297,12 +317,12 @@ func (h *holder) verify(t *testing.T) {
for options, value := range h.recorded {
if err := h.verifyRequest(options.converted, value); err != nil {
t.Errorf("phase:%v, converted:%v error: %v", options.phase, options.converted, err)
t.Errorf("version: %v, phase:%v, converted:%v error: %v", options.version, options.phase, options.converted, err)
}
}
}
func (h *holder) verifyRequest(converted bool, request *v1beta1.AdmissionRequest) error {
func (h *holder) verifyRequest(converted bool, request *admissionRequest) error {
// Check if current resource should be exempted from Admission processing
if admissionExemptResources[gvr(h.recordGVR.Group, h.recordGVR.Version, h.recordGVR.Resource)] {
if request == nil {
@@ -366,8 +386,8 @@ func (h *holder) verifyOptions(options runtime.Object) error {
return nil
}
// TestWebhookV1beta1 tests communication between API server and webhook process.
func TestWebhookV1beta1(t *testing.T) {
// TestWebhookAdmission tests communication between API server and webhook process.
func TestWebhookAdmission(t *testing.T) {
// holder communicates expectations to webhooks, and results from webhooks
holder := &holder{
t: t,
@@ -386,10 +406,17 @@ func TestWebhookV1beta1(t *testing.T) {
}
webhookMux := http.NewServeMux()
webhookMux.Handle("/"+mutation, newWebhookHandler(t, holder, mutation, false))
webhookMux.Handle("/convert/"+mutation, newWebhookHandler(t, holder, mutation, true))
webhookMux.Handle("/"+validation, newWebhookHandler(t, holder, validation, false))
webhookMux.Handle("/convert/"+validation, newWebhookHandler(t, holder, validation, true))
webhookMux.Handle("/v1beta1/"+mutation, newV1beta1WebhookHandler(t, holder, mutation, false))
webhookMux.Handle("/v1beta1/convert/"+mutation, newV1beta1WebhookHandler(t, holder, mutation, true))
webhookMux.Handle("/v1beta1/"+validation, newV1beta1WebhookHandler(t, holder, validation, false))
webhookMux.Handle("/v1beta1/convert/"+validation, newV1beta1WebhookHandler(t, holder, validation, true))
webhookMux.Handle("/v1/"+mutation, newV1WebhookHandler(t, holder, mutation, false))
webhookMux.Handle("/v1/convert/"+mutation, newV1WebhookHandler(t, holder, mutation, true))
webhookMux.Handle("/v1/"+validation, newV1WebhookHandler(t, holder, validation, false))
webhookMux.Handle("/v1/convert/"+validation, newV1WebhookHandler(t, holder, validation, true))
webhookMux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
holder.t.Errorf("unexpected request to %v", req.URL.Path)
}))
webhookServer := httptest.NewUnstartedServer(webhookMux)
webhookServer.TLS = &tls.Config{
RootCAs: roots,
@@ -488,7 +515,8 @@ func TestWebhookV1beta1(t *testing.T) {
// Note: this only works because there are no overlapping resource names in-process that are not co-located
convertedResources := map[string]schema.GroupVersionResource{}
// build the webhook rules enumerating the specific group/version/resources we want
convertedRules := []admissionv1beta1.RuleWithOperations{}
convertedV1beta1Rules := []admissionv1beta1.RuleWithOperations{}
convertedV1Rules := []admissionv1.RuleWithOperations{}
for _, gvr := range gvrsToTest {
metaGVR := metav1.GroupVersionResource{Group: gvr.Group, Version: gvr.Version, Resource: gvr.Resource}
@@ -499,10 +527,14 @@ func TestWebhookV1beta1(t *testing.T) {
convertedGVR = gvr
convertedResources[gvr.Resource] = gvr
// add an admission rule indicating we can receive this version
convertedRules = append(convertedRules, admissionv1beta1.RuleWithOperations{
convertedV1beta1Rules = append(convertedV1beta1Rules, admissionv1beta1.RuleWithOperations{
Operations: []admissionv1beta1.OperationType{admissionv1beta1.OperationAll},
Rule: admissionv1beta1.Rule{APIGroups: []string{gvr.Group}, APIVersions: []string{gvr.Version}, Resources: []string{gvr.Resource}},
})
convertedV1Rules = append(convertedV1Rules, admissionv1.RuleWithOperations{
Operations: []admissionv1.OperationType{admissionv1.OperationAll},
Rule: admissionv1.Rule{APIGroups: []string{gvr.Group}, APIVersions: []string{gvr.Version}, Resources: []string{gvr.Resource}},
})
}
// record the expected resource and kind
@@ -510,10 +542,16 @@ func TestWebhookV1beta1(t *testing.T) {
holder.gvrToConvertedGVK[metaGVR] = schema.GroupVersionKind{Group: resourcesByGVR[convertedGVR].Group, Version: resourcesByGVR[convertedGVR].Version, Kind: resourcesByGVR[convertedGVR].Kind}
}
if err := createV1beta1MutationWebhook(client, webhookServer.URL+"/"+mutation, webhookServer.URL+"/convert/"+mutation, convertedRules); err != nil {
if err := createV1beta1MutationWebhook(client, webhookServer.URL+"/v1beta1/"+mutation, webhookServer.URL+"/v1beta1/convert/"+mutation, convertedV1beta1Rules); err != nil {
t.Fatal(err)
}
if err := createV1beta1ValidationWebhook(client, webhookServer.URL+"/"+validation, webhookServer.URL+"/convert/"+validation, convertedRules); err != nil {
if err := createV1beta1ValidationWebhook(client, webhookServer.URL+"/v1beta1/"+validation, webhookServer.URL+"/v1beta1/convert/"+validation, convertedV1beta1Rules); err != nil {
t.Fatal(err)
}
if err := createV1MutationWebhook(client, webhookServer.URL+"/v1/"+mutation, webhookServer.URL+"/v1/convert/"+mutation, convertedV1Rules); err != nil {
t.Fatal(err)
}
if err := createV1ValidationWebhook(client, webhookServer.URL+"/v1/"+validation, webhookServer.URL+"/v1/convert/"+validation, convertedV1Rules); err != nil {
t.Fatal(err)
}
@@ -1118,7 +1156,7 @@ func testNoPruningCustomFancy(c *testContext) {
// utility methods
//
func newWebhookHandler(t *testing.T, holder *holder, phase string, converted bool) http.Handler {
func newV1beta1WebhookHandler(t *testing.T, holder *holder, phase string, converted bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
data, err := ioutil.ReadAll(r.Body)
@@ -1176,18 +1214,124 @@ func newWebhookHandler(t *testing.T, holder *holder, phase string, converted boo
if review.Request.UserInfo.Username == testClientUsername {
// only record requests originating from this integration test's client
holder.record(phase, converted, review.Request)
reviewRequest := &admissionRequest{
Operation: string(review.Request.Operation),
Resource: review.Request.Resource,
SubResource: review.Request.SubResource,
Namespace: review.Request.Namespace,
Name: review.Request.Name,
Object: review.Request.Object,
OldObject: review.Request.OldObject,
Options: review.Request.Options,
}
holder.record("v1beta1", phase, converted, reviewRequest)
}
review.Response = &v1beta1.AdmissionResponse{
Allowed: true,
Result: &metav1.Status{Message: "admitted"},
}
// v1beta1 webhook handler tolerated these not being set. verify the server continues to accept these as unset.
review.APIVersion = ""
review.Kind = ""
review.Response.UID = ""
// If we're mutating, and have an object, return a patch to exercise conversion
if phase == mutation && len(review.Request.Object.Raw) > 0 {
review.Response.Patch = []byte(`[{"op":"add","path":"/foo","value":"test"}]`)
jsonPatch := v1beta1.PatchTypeJSONPatch
review.Response.PatchType = &jsonPatch
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(review); err != nil {
t.Errorf("Marshal of response failed with error: %v", err)
}
})
}
func newV1WebhookHandler(t *testing.T, holder *holder, phase string, converted bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
data, err := ioutil.ReadAll(r.Body)
if err != nil {
t.Error(err)
return
}
if contentType := r.Header.Get("Content-Type"); contentType != "application/json" {
t.Errorf("contentType=%s, expect application/json", contentType)
return
}
review := admissionreviewv1.AdmissionReview{}
if err := json.Unmarshal(data, &review); err != nil {
t.Errorf("Fail to deserialize object: %s with error: %v", string(data), err)
http.Error(w, err.Error(), 400)
return
}
if review.GetObjectKind().GroupVersionKind() != gvk("admission.k8s.io", "v1", "AdmissionReview") {
err := fmt.Errorf("Invalid admission review kind: %#v", review.GetObjectKind().GroupVersionKind())
t.Error(err)
http.Error(w, err.Error(), 400)
return
}
if len(review.Request.Object.Raw) > 0 {
u := &unstructured.Unstructured{Object: map[string]interface{}{}}
if err := json.Unmarshal(review.Request.Object.Raw, u); err != nil {
t.Errorf("Fail to deserialize object: %s with error: %v", string(review.Request.Object.Raw), err)
http.Error(w, err.Error(), 400)
return
}
review.Request.Object.Object = u
}
if len(review.Request.OldObject.Raw) > 0 {
u := &unstructured.Unstructured{Object: map[string]interface{}{}}
if err := json.Unmarshal(review.Request.OldObject.Raw, u); err != nil {
t.Errorf("Fail to deserialize object: %s with error: %v", string(review.Request.OldObject.Raw), err)
http.Error(w, err.Error(), 400)
return
}
review.Request.OldObject.Object = u
}
if len(review.Request.Options.Raw) > 0 {
u := &unstructured.Unstructured{Object: map[string]interface{}{}}
if err := json.Unmarshal(review.Request.Options.Raw, u); err != nil {
t.Errorf("Fail to deserialize options object: %s for admission request %#+v with error: %v", string(review.Request.Options.Raw), review.Request, err)
http.Error(w, err.Error(), 400)
return
}
review.Request.Options.Object = u
}
if review.Request.UserInfo.Username == testClientUsername {
// only record requests originating from this integration test's client
reviewRequest := &admissionRequest{
Operation: string(review.Request.Operation),
Resource: review.Request.Resource,
SubResource: review.Request.SubResource,
Namespace: review.Request.Namespace,
Name: review.Request.Name,
Object: review.Request.Object,
OldObject: review.Request.OldObject,
Options: review.Request.Options,
}
holder.record("v1", phase, converted, reviewRequest)
}
review.Response = &admissionreviewv1.AdmissionResponse{
Allowed: true,
UID: review.Request.UID,
Result: &metav1.Status{Message: "admitted"},
}
// If we're mutating, and have an object, return a patch to exercise conversion
if phase == mutation && len(review.Request.Object.Raw) > 0 {
review.Response.Patch = []byte(`[{"op":"add","path":"/foo","value":"test"}]`)
jsonPatch := v1beta1.PatchTypeJSONPatch
review.Response.Patch = []byte(`[{"op":"add","path":"/bar","value":"test"}]`)
jsonPatch := admissionreviewv1.PatchTypeJSONPatch
review.Response.PatchType = &jsonPatch
}
@@ -1358,6 +1502,84 @@ func createV1beta1MutationWebhook(client clientset.Interface, endpoint, converte
return err
}
func createV1ValidationWebhook(client clientset.Interface, endpoint, convertedEndpoint string, convertedRules []admissionv1.RuleWithOperations) error {
fail := admissionv1.Fail
equivalent := admissionv1.Equivalent
none := admissionv1.SideEffectClassNone
// Attaching Admission webhook to API server
_, err := client.AdmissionregistrationV1().ValidatingWebhookConfigurations().Create(&admissionv1.ValidatingWebhookConfiguration{
ObjectMeta: metav1.ObjectMeta{Name: "admissionv1.integration.test"},
Webhooks: []admissionv1.ValidatingWebhook{
{
Name: "admissionv1.integration.test",
ClientConfig: admissionv1.WebhookClientConfig{
URL: &endpoint,
CABundle: localhostCert,
},
Rules: []admissionv1.RuleWithOperations{{
Operations: []admissionv1.OperationType{admissionv1.OperationAll},
Rule: admissionv1.Rule{APIGroups: []string{"*"}, APIVersions: []string{"*"}, Resources: []string{"*/*"}},
}},
FailurePolicy: &fail,
AdmissionReviewVersions: []string{"v1", "v1beta1"},
SideEffects: &none,
},
{
Name: "admissionv1.integration.testconversion",
ClientConfig: admissionv1.WebhookClientConfig{
URL: &convertedEndpoint,
CABundle: localhostCert,
},
Rules: convertedRules,
FailurePolicy: &fail,
MatchPolicy: &equivalent,
AdmissionReviewVersions: []string{"v1", "v1beta1"},
SideEffects: &none,
},
},
})
return err
}
func createV1MutationWebhook(client clientset.Interface, endpoint, convertedEndpoint string, convertedRules []admissionv1.RuleWithOperations) error {
fail := admissionv1.Fail
equivalent := admissionv1.Equivalent
none := admissionv1.SideEffectClassNone
// Attaching Mutation webhook to API server
_, err := client.AdmissionregistrationV1().MutatingWebhookConfigurations().Create(&admissionv1.MutatingWebhookConfiguration{
ObjectMeta: metav1.ObjectMeta{Name: "mutationv1.integration.test"},
Webhooks: []admissionv1.MutatingWebhook{
{
Name: "mutationv1.integration.test",
ClientConfig: admissionv1.WebhookClientConfig{
URL: &endpoint,
CABundle: localhostCert,
},
Rules: []admissionv1.RuleWithOperations{{
Operations: []admissionv1.OperationType{admissionv1.OperationAll},
Rule: admissionv1.Rule{APIGroups: []string{"*"}, APIVersions: []string{"*"}, Resources: []string{"*/*"}},
}},
FailurePolicy: &fail,
AdmissionReviewVersions: []string{"v1", "v1beta1"},
SideEffects: &none,
},
{
Name: "mutationv1.integration.testconversion",
ClientConfig: admissionv1.WebhookClientConfig{
URL: &convertedEndpoint,
CABundle: localhostCert,
},
Rules: convertedRules,
FailurePolicy: &fail,
MatchPolicy: &equivalent,
AdmissionReviewVersions: []string{"v1", "v1beta1"},
SideEffects: &none,
},
},
})
return err
}
// localhostCert was generated from crypto/tls/generate_cert.go with the following command:
// go run generate_cert.go --rsa-bits 512 --host 127.0.0.1,::1,example.com --ca --start-date "Jan 1 00:00:00 1970" --duration=1000000h
var localhostCert = []byte(`-----BEGIN CERTIFICATE-----