diff --git a/pkg/admission/customresourcevalidation/attributes.go b/pkg/admission/customresourcevalidation/attributes.go new file mode 100644 index 000000000000..2ce824194b3f --- /dev/null +++ b/pkg/admission/customresourcevalidation/attributes.go @@ -0,0 +1,46 @@ +package customresourcevalidation + +import ( + configv1 "github.com/openshift/api/config/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apiserver/pkg/admission" +) + +// unstructuredUnpackingAttributes tries to convert to a real object in the config scheme +type unstructuredUnpackingAttributes struct { + admission.Attributes +} + +func (a *unstructuredUnpackingAttributes) GetObject() runtime.Object { + return toBestObjectPossible(a.Attributes.GetObject()) +} + +func (a *unstructuredUnpackingAttributes) GetOldObject() runtime.Object { + return toBestObjectPossible(a.Attributes.GetOldObject()) +} + +// toBestObjectPossible tries to convert to a real object in the config scheme +func toBestObjectPossible(orig runtime.Object) runtime.Object { + unstructuredOrig, ok := orig.(runtime.Unstructured) + if !ok { + return orig + } + + targetObj, err := configScheme.New(unstructuredOrig.GetObjectKind().GroupVersionKind()) + if err != nil { + utilruntime.HandleError(err) + return unstructuredOrig + } + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredOrig.UnstructuredContent(), targetObj); err != nil { + utilruntime.HandleError(err) + return unstructuredOrig + } + return targetObj +} + +var configScheme = runtime.NewScheme() + +func init() { + utilruntime.Must(configv1.Install(configScheme)) +} diff --git a/pkg/admission/customresourcevalidation/customresourcevalidationregistration/cr_validation_registration.go b/pkg/admission/customresourcevalidation/customresourcevalidationregistration/cr_validation_registration.go new file mode 100644 index 000000000000..47b4a3c05c90 --- /dev/null +++ b/pkg/admission/customresourcevalidation/customresourcevalidationregistration/cr_validation_registration.go @@ -0,0 +1,15 @@ +package customresourcevalidationregistration + +import ( + "github.com/openshift/origin/pkg/admission/customresourcevalidation/image" + "k8s.io/apiserver/pkg/admission" +) + +// AllCustomResourceValidators are the names of all custom resource validators that should be registered +var AllCustomResourceValidators = []string{ + "config.openshift.io/ValidateImage", +} + +func RegisterCustomResourceValidation(plugins *admission.Plugins) { + image.Register(plugins) +} diff --git a/pkg/admission/customresourcevalidation/customresourcevalidator.go b/pkg/admission/customresourcevalidation/customresourcevalidator.go new file mode 100644 index 000000000000..8e347fb6cd92 --- /dev/null +++ b/pkg/admission/customresourcevalidation/customresourcevalidator.go @@ -0,0 +1,97 @@ +package customresourcevalidation + +import ( + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/admission" +) + +type ObjectValidator interface { + ValidateCreate(obj runtime.Object) field.ErrorList + ValidateUpdate(obj runtime.Object, oldObj runtime.Object) field.ErrorList + ValidateStatusUpdate(obj runtime.Object, oldObj runtime.Object) field.ErrorList +} + +// ValidateCustomResource is an implementation of admission.Interface. +// It looks at all new pods and overrides each container's image pull policy to Always. +type validateCustomResource struct { + *admission.Handler + + resources map[schema.GroupResource]bool + validators map[schema.GroupVersionKind]ObjectValidator +} + +func NewValidator(resources map[schema.GroupResource]bool, validators map[schema.GroupVersionKind]ObjectValidator) (admission.Interface, error) { + return &validateCustomResource{ + Handler: admission.NewHandler(admission.Create, admission.Update), + resources: resources, + validators: validators, + }, nil +} + +var _ admission.ValidationInterface = &validateCustomResource{} + +// Validate is an admission function that will validate a CRD in config.openshift.io. uncastAttributes are attributes +// that are of type unstructured. +func (a *validateCustomResource) Validate(uncastAttributes admission.Attributes) error { + attributes := &unstructuredUnpackingAttributes{Attributes: uncastAttributes} + if a.shouldIgnore(attributes) { + return nil + } + validator, ok := a.validators[attributes.GetKind()] + if !ok { + return admission.NewForbidden(attributes, fmt.Errorf("unhandled kind: %v", attributes.GetKind())) + } + + switch attributes.GetOperation() { + case admission.Create: + // creating subresources isn't something we understand, but we can be pretty sure we don't need to validate it + if len(attributes.GetSubresource()) > 0 { + return nil + } + errors := validator.ValidateCreate(attributes.GetObject()) + if len(errors) == 0 { + return nil + } + return apierrors.NewInvalid(attributes.GetKind().GroupKind(), attributes.GetName(), errors) + + case admission.Update: + switch attributes.GetSubresource() { + case "": + errors := validator.ValidateUpdate(attributes.GetObject(), attributes.GetOldObject()) + if len(errors) == 0 { + return nil + } + return apierrors.NewInvalid(attributes.GetKind().GroupKind(), attributes.GetName(), errors) + + case "status": + errors := validator.ValidateStatusUpdate(attributes.GetObject(), attributes.GetOldObject()) + if len(errors) == 0 { + return nil + } + return apierrors.NewInvalid(attributes.GetKind().GroupKind(), attributes.GetName(), errors) + + default: + return admission.NewForbidden(attributes, fmt.Errorf("unhandled subresource: %v", attributes.GetSubresource())) + } + + default: + return admission.NewForbidden(attributes, fmt.Errorf("unhandled operation: %v", attributes.GetOperation())) + } +} + +func (a *validateCustomResource) shouldIgnore(attributes admission.Attributes) bool { + if !a.resources[attributes.GetResource().GroupResource()] { + return true + } + // if a subresource is specified and it isn't status, skip it + if len(attributes.GetSubresource()) > 0 && attributes.GetSubresource() != "status" { + return true + } + + return false +} diff --git a/pkg/admission/customresourcevalidation/customresourcevalidator_test.go b/pkg/admission/customresourcevalidation/customresourcevalidator_test.go new file mode 100644 index 000000000000..8e414c12335c --- /dev/null +++ b/pkg/admission/customresourcevalidation/customresourcevalidator_test.go @@ -0,0 +1,275 @@ +package customresourcevalidation + +import ( + "errors" + "fmt" + "reflect" + "testing" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/kubernetes/staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + configv1 "github.com/openshift/api/config/v1" +) + +func TestCustomResourceValidator(t *testing.T) { + + const ( + testGroup = "config.openshift.io" + testVersion = "v1" + testResource = "images" + testKind = "Image" + ) + + var testObjectType *configv1.Image + + testCases := []struct { + description string + object runtime.Object + objectBytes []byte + oldObject runtime.Object + oldObjectBytes []byte + kind schema.GroupVersionKind + namespace string + name string + resource schema.GroupVersionResource + subresource string + operation admission.Operation + userInfo user.Info + expectError bool + expectCreateFuncCalled bool + expectUpdateFuncCalled bool + expectStatusUpdateFuncCalled bool + validateFuncErr bool + expectedObjectType interface{} + }{ + { + description: "ShouldIgnoreUnknownResource", + resource: schema.GroupVersionResource{ + Group: "other_group", + Version: "other_version", + Resource: "other_resource", + }, + }, + { + description: "ShouldIgnoreUnknownSubresource", + subresource: "not_status", + }, + { + description: "ShouldIgnoreUnknownSubresource", + subresource: "not_status", + }, + { + description: "UnhandledOperationConnect", + operation: admission.Connect, + expectError: true, + }, + { + description: "UnhandledOperationDelete", + operation: admission.Delete, + expectError: true, + }, + { + description: "UnhandledKind", + operation: admission.Create, + kind: schema.GroupVersionKind{ + Group: "other_group", + Version: "other_version", + Kind: "other_resource", + }, + expectError: true, + }, + { + description: "Create", + operation: admission.Create, + objectBytes: []byte(fmt.Sprintf(`{"kind":"%v","apiVersion":"%v/%v"}`, testKind, testGroup, testVersion)), + expectCreateFuncCalled: true, + expectedObjectType: testObjectType, + }, + { + description: "CreateSubresourceNope", + operation: admission.Create, + subresource: "status", + objectBytes: []byte(fmt.Sprintf(`{"kind":"%v","apiVersion":"%v/%v"}`, testKind, testGroup, testVersion)), + }, + { + description: "CreateError", + operation: admission.Create, + objectBytes: []byte(fmt.Sprintf(`{"kind":"%v","apiVersion":"%v/%v"}`, testKind, testGroup, testVersion)), + validateFuncErr: true, + expectCreateFuncCalled: true, + expectError: true, + }, + { + description: "Update", + operation: admission.Update, + objectBytes: []byte(fmt.Sprintf(`{"kind":"%v","apiVersion":"%v/%v"}`, testKind, testGroup, testVersion)), + oldObjectBytes: []byte(fmt.Sprintf(`{"kind":"%v","apiVersion":"%v/%v"}`, testKind, testGroup, testVersion)), + expectUpdateFuncCalled: true, + expectedObjectType: testObjectType, + }, + { + description: "UpdateError", + operation: admission.Update, + objectBytes: []byte(fmt.Sprintf(`{"kind":"%v","apiVersion":"%v/%v"}`, testKind, testGroup, testVersion)), + oldObjectBytes: []byte(fmt.Sprintf(`{"kind":"%v","apiVersion":"%v/%v"}`, testKind, testGroup, testVersion)), + validateFuncErr: true, + expectError: true, + }, + { + description: "UpdateStatus", + operation: admission.Update, + subresource: "status", + objectBytes: []byte(fmt.Sprintf(`{"kind":"%v","apiVersion":"%v/%v"}`, testKind, testGroup, testVersion)), + oldObjectBytes: []byte(fmt.Sprintf(`{"kind":"%v","apiVersion":"%v/%v"}`, testKind, testGroup, testVersion)), + expectStatusUpdateFuncCalled: true, + expectedObjectType: testObjectType, + }, + { + description: "UpdateStatusError", + operation: admission.Update, + subresource: "status", + objectBytes: []byte(fmt.Sprintf(`{"kind":"%v","apiVersion":"%v/%v"}`, testKind, testGroup, testVersion)), + oldObjectBytes: []byte(fmt.Sprintf(`{"kind":"%v","apiVersion":"%v/%v"}`, testKind, testGroup, testVersion)), + expectStatusUpdateFuncCalled: true, + validateFuncErr: true, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + + var createFuncCalled bool + var updateFuncCalled bool + var updateStatusFuncCalled bool + var funcArgObject runtime.Object + var funcArgOldObject runtime.Object + + handler, err := NewValidator( + map[schema.GroupResource]bool{ + {Group: testGroup, Resource: testResource}: true, + }, + map[schema.GroupVersionKind]ObjectValidator{ + {Group: testGroup, Version: testVersion, Kind: testKind}: testValidator{ + validateCreate: func(obj runtime.Object) field.ErrorList { + createFuncCalled = true + if tc.validateFuncErr { + return field.ErrorList{field.InternalError(field.NewPath("test"), errors.New("TEST Error"))} + } + funcArgObject = obj + return nil + }, + validateUpdate: func(obj runtime.Object, oldObj runtime.Object) field.ErrorList { + if tc.validateFuncErr { + return field.ErrorList{field.InternalError(field.NewPath("test"), errors.New("TEST Error"))} + } + updateFuncCalled = true + funcArgObject = obj + funcArgOldObject = oldObj + return nil + }, + validateStatusUpdate: func(obj runtime.Object, oldObj runtime.Object) field.ErrorList { + updateStatusFuncCalled = true + if tc.validateFuncErr { + return field.ErrorList{field.InternalError(field.NewPath("test"), errors.New("TEST Error"))} + } + funcArgObject = obj + funcArgOldObject = oldObj + return nil + }, + }, + }, + ) + if err != nil { + t.Fatal(err) + } + validator := handler.(admission.ValidationInterface) + + if len(tc.objectBytes) > 0 { + object, kind, err := unstructured.UnstructuredJSONScheme.Decode(tc.objectBytes, nil, nil) + if err != nil { + t.Fatal(err) + } + tc.object = object.(runtime.Object) + tc.kind = *kind + } + + if len(tc.oldObjectBytes) > 0 { + object, kind, err := unstructured.UnstructuredJSONScheme.Decode(tc.oldObjectBytes, nil, nil) + if err != nil { + t.Fatal(err) + } + tc.oldObject = object.(runtime.Object) + tc.kind = *kind + } + + if tc.resource == (schema.GroupVersionResource{}) { + tc.resource = schema.GroupVersionResource{ + Group: testGroup, + Version: testVersion, + Resource: testResource, + } + } + + attributes := admission.NewAttributesRecord( + tc.object, + tc.oldObject, + tc.kind, + tc.namespace, + tc.name, + tc.resource, + tc.subresource, + tc.operation, + tc.userInfo, + ) + + err = validator.Validate(attributes) + switch { + case tc.expectError && err == nil: + t.Error("Error expected") + case !tc.expectError && err != nil: + t.Errorf("Unexpected error: %v", err) + } + if tc.expectCreateFuncCalled != createFuncCalled { + t.Errorf("ValidateObjCreateFunc called: expected: %v, actual: %v", tc.expectCreateFuncCalled, createFuncCalled) + } + if tc.expectUpdateFuncCalled != updateFuncCalled { + t.Errorf("ValidateObjUpdateFunc called: expected: %v, actual: %v", tc.expectUpdateFuncCalled, updateFuncCalled) + } + if tc.expectStatusUpdateFuncCalled != updateStatusFuncCalled { + t.Errorf("ValidateStatusUpdateFunc called: expected: %v, actual: %v", tc.expectStatusUpdateFuncCalled, updateStatusFuncCalled) + } + if reflect.TypeOf(tc.expectedObjectType) != reflect.TypeOf(funcArgObject) { + t.Errorf("Expected %T, actual %T", tc.expectedObjectType, funcArgObject) + } + if (tc.oldObject != nil) && (reflect.TypeOf(tc.expectedObjectType) != reflect.TypeOf(funcArgOldObject)) { + t.Errorf("Expected %T, actual %T", tc.expectedObjectType, funcArgOldObject) + } + }) + } + +} + +type testValidator struct { + validateCreate func(uncastObj runtime.Object) field.ErrorList + validateUpdate func(uncastObj runtime.Object, uncastOldObj runtime.Object) field.ErrorList + validateStatusUpdate func(uncastObj runtime.Object, uncastOldObj runtime.Object) field.ErrorList +} + +func (v testValidator) ValidateCreate(uncastObj runtime.Object) field.ErrorList { + return v.validateCreate(uncastObj) +} + +func (v testValidator) ValidateUpdate(uncastObj runtime.Object, uncastOldObj runtime.Object) field.ErrorList { + return v.validateUpdate(uncastObj, uncastOldObj) + +} + +func (v testValidator) ValidateStatusUpdate(uncastObj runtime.Object, uncastOldObj runtime.Object) field.ErrorList { + return v.validateStatusUpdate(uncastObj, uncastOldObj) +} diff --git a/pkg/admission/customresourcevalidation/helpers.go b/pkg/admission/customresourcevalidation/helpers.go new file mode 100644 index 000000000000..a71cc59262c9 --- /dev/null +++ b/pkg/admission/customresourcevalidation/helpers.go @@ -0,0 +1,9 @@ +package customresourcevalidation + +// RequireNameCluster is a name validation function that requires the name to be cluster. It's handy for config.openshift.io types. +func RequireNameCluster(name string, prefix bool) []string { + if name != "cluster" { + return []string{"must be cluster"} + } + return nil +} diff --git a/pkg/admission/customresourcevalidation/image/validate_image.go b/pkg/admission/customresourcevalidation/image/validate_image.go new file mode 100644 index 000000000000..b21d0d5b0d0d --- /dev/null +++ b/pkg/admission/customresourcevalidation/image/validate_image.go @@ -0,0 +1,92 @@ +package image + +import ( + "fmt" + "io" + + "k8s.io/apimachinery/pkg/api/validation" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/apiserver/pkg/admission" + + configv1 "github.com/openshift/api/config/v1" + "github.com/openshift/origin/pkg/admission/customresourcevalidation" +) + +// Register registers a plugin +func Register(plugins *admission.Plugins) { + plugins.Register("config.openshift.io/ValidateImage", func(config io.Reader) (admission.Interface, error) { + return customresourcevalidation.NewValidator( + map[schema.GroupResource]bool{ + configv1.Resource("images"): true, + }, + map[schema.GroupVersionKind]customresourcevalidation.ObjectValidator{ + configv1.GroupVersion.WithKind("Image"): imageV1{}, + }) + }) +} + +func toImageV1(uncastObj runtime.Object) (*configv1.Image, field.ErrorList) { + if uncastObj == nil { + return nil, nil + } + + allErrs := field.ErrorList{} + + obj, ok := uncastObj.(*configv1.Image) + if !ok { + return nil, append(allErrs, + field.NotSupported(field.NewPath("kind"), fmt.Sprintf("%T", uncastObj), []string{"Image"}), + field.NotSupported(field.NewPath("apiVersion"), fmt.Sprintf("%T", uncastObj), []string{"config.openshift.io/v1"})) + } + + return obj, nil +} + +type imageV1 struct { +} + +func (imageV1) ValidateCreate(uncastObj runtime.Object) field.ErrorList { + obj, errs := toImageV1(uncastObj) + if len(errs) > 0 { + return errs + } + + // TODO validate the obj + errs = append(errs, validation.ValidateObjectMeta(&obj.ObjectMeta, false, customresourcevalidation.RequireNameCluster, field.NewPath("metadata"))...) + + return errs +} + +func (imageV1) ValidateUpdate(uncastObj runtime.Object, uncastOldObj runtime.Object) field.ErrorList { + obj, errs := toImageV1(uncastObj) + if len(errs) > 0 { + return errs + } + oldObj, errs := toImageV1(uncastOldObj) + if len(errs) > 0 { + return errs + } + + // TODO validate the obj + errs = append(errs, validation.ValidateObjectMetaUpdate(&obj.ObjectMeta, &oldObj.ObjectMeta, field.NewPath("metadata"))...) + + return errs +} + +func (imageV1) ValidateStatusUpdate(uncastObj runtime.Object, uncastOldObj runtime.Object) field.ErrorList { + obj, errs := toImageV1(uncastObj) + if len(errs) > 0 { + return errs + } + oldObj, errs := toImageV1(uncastOldObj) + if len(errs) > 0 { + return errs + } + + // TODO validate the obj. remember that status validation should *never* fail on spec validation errors. + errs = append(errs, validation.ValidateObjectMetaUpdate(&obj.ObjectMeta, &oldObj.ObjectMeta, field.NewPath("metadata"))...) + + return errs +} diff --git a/pkg/cmd/openshift-kube-apiserver/server.go b/pkg/cmd/openshift-kube-apiserver/server.go index 88122748ac8a..f7fcf8055643 100644 --- a/pkg/cmd/openshift-kube-apiserver/server.go +++ b/pkg/cmd/openshift-kube-apiserver/server.go @@ -14,6 +14,7 @@ import ( "k8s.io/kubernetes/plugin/pkg/auth/authorizer/rbac/bootstrappolicy" kubecontrolplanev1 "github.com/openshift/api/kubecontrolplane/v1" + "github.com/openshift/origin/pkg/admission/customresourcevalidation/customresourcevalidationregistration" originadmission "github.com/openshift/origin/pkg/apiserver/admission" "github.com/openshift/origin/pkg/cmd/openshift-kube-apiserver/openshiftkubeapiserver" "k8s.io/kubernetes/pkg/kubeapiserver/options" @@ -36,11 +37,14 @@ func RunOpenShiftKubeAPIServerServer(kubeAPIServerConfig *kubecontrolplanev1.Kub bootstrappolicy.ClusterRoles = bootstrappolicy.OpenshiftClusterRoles bootstrappolicy.ClusterRoleBindings = bootstrappolicy.OpenshiftClusterRoleBindings - options.AllOrderedPlugins = originadmission.KubeAdmissionPlugins + options.AllOrderedPlugins = append([]string{}, originadmission.KubeAdmissionPlugins...) + options.AllOrderedPlugins = append(options.AllOrderedPlugins, customresourcevalidationregistration.AllCustomResourceValidators...) + kubeRegisterAdmission := options.RegisterAllAdmissionPlugins options.RegisterAllAdmissionPlugins = func(plugins *admission.Plugins) { kubeRegisterAdmission(plugins) originadmission.RegisterOpenshiftKubeAdmissionPlugins(plugins) + customresourcevalidationregistration.RegisterCustomResourceValidation(plugins) } kubeDefaultOffAdmission := options.DefaultOffAdmissionPlugins options.DefaultOffAdmissionPlugins = func() sets.String {