Skip to content

Commit

Permalink
This creates a way for clients of the webhook to decorate the request…
Browse files Browse the repository at this point in the history
… context. (#342)

Clients of webhook can now decorate the request context with additional metadata
via:
```
  ac := &AdmissionController{
    ... // As before
    WithContext: func(ctx context.Context) context.Context {
      // logic to attach stuff to ctx
    }
  }
```

This metadata can then be accessed off of the context in methods like
`SetDefaults` and `Validate` on types registered as webhook handlers.

Fixes: #306
  • Loading branch information
mattmoor authored and knative-prow-robot committed Mar 27, 2019
1 parent 04154dd commit 0f749ef
Show file tree
Hide file tree
Showing 4 changed files with 181 additions and 23 deletions.
19 changes: 19 additions & 0 deletions testing/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ var _ apis.Listable = (*Resource)(nil)
// ResourceSpec represents test resource spec.
type ResourceSpec struct {
FieldWithDefault string `json:"fieldWithDefault,omitempty"`
FieldWithContextDefault string `json:"fieldWithContextDefault,omitempty"`
FieldWithValidation string `json:"fieldWithValidation,omitempty"`
FieldThatsImmutable string `json:"fieldThatsImmutable,omitempty"`
FieldThatsImmutableWithDefault string `json:"fieldThatsImmutableWithDefault,omitempty"`
Expand All @@ -65,11 +66,29 @@ func (c *Resource) SetDefaults(ctx context.Context) {
c.Spec.SetDefaults(ctx)
}

type onContextKey struct{}

// WithValue returns a WithContext for attaching an OnContext with the given value.
func WithValue(ctx context.Context, val string) context.Context {
return context.WithValue(ctx, onContextKey{}, &OnContext{Value: val})
}

// OnContext is a struct for holding a value attached to a context.
type OnContext struct {
Value string
}

// SetDefaults sets the defaults on the spec.
func (cs *ResourceSpec) SetDefaults(ctx context.Context) {
if cs.FieldWithDefault == "" {
cs.FieldWithDefault = "I'm a default."
}
if cs.FieldWithContextDefault == "" {
oc, ok := ctx.Value(onContextKey{}).(*OnContext)
if ok {
cs.FieldWithContextDefault = oc.Value
}
}
if cs.FieldThatsImmutableWithDefault == "" {
cs.FieldThatsImmutableWithDefault = "this is another default value"
}
Expand Down
35 changes: 23 additions & 12 deletions webhook/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,14 @@ type AdmissionController struct {
Handlers map[schema.GroupVersionKind]GenericCRD
Logger *zap.SugaredLogger

WithContext func(context.Context) context.Context
DisallowUnknownFields bool
}

func nop(ctx context.Context) context.Context {
return ctx
}

// GenericCRD is the interface definition that allows us to perform the generic
// CRD actions like deciding whether to increment generation and so forth.
type GenericCRD interface {
Expand Down Expand Up @@ -205,32 +210,32 @@ func getOrGenerateKeyCertsFromSecret(ctx context.Context, client kubernetes.Inte

// validate checks whether "new" and "old" implement HasImmutableFields and checks them,
// it then delegates validation to apis.Validatable on "new".
func validate(old GenericCRD, new GenericCRD) error {
func validate(ctx context.Context, old, new GenericCRD) error {
if immutableNew, ok := new.(apis.Immutable); ok && old != nil {
// Copy the old object and set defaults so that we don't reject our own
// defaulting done earlier in the webhook.
old = old.DeepCopyObject().(GenericCRD)
// TODO(mattmoor): Plumb through a real context
old.SetDefaults(context.TODO())
old.SetDefaults(ctx)

immutableOld, ok := old.(apis.Immutable)
if !ok {
return fmt.Errorf("unexpected type mismatch %T vs. %T", old, new)
}
// TODO(mattmoor): Plumb through a real context
if err := immutableNew.CheckImmutableFields(context.TODO(), immutableOld); err != nil {
if err := immutableNew.CheckImmutableFields(ctx, immutableOld); err != nil {
return err
}
}
// Can't just `return new.Validate()` because it doesn't properly nil-check.
// TODO(mattmoor): Plumb through a real context
if err := new.Validate(context.TODO()); err != nil {
if err := new.Validate(ctx); err != nil {
return err
}
return nil
}

func setAnnotations(patches duck.JSONPatch, new, old GenericCRD, ui *authenticationv1.UserInfo) (duck.JSONPatch, error) {
func setAnnotations(ctx context.Context, patches duck.JSONPatch, new, old GenericCRD, ui *authenticationv1.UserInfo) (duck.JSONPatch, error) {
// Nowhere to set the annotations.
if new == nil {
return patches, nil
Expand All @@ -246,7 +251,7 @@ func setAnnotations(patches duck.JSONPatch, new, old GenericCRD, ui *authenticat
b, a := new.DeepCopyObject().(apis.Annotatable), na

// TODO(mattmoor): Plumb through a real context
a.AnnotateUserInfo(context.TODO(), oa, ui)
a.AnnotateUserInfo(ctx, oa, ui)
patch, err := duck.CreatePatch(b, a)
if err != nil {
return nil, err
Expand All @@ -255,10 +260,10 @@ func setAnnotations(patches duck.JSONPatch, new, old GenericCRD, ui *authenticat
}

// setDefaults simply leverages apis.Defaultable to set defaults.
func setDefaults(patches duck.JSONPatch, crd GenericCRD) (duck.JSONPatch, error) {
func setDefaults(ctx context.Context, patches duck.JSONPatch, crd GenericCRD) (duck.JSONPatch, error) {
before, after := crd.DeepCopyObject(), crd
// TODO(mattmoor): Plumb through a real context
after.SetDefaults(context.TODO())
after.SetDefaults(ctx)

patch, err := duck.CreatePatch(before, after)
if err != nil {
Expand Down Expand Up @@ -456,7 +461,13 @@ func (ac *AdmissionController) ServeHTTP(w http.ResponseWriter, r *http.Request)
zap.String(logkey.Resource, fmt.Sprint(review.Request.Resource)),
zap.String(logkey.SubResource, fmt.Sprint(review.Request.SubResource)),
zap.String(logkey.UserInfo, fmt.Sprint(review.Request.UserInfo)))
reviewResponse := ac.admit(logging.WithLogger(r.Context(), logger), review.Request)
ctx := logging.WithLogger(r.Context(), logger)

if ac.WithContext != nil {
ctx = ac.WithContext(ctx)
}

reviewResponse := ac.admit(ctx, review.Request)
var response admissionv1beta1.AdmissionReview
if reviewResponse != nil {
response.Response = reviewResponse
Expand Down Expand Up @@ -561,14 +572,14 @@ func (ac *AdmissionController) mutate(ctx context.Context, req *admissionv1beta1
patches = append(patches, rtp...)
}

if patches, err = setDefaults(patches, newObj); err != nil {
if patches, err = setDefaults(ctx, patches, newObj); err != nil {
logger.Errorw("Failed the resource specific defaulter", zap.Error(err))
// Return the error message as-is to give the defaulter callback
// discretion over (our portion of) the message that the user sees.
return nil, err
}

if patches, err = setAnnotations(patches, newObj, oldObj, &req.UserInfo); err != nil {
if patches, err = setAnnotations(ctx, patches, newObj, oldObj, &req.UserInfo); err != nil {
logger.Errorw("Failed the resource annotator", zap.Error(err))
return nil, perrors.Wrap(err, "error setting annotations")
}
Expand All @@ -577,7 +588,7 @@ func (ac *AdmissionController) mutate(ctx context.Context, req *admissionv1beta1
if newObj == nil {
return nil, errMissingNewObject
}
if err := validate(oldObj, newObj); err != nil {
if err := validate(ctx, oldObj, newObj); err != nil {
logger.Errorw("Failed the resource specific validation", zap.Error(err))
// Return the error message as-is to give the validation callback
// discretion over (our portion of) the message that the user sees.
Expand Down
104 changes: 104 additions & 0 deletions webhook/webhook_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package webhook

import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
Expand All @@ -27,8 +28,10 @@ import (
"testing"
"time"

. "github.com/knative/pkg/testing"
"github.com/mattbaird/jsonpatch"
admissionv1beta1 "k8s.io/api/admission/v1beta1"
authenticationv1 "k8s.io/api/authentication/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

Expand Down Expand Up @@ -216,6 +219,104 @@ func TestValidResponseForResource(t *testing.T) {
}
}

func TestValidResponseForResourceWithContextDefault(t *testing.T) {
ac, serverURL, err := testSetup(t)
if err != nil {
t.Fatalf("testSetup() = %v", err)
}
theDefault := "Some default value"
ac.WithContext = func(ctx context.Context) context.Context {
return WithValue(ctx, theDefault)
}

stopCh := make(chan struct{})
defer close(stopCh)

go func() {
err := ac.Run(stopCh)
if err != nil {
t.Fatalf("Unable to run controller: %s", err)
}
}()

pollErr := waitForServerAvailable(t, serverURL, testTimeout)
if pollErr != nil {
t.Fatalf("waitForServerAvailable() = %v", err)
}
tlsClient, err := createSecureTLSClient(t, ac.Client, &ac.Options)
if err != nil {
t.Fatalf("createSecureTLSClient() = %v", err)
}

admissionreq := &admissionv1beta1.AdmissionRequest{
Operation: admissionv1beta1.Create,
Kind: metav1.GroupVersionKind{
Group: "pkg.knative.dev",
Version: "v1alpha1",
Kind: "Resource",
},
}
testRev := createResource("testrev")
marshaled, err := json.Marshal(testRev)
if err != nil {
t.Fatalf("Failed to marshal resource: %s", err)
}

admissionreq.Object.Raw = marshaled
rev := &admissionv1beta1.AdmissionReview{
Request: admissionreq,
}

reqBuf := new(bytes.Buffer)
err = json.NewEncoder(reqBuf).Encode(&rev)
if err != nil {
t.Fatalf("Failed to marshal admission review: %v", err)
}

req, err := http.NewRequest("GET", fmt.Sprintf("https://%s", serverURL), reqBuf)
if err != nil {
t.Fatalf("http.NewRequest() = %v", err)
}
req.Header.Add("Content-Type", "application/json")

response, err := tlsClient.Do(req)
if err != nil {
t.Fatalf("Failed to get response %v", err)
}

if got, want := response.StatusCode, http.StatusOK; got != want {
t.Errorf("Response status code = %v, wanted %v", got, want)
}

defer response.Body.Close()
responseBody, err := ioutil.ReadAll(response.Body)
if err != nil {
t.Fatalf("Failed to read response body %v", err)
}

reviewResponse := admissionv1beta1.AdmissionReview{}

err = json.NewDecoder(bytes.NewReader(responseBody)).Decode(&reviewResponse)
if err != nil {
t.Fatalf("Failed to decode response: %v", err)
}

expectPatches(t, reviewResponse.Response.Patch, []jsonpatch.JsonPatchOperation{{
Operation: "add",
Path: "/spec/fieldThatsImmutableWithDefault",
Value: "this is another default value",
}, {
Operation: "add",
Path: "/spec/fieldWithDefault",
Value: "I'm a default.",
}, {
Operation: "add",
Path: "/spec/fieldWithContextDefault",
Value: theDefault,
}, setUserAnnotation("", ""),
})
}

func TestInvalidResponseForResource(t *testing.T) {
ac, serverURL, err := testSetup(t)
if err != nil {
Expand Down Expand Up @@ -256,6 +357,9 @@ func TestInvalidResponseForResource(t *testing.T) {
Version: "v1alpha1",
Kind: "Resource",
},
UserInfo: authenticationv1.UserInfo{
Username: user1,
},
}

admissionreq.Object.Raw = marshaled
Expand Down
46 changes: 35 additions & 11 deletions webhook/webhook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,17 +179,41 @@ func TestValidCreateResourceSucceedsWithDefaultPatch(t *testing.T) {
_, ac := newNonRunningTestAdmissionController(t, newDefaultOptions())
resp := ac.admit(TestContextWithLogger(t), createCreateResource(r))
expectAllowed(t, resp)
expectPatches(t, resp.Patch, []jsonpatch.JsonPatchOperation{
{
Operation: "add",
Path: "/spec/fieldThatsImmutableWithDefault",
Value: "this is another default value",
}, {
Operation: "add",
Path: "/spec/fieldWithDefault",
Value: "I'm a default.",
},
setUserAnnotation(user1, user1),
expectPatches(t, resp.Patch, []jsonpatch.JsonPatchOperation{{
Operation: "add",
Path: "/spec/fieldThatsImmutableWithDefault",
Value: "this is another default value",
}, {
Operation: "add",
Path: "/spec/fieldWithDefault",
Value: "I'm a default.",
}, setUserAnnotation(user1, user1),
})
}

func TestValidCreateResourceSucceedsWithDefaultPatchWithContext(t *testing.T) {
r := createResource("a name")
_, ac := newNonRunningTestAdmissionController(t, newDefaultOptions())
ctx := TestContextWithLogger(t)

contextDefault := "I came from context y'all"
ctx = WithValue(ctx, contextDefault)

resp := ac.admit(ctx, createCreateResource(r))
expectAllowed(t, resp)
expectPatches(t, resp.Patch, []jsonpatch.JsonPatchOperation{{
Operation: "add",
Path: "/spec/fieldThatsImmutableWithDefault",
Value: "this is another default value",
}, {
Operation: "add",
Path: "/spec/fieldWithDefault",
Value: "I'm a default.",
}, {
Operation: "add",
Path: "/spec/fieldWithContextDefault",
Value: contextDefault,
}, setUserAnnotation(user1, user1),
})
}

Expand Down

0 comments on commit 0f749ef

Please sign in to comment.