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:
@@ -811,6 +811,15 @@ func newContent(contentName, boundToSnapshotUID, boundToSnapshotName, snapshotHa
|
||||
return &content
|
||||
}
|
||||
|
||||
func withSnapshotContentInvalidLabel(contents []*crdv1.VolumeSnapshotContent) []*crdv1.VolumeSnapshotContent {
|
||||
for i := range contents {
|
||||
if contents[i].ObjectMeta.Labels == nil {
|
||||
contents[i].ObjectMeta.Labels = make(map[string]string)
|
||||
}
|
||||
contents[i].ObjectMeta.Labels[utils.VolumeSnapshotContentInvalidLabel] = ""
|
||||
}
|
||||
return contents
|
||||
}
|
||||
func withContentAnnotations(contents []*crdv1.VolumeSnapshotContent, annotations map[string]string) []*crdv1.VolumeSnapshotContent {
|
||||
for i := range contents {
|
||||
if contents[i].ObjectMeta.Annotations == nil {
|
||||
@@ -939,6 +948,16 @@ func newSnapshotArray(
|
||||
}
|
||||
}
|
||||
|
||||
func withSnapshotInvalidLabel(snapshots []*crdv1.VolumeSnapshot) []*crdv1.VolumeSnapshot {
|
||||
for i := range snapshots {
|
||||
if snapshots[i].ObjectMeta.Labels == nil {
|
||||
snapshots[i].ObjectMeta.Labels = make(map[string]string)
|
||||
}
|
||||
snapshots[i].ObjectMeta.Labels[utils.VolumeSnapshotInvalidLabel] = ""
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
func newSnapshotClass(snapshotClassName, snapshotClassUID, driverName string, isDefaultClass bool) *crdv1.VolumeSnapshotClass {
|
||||
sc := &crdv1.VolumeSnapshotClass{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
|
@@ -84,6 +84,17 @@ const controllerUpdateFailMsg = "snapshot controller failed to update"
|
||||
func (ctrl *csiSnapshotCommonController) syncContent(content *crdv1.VolumeSnapshotContent) error {
|
||||
snapshotName := utils.SnapshotRefKey(&content.Spec.VolumeSnapshotRef)
|
||||
klog.V(4).Infof("synchronizing VolumeSnapshotContent[%s]: content is bound to snapshot %s", content.Name, snapshotName)
|
||||
|
||||
klog.V(5).Infof("syncContent[%s]: check if we should add invalid label on content", content.Name)
|
||||
// Perform additional validation. Label objects which fail.
|
||||
// Part of a plan to tighten validation, this label will enable users to
|
||||
// query for invalid content objects. See issue #363
|
||||
content, err := ctrl.checkAndSetInvalidContentLabel(content)
|
||||
if err != nil {
|
||||
klog.Errorf("syncContent[%s]: check and add invalid content label failed, %s", content.Name, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO(xiangqian): Putting this check in controller as webhook has not been implemented
|
||||
// yet. Remove the source checking once issue #187 is resolved:
|
||||
// https://github.com/kubernetes-csi/external-snapshotter/issues/187
|
||||
@@ -114,7 +125,7 @@ func (ctrl *csiSnapshotCommonController) syncContent(content *crdv1.VolumeSnapsh
|
||||
// and it may have already been deleted, and it will fall into the
|
||||
// snapshot == nil case below
|
||||
var snapshot *crdv1.VolumeSnapshot
|
||||
snapshot, err := ctrl.getSnapshotFromStore(snapshotName)
|
||||
snapshot, err = ctrl.getSnapshotFromStore(snapshotName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -167,6 +178,7 @@ func (ctrl *csiSnapshotCommonController) syncContent(content *crdv1.VolumeSnapsh
|
||||
// For easier readability, it is split into syncUnreadySnapshot and syncReadySnapshot
|
||||
func (ctrl *csiSnapshotCommonController) syncSnapshot(snapshot *crdv1.VolumeSnapshot) error {
|
||||
klog.V(5).Infof("synchronizing VolumeSnapshot[%s]: %s", utils.SnapshotKey(snapshot), utils.GetSnapshotStatusForLogging(snapshot))
|
||||
|
||||
klog.V(5).Infof("syncSnapshot [%s]: check if we should remove finalizer on snapshot PVC source and remove it if we can", utils.SnapshotKey(snapshot))
|
||||
|
||||
// Check if we should remove finalizer on PVC and remove it if we can.
|
||||
@@ -176,6 +188,16 @@ func (ctrl *csiSnapshotCommonController) syncSnapshot(snapshot *crdv1.VolumeSnap
|
||||
ctrl.eventRecorder.Event(snapshot, v1.EventTypeWarning, "ErrorPVCFinalizer", "Error check and remove PVC Finalizer for VolumeSnapshot")
|
||||
}
|
||||
|
||||
klog.V(5).Infof("syncSnapshot[%s]: check if we should add invalid label on snapshot", utils.SnapshotKey(snapshot))
|
||||
// Perform additional validation. Label objects which fail.
|
||||
// Part of a plan to tighten validation, this label will enable users to
|
||||
// query for invalid snapshot objects. See issue #363
|
||||
snapshot, err := ctrl.checkAndSetInvalidSnapshotLabel(snapshot)
|
||||
if err != nil {
|
||||
klog.Errorf("syncSnapshot[%s]: check and add invalid snapshot label failed, %s", utils.SnapshotKey(snapshot), err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Proceed with snapshot deletion only if snapshot is not in the middled of being
|
||||
// created from a PVC with a finalizer. This is to ensure that the PVC finalizer
|
||||
// can be removed even if a delete snapshot request is received before create
|
||||
@@ -1285,7 +1307,7 @@ func (ctrl *csiSnapshotCommonController) addSnapshotFinalizer(snapshot *crdv1.Vo
|
||||
}
|
||||
_, err := ctrl.clientset.SnapshotV1beta1().VolumeSnapshots(snapshotClone.Namespace).Update(context.TODO(), snapshotClone, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
return newControllerUpdateError(snapshot.Name, err.Error())
|
||||
return newControllerUpdateError(utils.SnapshotKey(snapshot), err.Error())
|
||||
}
|
||||
|
||||
_, err = ctrl.storeSnapshotUpdate(snapshotClone)
|
||||
@@ -1374,3 +1396,87 @@ func (ctrl *csiSnapshotCommonController) setAnnVolumeSnapshotBeingDeleted(conten
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkAndSetInvalidContentLabel adds a label to unlabeled invalid content objects and removes the label from valid ones.
|
||||
func (ctrl *csiSnapshotCommonController) checkAndSetInvalidContentLabel(content *crdv1.VolumeSnapshotContent) (*crdv1.VolumeSnapshotContent, error) {
|
||||
hasLabel := utils.MapContainsKey(content.ObjectMeta.Labels, utils.VolumeSnapshotContentInvalidLabel)
|
||||
err := utils.ValidateSnapshotContent(content)
|
||||
if err != nil {
|
||||
klog.Errorf("syncContent[%s]: Invalid content detected, %s", content.Name, err.Error())
|
||||
}
|
||||
// If the snapshot content correctly has the label, or correctly does not have the label, take no action.
|
||||
if hasLabel && err != nil || !hasLabel && err == nil {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
contentClone := content.DeepCopy()
|
||||
if hasLabel {
|
||||
// Need to remove the label
|
||||
delete(contentClone.Labels, utils.VolumeSnapshotContentInvalidLabel)
|
||||
} else {
|
||||
// Snapshot content is invalid and does not have the label. Need to add the label
|
||||
if contentClone.ObjectMeta.Labels == nil {
|
||||
contentClone.ObjectMeta.Labels = make(map[string]string)
|
||||
}
|
||||
contentClone.ObjectMeta.Labels[utils.VolumeSnapshotContentInvalidLabel] = ""
|
||||
}
|
||||
updatedContent, err := ctrl.clientset.SnapshotV1beta1().VolumeSnapshotContents().Update(context.TODO(), contentClone, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
return content, newControllerUpdateError(content.Name, err.Error())
|
||||
}
|
||||
|
||||
_, err = ctrl.storeContentUpdate(contentClone)
|
||||
if err != nil {
|
||||
klog.Errorf("failed to update content store %v", err)
|
||||
}
|
||||
|
||||
if hasLabel {
|
||||
klog.V(5).Infof("Removed invalid content label from volume snapshot content %s", content.Name)
|
||||
} else {
|
||||
klog.V(5).Infof("Added invalid content label to volume snapshot content %s", content.Name)
|
||||
}
|
||||
return updatedContent, nil
|
||||
}
|
||||
|
||||
// checkAndSetInvalidSnapshotLabel adds a label to unlabeled invalid snapshot objects and removes the label from valid ones.
|
||||
func (ctrl *csiSnapshotCommonController) checkAndSetInvalidSnapshotLabel(snapshot *crdv1.VolumeSnapshot) (*crdv1.VolumeSnapshot, error) {
|
||||
hasLabel := utils.MapContainsKey(snapshot.ObjectMeta.Labels, utils.VolumeSnapshotInvalidLabel)
|
||||
err := utils.ValidateSnapshot(snapshot)
|
||||
if err != nil {
|
||||
klog.Errorf("syncSnapshot[%s]: Invalid snapshot detected, %s", utils.SnapshotKey(snapshot), err.Error())
|
||||
}
|
||||
// If the snapshot correctly has the label, or correctly does not have the label, take no action.
|
||||
if hasLabel && err != nil || !hasLabel && err == nil {
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
snapshotClone := snapshot.DeepCopy()
|
||||
if hasLabel {
|
||||
// Need to remove the label
|
||||
delete(snapshotClone.Labels, utils.VolumeSnapshotInvalidLabel)
|
||||
} else {
|
||||
// Snapshot is invalid and does not have the label. Need to add the label
|
||||
if snapshotClone.ObjectMeta.Labels == nil {
|
||||
snapshotClone.ObjectMeta.Labels = make(map[string]string)
|
||||
}
|
||||
snapshotClone.ObjectMeta.Labels[utils.VolumeSnapshotInvalidLabel] = ""
|
||||
}
|
||||
|
||||
updatedSnapshot, err := ctrl.clientset.SnapshotV1beta1().VolumeSnapshots(snapshot.Namespace).Update(context.TODO(), snapshotClone, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
return snapshot, newControllerUpdateError(utils.SnapshotKey(snapshot), err.Error())
|
||||
}
|
||||
|
||||
_, err = ctrl.storeSnapshotUpdate(snapshotClone)
|
||||
if err != nil {
|
||||
klog.Errorf("failed to update snapshot store %v", err)
|
||||
}
|
||||
|
||||
if hasLabel {
|
||||
klog.V(5).Infof("Removed invalid snapshot label from volume snapshot %s", utils.SnapshotKey(snapshot))
|
||||
} else {
|
||||
klog.V(5).Infof("Added invalid snapshot label to volume snapshot %s", utils.SnapshotKey(snapshot))
|
||||
}
|
||||
|
||||
return updatedSnapshot, nil
|
||||
}
|
||||
|
@@ -131,8 +131,8 @@ func TestDeleteSync(t *testing.T) {
|
||||
tests := []controllerTest{
|
||||
{
|
||||
name: "1-1 - noop: content will not be deleted if it is bound to a snapshot correctly, snapshot uid is not specified",
|
||||
initialContents: newContentArray("content1-1", "", "snap1-1", "sid1-1", validSecretClass, "", "", deletePolicy, nil, nil, true),
|
||||
expectedContents: newContentArray("content1-1", "", "snap1-1", "sid1-1", validSecretClass, "", "", deletePolicy, nil, nil, true),
|
||||
initialContents: newContentArray("content1-1", "", "snap1-1", "snaphandle1-1", validSecretClass, "snaphandle1-1", "", deletePolicy, nil, nil, true),
|
||||
expectedContents: newContentArray("content1-1", "", "snap1-1", "snaphandle1-1", validSecretClass, "snaphandle1-1", "", deletePolicy, nil, nil, true),
|
||||
initialSnapshots: newSnapshotArray("snap1-1", "snapuid1-1", "claim1-1", "", validSecretClass, "content1-1", &False, nil, nil, nil, false, true, &timeNowMetav1),
|
||||
expectedSnapshots: newSnapshotArray("snap1-1", "snapuid1-1", "claim1-1", "", validSecretClass, "content1-1", &False, nil, nil, nil, false, true, &timeNowMetav1),
|
||||
expectedEvents: noevents,
|
||||
@@ -159,8 +159,8 @@ func TestDeleteSync(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "1-3 - will not delete content with retain policy set which is bound to a snapshot incorrectly",
|
||||
initialContents: newContentArray("content1-3", "snapuid1-3-x", "snap1-3", "sid1-3", validSecretClass, "", "", retainPolicy, nil, nil, true),
|
||||
expectedContents: newContentArray("content1-3", "snapuid1-3-x", "snap1-3", "sid1-3", validSecretClass, "", "", retainPolicy, nil, nil, true),
|
||||
initialContents: newContentArray("content1-3", "snapuid1-3-x", "snap1-3", "snaphandle1-3", validSecretClass, "snaphandle1-3", "", retainPolicy, nil, nil, true),
|
||||
expectedContents: newContentArray("content1-3", "snapuid1-3-x", "snap1-3", "snaphandle1-3", validSecretClass, "snaphandle1-3", "", retainPolicy, nil, nil, true),
|
||||
initialSnapshots: newSnapshotArray("snap1-3", "snapuid1-3", "claim1-3", "", validSecretClass, "content1-3", &False, nil, nil, nil, false, true, &timeNowMetav1),
|
||||
expectedSnapshots: newSnapshotArray("snap1-3", "snapuid1-3", "claim1-3", "", validSecretClass, "content1-3", &False, nil, nil, nil, false, true, &timeNowMetav1),
|
||||
expectedEvents: noevents,
|
||||
|
@@ -413,7 +413,7 @@ func TestSync(t *testing.T) {
|
||||
initialContents: nocontents,
|
||||
expectedContents: nocontents,
|
||||
initialSnapshots: newSnapshotArray("snap7-2", "snapuid7-2", "", "", validSecretClass, "", &False, nil, nil, nil, false, true, nil),
|
||||
expectedSnapshots: newSnapshotArray("snap7-2", "snapuid7-2", "", "", validSecretClass, "", &False, nil, nil, newVolumeError("Exactly one of PersistentVolumeClaimName and VolumeSnapshotContentName should be specified"), false, true, nil),
|
||||
expectedSnapshots: withSnapshotInvalidLabel(newSnapshotArray("snap7-2", "snapuid7-2", "", "", validSecretClass, "", &False, nil, nil, newVolumeError("Exactly one of PersistentVolumeClaimName and VolumeSnapshotContentName should be specified"), false, true, nil)),
|
||||
expectedEvents: []string{"Warning SnapshotValidationError"},
|
||||
errors: noerrors,
|
||||
expectSuccess: false,
|
||||
@@ -424,7 +424,7 @@ func TestSync(t *testing.T) {
|
||||
initialContents: nocontents,
|
||||
expectedContents: nocontents,
|
||||
initialSnapshots: newSnapshotArray("snap7-3", "snapuid7-3", "claim7-3", "snaphandle7-3", validSecretClass, "", &False, nil, nil, nil, false, true, nil),
|
||||
expectedSnapshots: newSnapshotArray("snap7-3", "snapuid7-3", "claim7-3", "snaphandle7-3", validSecretClass, "", &False, nil, nil, newVolumeError("Exactly one of PersistentVolumeClaimName and VolumeSnapshotContentName should be specified"), false, true, nil),
|
||||
expectedSnapshots: withSnapshotInvalidLabel(newSnapshotArray("snap7-3", "snapuid7-3", "claim7-3", "snaphandle7-3", validSecretClass, "", &False, nil, nil, newVolumeError("Exactly one of PersistentVolumeClaimName and VolumeSnapshotContentName should be specified"), false, true, nil)),
|
||||
initialClaims: newClaimArray("claim7-3", "pvc-uid7-3", "1Gi", "volume7-3", v1.ClaimBound, &classEmpty),
|
||||
initialVolumes: newVolumeArray("volume7-3", "pv-uid7-3", "pv-handle7-3", "1Gi", "pvc-uid7-3", "claim7-3", v1.VolumeBound, v1.PersistentVolumeReclaimDelete, classEmpty),
|
||||
expectedEvents: []string{"Warning SnapshotValidationError"},
|
||||
@@ -437,7 +437,7 @@ func TestSync(t *testing.T) {
|
||||
initialSnapshots: nosnapshots,
|
||||
expectedSnapshots: nosnapshots,
|
||||
initialContents: newContentArray("content7-4", "snapuid7-4", "snap7-4", "sid7-4", validSecretClass, "sid7-4", "pv-handle7-4", deletionPolicy, nil, nil, true),
|
||||
expectedContents: newContentArray("content7-4", "snapuid7-4", "snap7-4", "sid7-4", validSecretClass, "sid7-4", "pv-handle7-4", deletionPolicy, nil, nil, true),
|
||||
expectedContents: withSnapshotContentInvalidLabel(newContentArray("content7-4", "snapuid7-4", "snap7-4", "sid7-4", validSecretClass, "sid7-4", "pv-handle7-4", deletionPolicy, nil, nil, true)),
|
||||
expectedEvents: []string{"Warning ContentValidationError"},
|
||||
errors: noerrors,
|
||||
expectSuccess: false,
|
||||
@@ -448,7 +448,7 @@ func TestSync(t *testing.T) {
|
||||
initialSnapshots: nosnapshots,
|
||||
expectedSnapshots: nosnapshots,
|
||||
initialContents: newContentArray("content7-4", "snapuid7-4", "snap7-4", "sid7-4", validSecretClass, "", "", deletionPolicy, nil, nil, true),
|
||||
expectedContents: newContentArray("content7-4", "snapuid7-4", "snap7-4", "sid7-4", validSecretClass, "", "", deletionPolicy, nil, nil, true),
|
||||
expectedContents: withSnapshotContentInvalidLabel(newContentArray("content7-4", "snapuid7-4", "snap7-4", "sid7-4", validSecretClass, "", "", deletionPolicy, nil, nil, true)),
|
||||
expectedEvents: []string{"Warning ContentValidationError"},
|
||||
errors: noerrors,
|
||||
expectSuccess: false,
|
||||
|
@@ -94,6 +94,13 @@ const (
|
||||
// and used at snapshot content deletion time.
|
||||
AnnDeletionSecretRefName = "snapshot.storage.kubernetes.io/deletion-secret-name"
|
||||
AnnDeletionSecretRefNamespace = "snapshot.storage.kubernetes.io/deletion-secret-namespace"
|
||||
|
||||
// VolumeSnapshotContentInvalidLabel is applied to invalid content as a label key. The value does not matter.
|
||||
// See https://github.com/kubernetes/enhancements/blob/master/keps/sig-storage/177-volume-snapshot/tighten-validation-webhook-crd.md#automatic-labelling-of-invalid-objects
|
||||
VolumeSnapshotContentInvalidLabel = "snapshot.storage.kubernetes.io/invalid-snapshot-content-resource"
|
||||
// VolumeSnapshotInvalidLabel is applied to invalid snapshot as a label key. The value does not matter.
|
||||
// See https://github.com/kubernetes/enhancements/blob/master/keps/sig-storage/177-volume-snapshot/tighten-validation-webhook-crd.md#automatic-labelling-of-invalid-objects
|
||||
VolumeSnapshotInvalidLabel = "snapshot.storage.kubernetes.io/invalid-snapshot-resource"
|
||||
)
|
||||
|
||||
var SnapshotterSecretParams = secretParamsMap{
|
||||
@@ -108,6 +115,61 @@ var SnapshotterListSecretParams = secretParamsMap{
|
||||
secretNamespaceKey: PrefixedSnapshotterListSecretNamespaceKey,
|
||||
}
|
||||
|
||||
// ValidateSnapshot performs additional strict validation.
|
||||
// Do NOT rely on this function to fully validate snapshot objects.
|
||||
// This function will only check the additional rules provided by the webhook.
|
||||
func ValidateSnapshot(snapshot *crdv1.VolumeSnapshot) error {
|
||||
if snapshot == nil {
|
||||
return fmt.Errorf("VolumeSnapshot is nil")
|
||||
}
|
||||
|
||||
source := snapshot.Spec.Source
|
||||
|
||||
if source.PersistentVolumeClaimName != nil && source.VolumeSnapshotContentName != nil {
|
||||
return fmt.Errorf("only one of Spec.Source.PersistentVolumeClaimName = %s and Spec.Source.VolumeSnapshotContentName = %s should be set", *source.PersistentVolumeClaimName, *source.VolumeSnapshotContentName)
|
||||
}
|
||||
if source.PersistentVolumeClaimName == nil && source.VolumeSnapshotContentName == nil {
|
||||
return fmt.Errorf("one of Spec.Source.PersistentVolumeClaimName and Spec.Source.VolumeSnapshotContentName should be set")
|
||||
}
|
||||
vscname := snapshot.Spec.VolumeSnapshotClassName
|
||||
if vscname != nil && *vscname == "" {
|
||||
return fmt.Errorf("Spec.VolumeSnapshotClassName must not be the empty string")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSnapshotContent performs additional strict validation.
|
||||
// Do NOT rely on this function to fully validate snapshot content objects.
|
||||
// This function will only check the additional rules provided by the webhook.
|
||||
func ValidateSnapshotContent(snapcontent *crdv1.VolumeSnapshotContent) error {
|
||||
if snapcontent == nil {
|
||||
return fmt.Errorf("VolumeSnapshotContent is nil")
|
||||
}
|
||||
|
||||
source := snapcontent.Spec.Source
|
||||
|
||||
if source.VolumeHandle != nil && source.SnapshotHandle != nil {
|
||||
return fmt.Errorf("only one of Spec.Source.VolumeHandle = %s and Spec.Source.SnapshotHandle = %s should be set", *source.VolumeHandle, *source.SnapshotHandle)
|
||||
}
|
||||
if source.VolumeHandle == nil && source.SnapshotHandle == nil {
|
||||
return fmt.Errorf("one of Spec.Source.VolumeHandle and Spec.Source.SnapshotHandle should be set")
|
||||
}
|
||||
|
||||
vsref := snapcontent.Spec.VolumeSnapshotRef
|
||||
|
||||
if vsref.Name == "" || vsref.Namespace == "" {
|
||||
return fmt.Errorf("both Spec.VolumeSnapshotRef.Name = %s and Spec.VolumeSnapshotRef.Namespace = %s must be set", vsref.Name, vsref.Namespace)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MapContainsKey checks if a given map of string to string contains the provided string.
|
||||
func MapContainsKey(m map[string]string, s string) bool {
|
||||
_, r := m[s]
|
||||
return r
|
||||
}
|
||||
|
||||
// ContainsString checks if a given slice of strings contains the provided string.
|
||||
func ContainsString(slice []string, s string) bool {
|
||||
for _, item := range slice {
|
||||
|
41
pkg/validation-webhook/config.go
Normal file
41
pkg/validation-webhook/config.go
Normal 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,
|
||||
}
|
||||
}
|
105
pkg/validation-webhook/convert.go
Normal file
105
pkg/validation-webhook/convert.go
Normal 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(),
|
||||
},
|
||||
}
|
||||
}
|
64
pkg/validation-webhook/convert_test.go
Normal file
64
pkg/validation-webhook/convert_test.go
Normal 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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
43
pkg/validation-webhook/scheme.go
Normal file
43
pkg/validation-webhook/scheme.go
Normal 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))
|
||||
}
|
197
pkg/validation-webhook/snapshot.go
Normal file
197
pkg/validation-webhook/snapshot.go
Normal 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
|
||||
}
|
374
pkg/validation-webhook/snapshot_test.go
Normal file
374
pkg/validation-webhook/snapshot_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
197
pkg/validation-webhook/webhook.go
Normal file
197
pkg/validation-webhook/webhook.go
Normal 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)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user