Add phase 1 of validation tightening.

https://github.com/kubernetes/enhancements/blob/master/keps/sig-storage/177-volume-snapshot/tighten-validation-webhook-crd.md

1. Ratcheting validation webhook server image
2. Controller labels invalid objects
3. Unit tests for webhook
4. Deployment README and example deployment method with certs
5. Update top-level README

Racheting validation:
1. webhook is strict on create
2. webhook is strict on updates where the existing object passes strict validation
3. webhook is relaxed on updates where the existing object fails strict validation (allows finalizer removal, status update, deletion, etc)

Additionally the validating wehook server will perform immutability
checks on scenario 2 above.
This commit is contained in:
Andi Li
2020-08-04 18:55:54 +00:00
parent db336e8070
commit 42b6b374cf
73 changed files with 12815 additions and 21 deletions

View File

@@ -0,0 +1,41 @@
/*
Copyright 2020 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package webhook
import (
"crypto/tls"
"k8s.io/klog"
)
// Config contains the server (the webhook) cert and key.
type Config struct {
CertFile string
KeyFile string
}
func configTLS(config Config) *tls.Config {
sCert, err := tls.LoadX509KeyPair(config.CertFile, config.KeyFile)
if err != nil {
klog.Fatal(err)
}
return &tls.Config{
Certificates: []tls.Certificate{sCert},
// TODO: uses mutual tls after we agree on what cert the apiserver should use.
// ClientAuth: tls.RequireAndVerifyClientCert,
}
}

View File

@@ -0,0 +1,105 @@
/*
Copyright 2020 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package webhook
import (
v1 "k8s.io/api/admission/v1"
"k8s.io/api/admission/v1beta1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func convertAdmissionRequestToV1(r *v1beta1.AdmissionRequest) *v1.AdmissionRequest {
return &v1.AdmissionRequest{
Kind: r.Kind,
Namespace: r.Namespace,
Name: r.Name,
Object: r.Object,
Resource: r.Resource,
Operation: v1.Operation(r.Operation),
UID: r.UID,
DryRun: r.DryRun,
OldObject: r.OldObject,
Options: r.Options,
RequestKind: r.RequestKind,
RequestResource: r.RequestResource,
RequestSubResource: r.RequestSubResource,
SubResource: r.SubResource,
UserInfo: r.UserInfo,
}
}
func convertAdmissionRequestToV1beta1(r *v1.AdmissionRequest) *v1beta1.AdmissionRequest {
return &v1beta1.AdmissionRequest{
Kind: r.Kind,
Namespace: r.Namespace,
Name: r.Name,
Object: r.Object,
Resource: r.Resource,
Operation: v1beta1.Operation(r.Operation),
UID: r.UID,
DryRun: r.DryRun,
OldObject: r.OldObject,
Options: r.Options,
RequestKind: r.RequestKind,
RequestResource: r.RequestResource,
RequestSubResource: r.RequestSubResource,
SubResource: r.SubResource,
UserInfo: r.UserInfo,
}
}
func convertAdmissionResponseToV1(r *v1beta1.AdmissionResponse) *v1.AdmissionResponse {
var pt *v1.PatchType
if r.PatchType != nil {
t := v1.PatchType(*r.PatchType)
pt = &t
}
return &v1.AdmissionResponse{
UID: r.UID,
Allowed: r.Allowed,
AuditAnnotations: r.AuditAnnotations,
Patch: r.Patch,
PatchType: pt,
Result: r.Result,
Warnings: r.Warnings,
}
}
func convertAdmissionResponseToV1beta1(r *v1.AdmissionResponse) *v1beta1.AdmissionResponse {
var pt *v1beta1.PatchType
if r.PatchType != nil {
t := v1beta1.PatchType(*r.PatchType)
pt = &t
}
return &v1beta1.AdmissionResponse{
UID: r.UID,
Allowed: r.Allowed,
AuditAnnotations: r.AuditAnnotations,
Patch: r.Patch,
PatchType: pt,
Result: r.Result,
Warnings: r.Warnings,
}
}
func toV1AdmissionResponse(err error) *v1.AdmissionResponse {
return &v1.AdmissionResponse{
Result: &metav1.Status{
Message: err.Error(),
},
}
}

View File

@@ -0,0 +1,64 @@
/*
Copyright 2020 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package webhook
import (
"fmt"
"math/rand"
"reflect"
"testing"
fuzz "github.com/google/gofuzz"
v1 "k8s.io/api/admission/v1"
"k8s.io/api/admission/v1beta1"
"k8s.io/apimachinery/pkg/api/apitesting/fuzzer"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/util/diff"
admissionfuzzer "k8s.io/kubernetes/pkg/apis/admission/fuzzer"
)
func TestConvertAdmissionRequestToV1(t *testing.T) {
f := fuzzer.FuzzerFor(admissionfuzzer.Funcs, rand.NewSource(rand.Int63()), serializer.NewCodecFactory(runtime.NewScheme()))
for i := 0; i < 100; i++ {
t.Run(fmt.Sprintf("Run %d/100", i), func(t *testing.T) {
orig := &v1beta1.AdmissionRequest{}
f.Fuzz(orig)
converted := convertAdmissionRequestToV1(orig)
rt := convertAdmissionRequestToV1beta1(converted)
if !reflect.DeepEqual(orig, rt) {
t.Errorf("expected all request fields to be in converted object but found unaccounted for differences, diff:\n%s", diff.ObjectReflectDiff(orig, converted))
}
})
}
}
func TestConvertAdmissionResponseToV1beta1(t *testing.T) {
f := fuzz.New()
for i := 0; i < 100; i++ {
t.Run(fmt.Sprintf("Run %d/100", i), func(t *testing.T) {
orig := &v1.AdmissionResponse{}
f.Fuzz(orig)
converted := convertAdmissionResponseToV1beta1(orig)
rt := convertAdmissionResponseToV1(converted)
if !reflect.DeepEqual(orig, rt) {
t.Errorf("expected all fields to be in converted object but found unaccounted for differences, diff:\n%s", diff.ObjectReflectDiff(orig, converted))
}
})
}
}

View File

@@ -0,0 +1,43 @@
/*
Copyright 2020 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package webhook
import (
admissionv1 "k8s.io/api/admission/v1"
admissionv1beta1 "k8s.io/api/admission/v1beta1"
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
admissionregistrationv1beta1 "k8s.io/api/admissionregistration/v1beta1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
)
var scheme = runtime.NewScheme()
var codecs = serializer.NewCodecFactory(scheme)
func init() {
addToScheme(scheme)
}
func addToScheme(scheme *runtime.Scheme) {
utilruntime.Must(corev1.AddToScheme(scheme))
utilruntime.Must(admissionv1beta1.AddToScheme(scheme))
utilruntime.Must(admissionregistrationv1beta1.AddToScheme(scheme))
utilruntime.Must(admissionv1.AddToScheme(scheme))
utilruntime.Must(admissionregistrationv1.AddToScheme(scheme))
}

View File

@@ -0,0 +1,197 @@
/*
Copyright 2020 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package webhook
import (
"fmt"
"reflect"
volumesnapshotv1beta1 "github.com/kubernetes-csi/external-snapshotter/client/v2/apis/volumesnapshot/v1beta1"
"github.com/kubernetes-csi/external-snapshotter/v2/pkg/utils"
v1 "k8s.io/api/admission/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/klog"
)
var (
// SnapshotV1Beta1GVR is GroupVersionResource for volumesnapshots
SnapshotV1Beta1GVR = metav1.GroupVersionResource{Group: volumesnapshotv1beta1.GroupName, Version: "v1beta1", Resource: "volumesnapshots"}
// SnapshotContentV1Beta1GVR is GroupVersionResource for volumesnapshotcontents
SnapshotContentV1Beta1GVR = metav1.GroupVersionResource{Group: volumesnapshotv1beta1.GroupName, Version: "v1beta1", Resource: "volumesnapshotcontents"}
)
// Add a label {"added-label": "yes"} to the object
func admitSnapshot(ar v1.AdmissionReview) *v1.AdmissionResponse {
klog.V(2).Info("admitting volumesnapshots or volumesnapshotcontents")
reviewResponse := &v1.AdmissionResponse{
Allowed: true,
Result: &metav1.Status{},
}
// Admit requests other than Update and Create
if !(ar.Request.Operation == v1.Update || ar.Request.Operation == v1.Create) {
return reviewResponse
}
isUpdate := ar.Request.Operation == v1.Update
raw := ar.Request.Object.Raw
oldRaw := ar.Request.OldObject.Raw
deserializer := codecs.UniversalDeserializer()
switch ar.Request.Resource {
case SnapshotV1Beta1GVR:
snapshot := &volumesnapshotv1beta1.VolumeSnapshot{}
if _, _, err := deserializer.Decode(raw, nil, snapshot); err != nil {
klog.Error(err)
return toV1AdmissionResponse(err)
}
oldSnapshot := &volumesnapshotv1beta1.VolumeSnapshot{}
if _, _, err := deserializer.Decode(oldRaw, nil, oldSnapshot); err != nil {
klog.Error(err)
return toV1AdmissionResponse(err)
}
return decideSnapshot(snapshot, oldSnapshot, isUpdate)
case SnapshotContentV1Beta1GVR:
snapcontent := &volumesnapshotv1beta1.VolumeSnapshotContent{}
if _, _, err := deserializer.Decode(raw, nil, snapcontent); err != nil {
klog.Error(err)
return toV1AdmissionResponse(err)
}
oldSnapcontent := &volumesnapshotv1beta1.VolumeSnapshotContent{}
if _, _, err := deserializer.Decode(oldRaw, nil, oldSnapcontent); err != nil {
klog.Error(err)
return toV1AdmissionResponse(err)
}
return decideSnapshotContent(snapcontent, oldSnapcontent, isUpdate)
default:
err := fmt.Errorf("expect resource to be %s or %s", SnapshotV1Beta1GVR, SnapshotContentV1Beta1GVR)
klog.Error(err)
return toV1AdmissionResponse(err)
}
}
func decideSnapshot(snapshot, oldSnapshot *volumesnapshotv1beta1.VolumeSnapshot, isUpdate bool) *v1.AdmissionResponse {
reviewResponse := &v1.AdmissionResponse{
Allowed: true,
Result: &metav1.Status{},
}
if isUpdate {
// if it is an UPDATE and oldSnapshot is not valid, then don't enforce strict validation
// This allows no-op updates to occur on snapshot resources which fail strict validation
// Which allows the remover of finalizers and therefore deletion of this object
// Don't rely on the pointers to be nil, because the deserialization method will convert it to
// The empty struct value. Instead check the operation type.
if err := utils.ValidateSnapshot(oldSnapshot); err != nil {
return reviewResponse
}
// if it is an UPDATE and oldSnapshot is valid, check immutable fields
if err := checkSnapshotImmutableFields(snapshot, oldSnapshot); err != nil {
reviewResponse.Allowed = false
reviewResponse.Result.Message = err.Error()
return reviewResponse
}
}
// Enforce strict validation for CREATE requests. Immutable checks don't apply for CREATE requests.
// Enforce strict validation for UPDATE requests where old is valid and passes immutability check.
if err := utils.ValidateSnapshot(snapshot); err != nil {
reviewResponse.Allowed = false
reviewResponse.Result.Message = err.Error()
}
return reviewResponse
}
func decideSnapshotContent(snapcontent, oldSnapcontent *volumesnapshotv1beta1.VolumeSnapshotContent, isUpdate bool) *v1.AdmissionResponse {
reviewResponse := &v1.AdmissionResponse{
Allowed: true,
Result: &metav1.Status{},
}
if isUpdate {
// if it is an UPDATE and oldSnapcontent is not valid, then don't enforce strict validation
// This allows no-op updates to occur on snapshot resources which fail strict validation
// Which allows the remover of finalizers and therefore deletion of this object
// Don't rely on the pointers to be nil, because the deserialization method will convert it to
// The empty struct value. Instead check the operation type.
if err := utils.ValidateSnapshotContent(oldSnapcontent); err != nil {
return reviewResponse
}
// if it is an UPDATE and oldSnapcontent is valid, check immutable fields
if err := checkSnapshotContentImmutableFields(snapcontent, oldSnapcontent); err != nil {
reviewResponse.Allowed = false
reviewResponse.Result.Message = err.Error()
return reviewResponse
}
}
// Enforce strict validation for all CREATE requests. Immutable checks don't apply for CREATE requests.
// Enforce strict validation for UPDATE requests where old is valid and passes immutability check.
if err := utils.ValidateSnapshotContent(snapcontent); err != nil {
reviewResponse.Allowed = false
reviewResponse.Result.Message = err.Error()
}
return reviewResponse
}
func strPtrDereference(s *string) string {
if s == nil {
return "<nil string pointer>"
}
return *s
}
func checkSnapshotImmutableFields(snapshot, oldSnapshot *volumesnapshotv1beta1.VolumeSnapshot) error {
if snapshot == nil {
return fmt.Errorf("VolumeSnapshot is nil")
}
if oldSnapshot == nil {
return fmt.Errorf("old VolumeSnapshot is nil")
}
source := snapshot.Spec.Source
oldSource := oldSnapshot.Spec.Source
if !reflect.DeepEqual(source.PersistentVolumeClaimName, oldSource.PersistentVolumeClaimName) {
return fmt.Errorf("Spec.Source.PersistentVolumeClaimName is immutable but was changed from %s to %s", strPtrDereference(oldSource.PersistentVolumeClaimName), strPtrDereference(source.PersistentVolumeClaimName))
}
if !reflect.DeepEqual(source.VolumeSnapshotContentName, oldSource.VolumeSnapshotContentName) {
return fmt.Errorf("Spec.Source.VolumeSnapshotContentName is immutable but was changed from %s to %s", strPtrDereference(oldSource.VolumeSnapshotContentName), strPtrDereference(source.VolumeSnapshotContentName))
}
return nil
}
func checkSnapshotContentImmutableFields(snapcontent, oldSnapcontent *volumesnapshotv1beta1.VolumeSnapshotContent) error {
if snapcontent == nil {
return fmt.Errorf("VolumeSnapshotContent is nil")
}
if oldSnapcontent == nil {
return fmt.Errorf("old VolumeSnapshotContent is nil")
}
source := snapcontent.Spec.Source
oldSource := oldSnapcontent.Spec.Source
if !reflect.DeepEqual(source.VolumeHandle, oldSource.VolumeHandle) {
return fmt.Errorf("Spec.Source.VolumeHandle is immutable but was changed from %s to %s", strPtrDereference(oldSource.VolumeHandle), strPtrDereference(source.VolumeHandle))
}
if !reflect.DeepEqual(source.SnapshotHandle, oldSource.SnapshotHandle) {
return fmt.Errorf("Spec.Source.SnapshotHandle is immutable but was changed from %s to %s", strPtrDereference(oldSource.SnapshotHandle), strPtrDereference(source.SnapshotHandle))
}
return nil
}

View File

@@ -0,0 +1,374 @@
/*
Copyright 2020 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package webhook
import (
"encoding/json"
"fmt"
"testing"
volumesnapshotv1beta1 "github.com/kubernetes-csi/external-snapshotter/client/v2/apis/volumesnapshot/v1beta1"
v1 "k8s.io/api/admission/v1"
core_v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
)
func TestAdmitVolumeSnapshot(t *testing.T) {
pvcname := "pvcname1"
mutatedField := "changed-immutable-field"
contentname := "snapcontent1"
volumeSnapshotClassName := "volume-snapshot-class-1"
emptyVolumeSnapshotClassName := ""
invalidErrorMsg := fmt.Sprintf("only one of Spec.Source.PersistentVolumeClaimName = %s and Spec.Source.VolumeSnapshotContentName = %s should be set", pvcname, contentname)
testCases := []struct {
name string
volumeSnapshot *volumesnapshotv1beta1.VolumeSnapshot
oldVolumeSnapshot *volumesnapshotv1beta1.VolumeSnapshot
shouldAdmit bool
msg string
operation v1.Operation
}{
{
name: "Delete: new and old are nil. Should admit",
volumeSnapshot: nil,
oldVolumeSnapshot: nil,
shouldAdmit: true,
operation: v1.Delete,
},
{
name: "Create: old is nil and new is invalid",
volumeSnapshot: &volumesnapshotv1beta1.VolumeSnapshot{
Spec: volumesnapshotv1beta1.VolumeSnapshotSpec{
Source: volumesnapshotv1beta1.VolumeSnapshotSource{
PersistentVolumeClaimName: &pvcname,
VolumeSnapshotContentName: &contentname,
},
},
},
oldVolumeSnapshot: nil,
shouldAdmit: false,
operation: v1.Create,
msg: invalidErrorMsg,
},
{
name: "Create: old is nil and new is valid",
volumeSnapshot: &volumesnapshotv1beta1.VolumeSnapshot{
Spec: volumesnapshotv1beta1.VolumeSnapshotSpec{
Source: volumesnapshotv1beta1.VolumeSnapshotSource{
VolumeSnapshotContentName: &contentname,
},
},
},
oldVolumeSnapshot: nil,
shouldAdmit: true,
operation: v1.Create,
},
{
name: "Update: old is valid and new is invalid",
volumeSnapshot: &volumesnapshotv1beta1.VolumeSnapshot{
Spec: volumesnapshotv1beta1.VolumeSnapshotSpec{
Source: volumesnapshotv1beta1.VolumeSnapshotSource{
VolumeSnapshotContentName: &contentname,
},
VolumeSnapshotClassName: &emptyVolumeSnapshotClassName,
},
},
oldVolumeSnapshot: &volumesnapshotv1beta1.VolumeSnapshot{
Spec: volumesnapshotv1beta1.VolumeSnapshotSpec{
Source: volumesnapshotv1beta1.VolumeSnapshotSource{
VolumeSnapshotContentName: &contentname,
},
},
},
shouldAdmit: false,
operation: v1.Update,
msg: "Spec.VolumeSnapshotClassName must not be the empty string",
},
{
name: "Update: old is valid and new is valid",
volumeSnapshot: &volumesnapshotv1beta1.VolumeSnapshot{
Spec: volumesnapshotv1beta1.VolumeSnapshotSpec{
Source: volumesnapshotv1beta1.VolumeSnapshotSource{
VolumeSnapshotContentName: &contentname,
},
VolumeSnapshotClassName: &volumeSnapshotClassName,
},
},
oldVolumeSnapshot: &volumesnapshotv1beta1.VolumeSnapshot{
Spec: volumesnapshotv1beta1.VolumeSnapshotSpec{
Source: volumesnapshotv1beta1.VolumeSnapshotSource{
VolumeSnapshotContentName: &contentname,
},
},
},
shouldAdmit: true,
operation: v1.Update,
},
{
name: "Update: old is valid and new is valid but changes immutable field spec.source",
volumeSnapshot: &volumesnapshotv1beta1.VolumeSnapshot{
Spec: volumesnapshotv1beta1.VolumeSnapshotSpec{
Source: volumesnapshotv1beta1.VolumeSnapshotSource{
VolumeSnapshotContentName: &mutatedField,
},
VolumeSnapshotClassName: &volumeSnapshotClassName,
},
},
oldVolumeSnapshot: &volumesnapshotv1beta1.VolumeSnapshot{
Spec: volumesnapshotv1beta1.VolumeSnapshotSpec{
Source: volumesnapshotv1beta1.VolumeSnapshotSource{
VolumeSnapshotContentName: &contentname,
},
},
},
shouldAdmit: false,
operation: v1.Update,
msg: fmt.Sprintf("Spec.Source.VolumeSnapshotContentName is immutable but was changed from %s to %s", contentname, mutatedField),
},
{
name: "Update: old is invalid and new is valid",
volumeSnapshot: &volumesnapshotv1beta1.VolumeSnapshot{
Spec: volumesnapshotv1beta1.VolumeSnapshotSpec{
Source: volumesnapshotv1beta1.VolumeSnapshotSource{
VolumeSnapshotContentName: &contentname,
},
},
},
oldVolumeSnapshot: &volumesnapshotv1beta1.VolumeSnapshot{
Spec: volumesnapshotv1beta1.VolumeSnapshotSpec{
Source: volumesnapshotv1beta1.VolumeSnapshotSource{
PersistentVolumeClaimName: &pvcname,
VolumeSnapshotContentName: &contentname,
},
},
},
shouldAdmit: true,
operation: v1.Update,
},
{
name: "Update: old is invalid and new is invalid",
volumeSnapshot: &volumesnapshotv1beta1.VolumeSnapshot{
Spec: volumesnapshotv1beta1.VolumeSnapshotSpec{
Source: volumesnapshotv1beta1.VolumeSnapshotSource{
VolumeSnapshotContentName: &contentname,
PersistentVolumeClaimName: &pvcname,
},
},
},
oldVolumeSnapshot: &volumesnapshotv1beta1.VolumeSnapshot{
Spec: volumesnapshotv1beta1.VolumeSnapshotSpec{
Source: volumesnapshotv1beta1.VolumeSnapshotSource{
PersistentVolumeClaimName: &pvcname,
VolumeSnapshotContentName: &contentname,
},
},
},
shouldAdmit: true,
operation: v1.Update,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
snapshot := tc.volumeSnapshot
raw, err := json.Marshal(snapshot)
if err != nil {
t.Fatal(err)
}
oldSnapshot := tc.oldVolumeSnapshot
oldRaw, err := json.Marshal(oldSnapshot)
if err != nil {
t.Fatal(err)
}
review := v1.AdmissionReview{
Request: &v1.AdmissionRequest{
Object: runtime.RawExtension{
Raw: raw,
},
OldObject: runtime.RawExtension{
Raw: oldRaw,
},
Resource: SnapshotV1Beta1GVR,
Operation: tc.operation,
},
}
response := admitSnapshot(review)
shouldAdmit := response.Allowed
msg := response.Result.Message
expectedResponse := tc.shouldAdmit
expectedMsg := tc.msg
if shouldAdmit != expectedResponse {
t.Errorf("expected \"%v\" to equal \"%v\"", shouldAdmit, expectedResponse)
}
if msg != expectedMsg {
t.Errorf("expected \"%v\" to equal \"%v\"", msg, expectedMsg)
}
})
}
}
func TestAdmitVolumeSnapshotContent(t *testing.T) {
volumeHandle := "volumeHandle1"
modifiedField := "modified-field"
snapshotHandle := "snapshotHandle1"
volumeSnapshotClassName := "volume-snapshot-class-1"
validContent := &volumesnapshotv1beta1.VolumeSnapshotContent{
Spec: volumesnapshotv1beta1.VolumeSnapshotContentSpec{
Source: volumesnapshotv1beta1.VolumeSnapshotContentSource{
SnapshotHandle: &snapshotHandle,
},
VolumeSnapshotRef: core_v1.ObjectReference{
Name: "snapshot-ref",
Namespace: "default-ns",
},
VolumeSnapshotClassName: &volumeSnapshotClassName,
},
}
invalidContent := &volumesnapshotv1beta1.VolumeSnapshotContent{
Spec: volumesnapshotv1beta1.VolumeSnapshotContentSpec{
Source: volumesnapshotv1beta1.VolumeSnapshotContentSource{
SnapshotHandle: &snapshotHandle,
VolumeHandle: &volumeHandle,
},
VolumeSnapshotRef: core_v1.ObjectReference{
Name: "",
Namespace: "default-ns",
},
},
}
invalidErrorMsg := fmt.Sprintf("only one of Spec.Source.VolumeHandle = %s and Spec.Source.SnapshotHandle = %s should be set", volumeHandle, snapshotHandle)
testCases := []struct {
name string
volumeSnapshotContent *volumesnapshotv1beta1.VolumeSnapshotContent
oldVolumeSnapshotContent *volumesnapshotv1beta1.VolumeSnapshotContent
shouldAdmit bool
msg string
operation v1.Operation
}{
{
name: "Delete: both new and old are nil",
volumeSnapshotContent: nil,
oldVolumeSnapshotContent: nil,
shouldAdmit: true,
operation: v1.Delete,
},
{
name: "Create: old is nil and new is invalid",
volumeSnapshotContent: invalidContent,
oldVolumeSnapshotContent: nil,
shouldAdmit: false,
operation: v1.Create,
msg: invalidErrorMsg,
},
{
name: "Create: old is nil and new is valid",
volumeSnapshotContent: validContent,
oldVolumeSnapshotContent: nil,
shouldAdmit: true,
operation: v1.Create,
},
{
name: "Update: old is valid and new is invalid",
volumeSnapshotContent: invalidContent,
oldVolumeSnapshotContent: validContent,
shouldAdmit: false,
operation: v1.Update,
msg: fmt.Sprintf("Spec.Source.VolumeHandle is immutable but was changed from %s to %s", strPtrDereference(nil), volumeHandle),
},
{
name: "Update: old is valid and new is valid",
volumeSnapshotContent: validContent,
oldVolumeSnapshotContent: validContent,
shouldAdmit: true,
operation: v1.Update,
},
{
name: "Update: old is valid and new is valid but modifies immutable field",
volumeSnapshotContent: &volumesnapshotv1beta1.VolumeSnapshotContent{
Spec: volumesnapshotv1beta1.VolumeSnapshotContentSpec{
Source: volumesnapshotv1beta1.VolumeSnapshotContentSource{
SnapshotHandle: &modifiedField,
},
VolumeSnapshotRef: core_v1.ObjectReference{
Name: "snapshot-ref",
Namespace: "default-ns",
},
},
},
oldVolumeSnapshotContent: validContent,
shouldAdmit: false,
operation: v1.Update,
msg: fmt.Sprintf("Spec.Source.SnapshotHandle is immutable but was changed from %s to %s", snapshotHandle, modifiedField),
},
{
name: "Update: old is invalid and new is valid",
volumeSnapshotContent: validContent,
oldVolumeSnapshotContent: invalidContent,
shouldAdmit: true,
operation: v1.Update,
},
{
name: "Update: old is invalid and new is invalid",
volumeSnapshotContent: invalidContent,
oldVolumeSnapshotContent: invalidContent,
shouldAdmit: true,
operation: v1.Update,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
snapshotContent := tc.volumeSnapshotContent
raw, err := json.Marshal(snapshotContent)
if err != nil {
t.Fatal(err)
}
oldSnapshotContent := tc.oldVolumeSnapshotContent
oldRaw, err := json.Marshal(oldSnapshotContent)
if err != nil {
t.Fatal(err)
}
review := v1.AdmissionReview{
Request: &v1.AdmissionRequest{
Object: runtime.RawExtension{
Raw: raw,
},
OldObject: runtime.RawExtension{
Raw: oldRaw,
},
Resource: SnapshotContentV1Beta1GVR,
Operation: tc.operation,
},
}
response := admitSnapshot(review)
shouldAdmit := response.Allowed
msg := response.Result.Message
expectedResponse := tc.shouldAdmit
expectedMsg := tc.msg
if shouldAdmit != expectedResponse {
t.Errorf("expected \"%v\" to equal \"%v\"", shouldAdmit, expectedResponse)
}
if msg != expectedMsg {
t.Errorf("expected \"%v\" to equal \"%v\"", msg, expectedMsg)
}
})
}
}

View File

@@ -0,0 +1,197 @@
/*
Copyright 2020 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package webhook
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"github.com/spf13/cobra"
v1 "k8s.io/api/admission/v1"
"k8s.io/api/admission/v1beta1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/klog"
// TODO: try this library to see if it generates correct json patch
// https://github.com/mattbaird/jsonpatch
)
var (
certFile string
keyFile string
port int
)
// CmdWebhook is used by agnhost Cobra.
var CmdWebhook = &cobra.Command{
Use: "validation-webhook",
Short: "Starts a HTTP server, useful for testing MutatingAdmissionWebhook and ValidatingAdmissionWebhook",
Long: `Starts a HTTP server, useful for testing MutatingAdmissionWebhook and ValidatingAdmissionWebhook.
After deploying it to Kubernetes cluster, the Administrator needs to create a ValidatingWebhookConfiguration
in the Kubernetes cluster to register remote webhook admission controllers.`,
Args: cobra.MaximumNArgs(0),
Run: main,
}
func init() {
CmdWebhook.Flags().StringVar(&certFile, "tls-cert-file", "",
"File containing the default x509 Certificate for HTTPS. (CA cert, if any, concatenated after server cert).")
CmdWebhook.Flags().StringVar(&keyFile, "tls-private-key-file", "",
"File containing the default x509 private key matching --tls-cert-file.")
CmdWebhook.Flags().IntVar(&port, "port", 443,
"Secure port that the webhook listens on")
CmdWebhook.MarkFlagRequired("tls-cert-file")
CmdWebhook.MarkFlagRequired("tls-private-key-file")
}
// admitv1beta1Func handles a v1beta1 admission
type admitv1beta1Func func(v1beta1.AdmissionReview) *v1beta1.AdmissionResponse
// admitv1beta1Func handles a v1 admission
type admitv1Func func(v1.AdmissionReview) *v1.AdmissionResponse
// admitHandler is a handler, for both validators and mutators, that supports multiple admission review versions
type admitHandler struct {
v1beta1 admitv1beta1Func
v1 admitv1Func
}
func newDelegateToV1AdmitHandler(f admitv1Func) admitHandler {
return admitHandler{
v1beta1: delegateV1beta1AdmitToV1(f),
v1: f,
}
}
func delegateV1beta1AdmitToV1(f admitv1Func) admitv1beta1Func {
return func(review v1beta1.AdmissionReview) *v1beta1.AdmissionResponse {
in := v1.AdmissionReview{Request: convertAdmissionRequestToV1(review.Request)}
out := f(in)
return convertAdmissionResponseToV1beta1(out)
}
}
// serve handles the http portion of a request prior to handing to an admit
// function
func serve(w http.ResponseWriter, r *http.Request, admit admitHandler) {
var body []byte
if r.Body == nil {
msg := "Expected request body to be non-empty"
klog.Error(msg)
http.Error(w, msg, http.StatusBadRequest)
}
data, err := ioutil.ReadAll(r.Body)
if err != nil {
msg := fmt.Sprintf("Request could not be decoded: %v", err)
klog.Error(msg)
http.Error(w, msg, http.StatusBadRequest)
}
body = data
// verify the content type is accurate
contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
msg := fmt.Sprintf("contentType=%s, expect application/json", contentType)
klog.Errorf(msg)
http.Error(w, msg, http.StatusBadRequest)
return
}
klog.V(2).Info(fmt.Sprintf("handling request: %s", body))
deserializer := codecs.UniversalDeserializer()
obj, gvk, err := deserializer.Decode(body, nil, nil)
if err != nil {
msg := fmt.Sprintf("Request could not be decoded: %v", err)
klog.Error(msg)
http.Error(w, msg, http.StatusBadRequest)
return
}
var responseObj runtime.Object
switch *gvk {
case v1beta1.SchemeGroupVersion.WithKind("AdmissionReview"):
requestedAdmissionReview, ok := obj.(*v1beta1.AdmissionReview)
if !ok {
msg := fmt.Sprintf("Expected v1beta1.AdmissionReview but got: %T", obj)
klog.Errorf(msg)
http.Error(w, msg, http.StatusBadRequest)
return
}
responseAdmissionReview := &v1beta1.AdmissionReview{}
responseAdmissionReview.SetGroupVersionKind(*gvk)
responseAdmissionReview.Response = admit.v1beta1(*requestedAdmissionReview)
responseAdmissionReview.Response.UID = requestedAdmissionReview.Request.UID
responseObj = responseAdmissionReview
case v1.SchemeGroupVersion.WithKind("AdmissionReview"):
requestedAdmissionReview, ok := obj.(*v1.AdmissionReview)
if !ok {
msg := fmt.Sprintf("Expected v1.AdmissionReview but got: %T", obj)
klog.Errorf(msg)
http.Error(w, msg, http.StatusBadRequest)
return
}
responseAdmissionReview := &v1.AdmissionReview{}
responseAdmissionReview.SetGroupVersionKind(*gvk)
responseAdmissionReview.Response = admit.v1(*requestedAdmissionReview)
responseAdmissionReview.Response.UID = requestedAdmissionReview.Request.UID
responseObj = responseAdmissionReview
default:
msg := fmt.Sprintf("Unsupported group version kind: %v", gvk)
klog.Error(msg)
http.Error(w, msg, http.StatusBadRequest)
return
}
klog.V(2).Info(fmt.Sprintf("sending response: %v", responseObj))
respBytes, err := json.Marshal(responseObj)
if err != nil {
klog.Error(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if _, err := w.Write(respBytes); err != nil {
klog.Error(err)
}
}
func serveSnapshotRequest(w http.ResponseWriter, r *http.Request) {
serve(w, r, newDelegateToV1AdmitHandler(admitSnapshot))
}
func main(cmd *cobra.Command, args []string) {
fmt.Println("Starting webhook server")
config := Config{
CertFile: certFile,
KeyFile: keyFile,
}
http.HandleFunc("/volumesnapshot", serveSnapshotRequest)
http.HandleFunc("/readyz", func(w http.ResponseWriter, req *http.Request) { w.Write([]byte("ok")) })
server := &http.Server{
Addr: fmt.Sprintf(":%d", port),
TLSConfig: configTLS(config),
}
err := server.ListenAndServeTLS("", "")
if err != nil {
panic(err)
}
}