diff --git a/apis/core/v1alpha2/core_types.go b/apis/core/v1alpha2/core_types.go index c7274bb6..a42faa6e 100644 --- a/apis/core/v1alpha2/core_types.go +++ b/apis/core/v1alpha2/core_types.go @@ -205,8 +205,9 @@ type ComponentParameter struct { // A ComponentSpec defines the desired state of a Component. type ComponentSpec struct { // A Workload that will be created for each ApplicationConfiguration that - // includes this Component. Workloads must be defined by a - // WorkloadDefinition. + // includes this Component. Workload is an instance of a workloadDefinition. + // We either use the GVK info or a special "type" field in the workload to associate + // the content of the workload with its workloadDefinition // +kubebuilder:validation:EmbeddedResource // +kubebuilder:pruning:PreserveUnknownFields Workload runtime.RawExtension `json:"workload"` diff --git a/charts/oam-kubernetes-runtime/crds/core.oam.dev_components.yaml b/charts/oam-kubernetes-runtime/crds/core.oam.dev_components.yaml index 5539c560..5947b071 100644 --- a/charts/oam-kubernetes-runtime/crds/core.oam.dev_components.yaml +++ b/charts/oam-kubernetes-runtime/crds/core.oam.dev_components.yaml @@ -82,7 +82,9 @@ spec: type: array workload: description: A Workload that will be created for each ApplicationConfiguration - that includes this Component. Workloads must be defined by a WorkloadDefinition. + that includes this Component. Workload is an instance of a workloadDefinition. + We either use the GVK info or a special "type" field in the workload + to associate the content of the workload with its workloadDefinition type: object x-kubernetes-embedded-resource: true x-kubernetes-preserve-unknown-fields: true diff --git a/charts/oam-kubernetes-runtime/templates/webhook.yaml b/charts/oam-kubernetes-runtime/templates/webhook.yaml index 7d66c733..c2df7109 100644 --- a/charts/oam-kubernetes-runtime/templates/webhook.yaml +++ b/charts/oam-kubernetes-runtime/templates/webhook.yaml @@ -11,18 +11,58 @@ webhooks: rules: - apiGroups: ["core.oam.dev"] apiVersions: ["v1alpha2"] - operations: ["CREATE", "UPDATE","DELETE"] + operations: ["CREATE", "UPDATE"] resources: ["applicationconfigurations"] scope: "Namespaced" clientConfig: service: namespace: {{.Release.Namespace}} name: {{ template "oam-kubernetes-runtime.name" . }}-webhook - path: /validating-applicationconfigurations + path: /validating-core-oam-dev-v1alpha2-applicationconfigurations caBundle: "{{.Values.certificate.caBundle}}" admissionReviewVersions: ["v1beta1"] failurePolicy: Fail timeoutSeconds: 5 + - name: "validate.component.core.oam.dev" + clientConfig: + service: + name: {{ template "oam-kubernetes-runtime.name" . }}-webhook + namespace: {{.Release.Namespace}} + path: /validating-core-oam-dev-v1alpha2-components + caBundle: "{{.Values.certificate.caBundle}}" + rules: + - apiGroups: ["core.oam.dev"] + apiVersions: ["v1alpha2"] + operations: ["CREATE", "UPDATE"] + resources: ["components"] + scope: "Namespaced" + admissionReviewVersions: ["v1beta1"] + failurePolicy: Fail + timeoutSeconds: 5 +--- +apiVersion: admissionregistration.k8s.io/v1beta1 +kind: MutatingWebhookConfiguration +metadata: + name: {{ include "oam-kubernetes-runtime.fullname" . }} + labels: + {{- include "oam-kubernetes-runtime.selectorLabels" . | nindent 4 }} +webhooks: + - name: "mutate.component.core.oam.dev" + clientConfig: + service: + name: {{ template "oam-kubernetes-runtime.name" . }}-webhook + namespace: {{.Release.Namespace}} + path: /mutating-core-oam-dev-v1alpha2-components + caBundle: "{{.Values.certificate.caBundle}}" + rules: + - apiGroups: ["core.oam.dev"] + apiVersions: ["v1alpha2"] + operations: ["CREATE", "UPDATE"] + resources: ["components"] + scope: "Namespaced" + admissionReviewVersions: ["v1beta1"] + failurePolicy: Fail + timeoutSeconds: 5 --- apiVersion: v1 kind: Service diff --git a/charts/oam-kubernetes-runtime/values.yaml.tmpl b/charts/oam-kubernetes-runtime/values.yaml.tmpl index 7246eac7..212d179f 100644 --- a/charts/oam-kubernetes-runtime/values.yaml.tmpl +++ b/charts/oam-kubernetes-runtime/values.yaml.tmpl @@ -3,7 +3,7 @@ # Declare variables to be passed into your templates. replicaCount: 1 -useWebhook: false +useWebhook: true image: repository: crossplane/oam-kubernetes-runtime tag: %%VERSION%% diff --git a/cmd/oam-kubernetes-runtime/main.go b/cmd/oam-kubernetes-runtime/main.go index b003d6f0..5129e9d5 100644 --- a/cmd/oam-kubernetes-runtime/main.go +++ b/cmd/oam-kubernetes-runtime/main.go @@ -9,6 +9,7 @@ import ( "github.com/crossplane/crossplane-runtime/pkg/logging" "go.uber.org/zap/zapcore" "gopkg.in/natefinch/lumberjack.v2" + crdv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" @@ -25,6 +26,7 @@ var scheme = runtime.NewScheme() func init() { _ = clientgoscheme.AddToScheme(scheme) _ = core.AddToScheme(scheme) + _ = crdv1.AddToScheme(scheme) // +kubebuilder:scaffold:scheme } diff --git a/examples/typed-component/definitions.yaml b/examples/typed-component/definitions.yaml new file mode 100644 index 00000000..6a306651 --- /dev/null +++ b/examples/typed-component/definitions.yaml @@ -0,0 +1,12 @@ +apiVersion: core.oam.dev/v1alpha2 +kind: WorkloadDefinition +metadata: + name: web-service +spec: + definitionRef: + name: containerizedworkloads.core.oam.dev + childResourceKinds: + - apiVersion: apps/v1 + kind: Deployment + - apiVersion: v1 + kind: Service \ No newline at end of file diff --git a/examples/typed-component/sample_application_config.yaml b/examples/typed-component/sample_application_config.yaml new file mode 100644 index 00000000..55cbf6c3 --- /dev/null +++ b/examples/typed-component/sample_application_config.yaml @@ -0,0 +1,30 @@ +apiVersion: core.oam.dev/v1alpha2 +kind: HealthScope +metadata: + name: example-health-scope +spec: + workloadRefs: [] +--- +apiVersion: core.oam.dev/v1alpha2 +kind: ApplicationConfiguration +metadata: + name: example-appconfig +spec: + components: + - componentName: web-service-component + parameterValues: + - name: image + value: wordpress:php7.2 + traits: + - trait: + apiVersion: core.oam.dev/v1alpha2 + kind: ManualScalerTrait + metadata: + name: example-appconfig-trait + spec: + replicaCount: 3 + scopes: + - scopeRef: + apiVersion: core.oam.dev/v1alpha2 + kind: HealthScope + name: example-health-scope \ No newline at end of file diff --git a/examples/typed-component/sample_component.yaml b/examples/typed-component/sample_component.yaml new file mode 100644 index 00000000..9e3f44e8 --- /dev/null +++ b/examples/typed-component/sample_component.yaml @@ -0,0 +1,21 @@ +apiVersion: core.oam.dev/v1alpha2 +kind: Component +metadata: + name: web-service-component +spec: + workload: + type: web-service + spec: + containers: + - name: wordpress + image: wordpress:4.6.1-apache + ports: + - containerPort: 80 + name: wordpress + env: + - name: TEST_ENV + value: test + parameters: + - name: image + fieldPaths: + - spec.containers[0].image \ No newline at end of file diff --git a/go.mod b/go.mod index 06bf55b6..18c4621c 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( k8s.io/klog v1.0.0 k8s.io/kube-openapi v0.0.0-20200410145947-61e04a5be9a6 k8s.io/kubectl v0.18.5 + k8s.io/utils v0.0.0-20200324210504-a9aa75ae1b89 sigs.k8s.io/controller-runtime v0.6.0 sigs.k8s.io/controller-tools v0.2.4 ) diff --git a/pkg/oam/util/helper.go b/pkg/oam/util/helper.go index 8c111883..3238a853 100644 --- a/pkg/oam/util/helper.go +++ b/pkg/oam/util/helper.go @@ -219,9 +219,9 @@ type labelAnnotationObject interface { // PassLabelAndAnnotation passes through labels and annotation objectMeta from the parent to the child object func PassLabelAndAnnotation(parentObj oam.Object, childObj labelAnnotationObject) { // pass app-config labels - childObj.SetLabels(mergeMap(parentObj.GetLabels(), childObj.GetLabels())) + childObj.SetLabels(MergeMap(parentObj.GetLabels(), childObj.GetLabels())) // pass app-config annotation - childObj.SetAnnotations(mergeMap(parentObj.GetAnnotations(), childObj.GetAnnotations())) + childObj.SetAnnotations(MergeMap(parentObj.GetAnnotations(), childObj.GetAnnotations())) } // GetCRDName return the CRD name of any resources @@ -344,10 +344,11 @@ func UnpackRevisionData(rev *appsv1.ControllerRevision) (*v1alpha2.Component, er // AddLabels will merge labels with existing labels func AddLabels(o *unstructured.Unstructured, labels map[string]string) { - o.SetLabels(mergeMap(o.GetLabels(), labels)) + o.SetLabels(MergeMap(o.GetLabels(), labels)) } -func mergeMap(src, dst map[string]string) map[string]string { +//MergeMap merges two could be nil maps +func MergeMap(src, dst map[string]string) map[string]string { if len(src) == 0 { return dst } diff --git a/pkg/oam/util/test_utils.go b/pkg/oam/util/test_utils.go index fc57a618..0ea26a77 100644 --- a/pkg/oam/util/test_utils.go +++ b/pkg/oam/util/test_utils.go @@ -1,12 +1,20 @@ package util import ( + "encoding/json" + "github.com/onsi/gomega/format" "github.com/onsi/gomega/types" apierrors "k8s.io/apimachinery/pkg/api/errors" ) -// AlreadyExistMatcher matches the error to be already exist +// JSONMarshal returns the JSON encoding +func JSONMarshal(o interface{}) []byte { + j, _ := json.Marshal(o) + return j +} + +//AlreadyExistMatcher matches the error to be already exist type AlreadyExistMatcher struct { } diff --git a/pkg/webhook/v1alpha2/admit.go b/pkg/webhook/v1alpha2/admit.go index e1c79a53..ca7cb374 100644 --- a/pkg/webhook/v1alpha2/admit.go +++ b/pkg/webhook/v1alpha2/admit.go @@ -2,6 +2,7 @@ package v1alpha2 import ( "github.com/crossplane/oam-kubernetes-runtime/pkg/webhook/v1alpha2/applicationconfiguration" + "github.com/crossplane/oam-kubernetes-runtime/pkg/webhook/v1alpha2/component" "sigs.k8s.io/controller-runtime/pkg/manager" ) @@ -9,4 +10,6 @@ import ( // Add will be called in main and register all validation handlers func Add(mgr manager.Manager) { applicationconfiguration.Register(mgr) + component.RegisterMutatingHandler(mgr) + component.RegisterValidatingHandler(mgr) } diff --git a/pkg/webhook/v1alpha2/applicationconfiguration/applicationconfiguration.go b/pkg/webhook/v1alpha2/applicationconfiguration/applicationconfiguration.go index 32721a0a..8dbcdd73 100644 --- a/pkg/webhook/v1alpha2/applicationconfiguration/applicationconfiguration.go +++ b/pkg/webhook/v1alpha2/applicationconfiguration/applicationconfiguration.go @@ -139,5 +139,5 @@ func (h *ValidatingHandler) InjectDecoder(d *admission.Decoder) error { // Register will regsiter application configuration validation to webhook func Register(mgr manager.Manager) { server := mgr.GetWebhookServer() - server.Register("/validating-applicationconfigurations", &webhook.Admission{Handler: &ValidatingHandler{}}) + server.Register("/validating-core-oam-dev-v1alpha2-applicationconfigurations", &webhook.Admission{Handler: &ValidatingHandler{}}) } diff --git a/pkg/webhook/v1alpha2/component/component_suite_test.go b/pkg/webhook/v1alpha2/component/component_suite_test.go new file mode 100644 index 00000000..2bcfd580 --- /dev/null +++ b/pkg/webhook/v1alpha2/component/component_suite_test.go @@ -0,0 +1,86 @@ +package component_test + +import ( + "os" + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + crdv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/crossplane/oam-kubernetes-runtime/apis/core" +) + +var scheme = runtime.NewScheme() +var crd crdv1.CustomResourceDefinition +var reqResource metav1.GroupVersionResource +var decoder *admission.Decoder + +func TestComponentWebHandler(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Component Web handler") +} + +var _ = BeforeSuite(func(done Done) { + By("Bootstrapping test environment") + ctrl.SetLogger(zap.New(func(o *zap.Options) { + o.Development = true + o.DestWritter = os.Stdout + })) + By("Setup scheme") + err := core.AddToScheme(scheme) + Expect(err).Should(BeNil()) + err = clientgoscheme.AddToScheme(scheme) + Expect(err).Should(BeNil()) + // the crd we will refer to + crd = crdv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo.example.com", + Labels: map[string]string{"crd": "dependency"}, + }, + Spec: crdv1.CustomResourceDefinitionSpec{ + Group: "example.com", + Names: crdv1.CustomResourceDefinitionNames{ + Kind: "Foo", + ListKind: "FooList", + Plural: "foo", + Singular: "foo", + }, + Versions: []crdv1.CustomResourceDefinitionVersion{ + { + Name: "v1", + Served: true, + Storage: true, + Schema: &crdv1.CustomResourceValidation{ + OpenAPIV3Schema: &crdv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]crdv1.JSONSchemaProps{ + "status": { + Type: "object", + Properties: map[string]crdv1.JSONSchemaProps{ + "key": {Type: "string"}, + }, + }, + }, + }, + }, + }, + }, + Scope: crdv1.NamespaceScoped, + }, + } + By("Prepare for the admission resource") + reqResource = metav1.GroupVersionResource{Group: "core.oam.dev", Version: "v1alpha2", Resource: "components"} + By("Prepare for the admission decoder") + decoder, err = admission.NewDecoder(scheme) + Expect(err).Should(BeNil()) + By("Finished test bootstrap") + close(done) +}) diff --git a/pkg/webhook/v1alpha2/component/component_test.go b/pkg/webhook/v1alpha2/component/component_test.go new file mode 100644 index 00000000..2f80435f --- /dev/null +++ b/pkg/webhook/v1alpha2/component/component_test.go @@ -0,0 +1,271 @@ +package component_test + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "github.com/crossplane/crossplane-runtime/pkg/test" + admissionv1beta1 "k8s.io/api/admission/v1beta1" + crdv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilpointer "k8s.io/utils/pointer" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/runtime/inject" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/crossplane/oam-kubernetes-runtime/apis/core/v1alpha2" + "github.com/crossplane/oam-kubernetes-runtime/pkg/oam/util" + . "github.com/crossplane/oam-kubernetes-runtime/pkg/webhook/v1alpha2/component" +) + +var _ = Describe("Component Admission controller Test", func() { + var component v1alpha2.Component + var componentName, namespace string + var label map[string]string + BeforeEach(func() { + namespace = "component-test" + label = map[string]string{"workload": "test-component"} + // Create a component definition + componentName = "example-deployment-workload" + component = v1alpha2.Component{ + ObjectMeta: metav1.ObjectMeta{ + Name: componentName, + Namespace: namespace, + Labels: label, + }, + Spec: v1alpha2.ComponentSpec{ + Parameters: []v1alpha2.ComponentParameter{ + { + Name: "image", + Required: utilpointer.BoolPtr(true), + FieldPaths: []string{"spec.template.spec.containers[0].image"}, + }, + }, + }, + } + }) + + Context("Test Mutation Webhook", func() { + var handler admission.Handler = &MutatingHandler{} + var workloadDef v1alpha2.WorkloadDefinition + var workloadTypeName string + var baseWorkload unstructured.Unstructured + + BeforeEach(func() { + decoderInjector := handler.(admission.DecoderInjector) + decoderInjector.InjectDecoder(decoder) + // define workloadDefinition + workloadDef = v1alpha2.WorkloadDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: workloadTypeName, + Labels: label, + }, + Spec: v1alpha2.WorkloadDefinitionSpec{ + Reference: v1alpha2.DefinitionReference{ + Name: "foos.example.com", + }, + }, + } + // the base workload + baseWorkload = unstructured.Unstructured{} + baseWorkload.SetAPIVersion("example.com/v1") + baseWorkload.SetKind("Foo") + baseWorkload.SetName("workloadName") + Expect(len(crd.Spec.Versions)).Should(Equal(1)) + Expect(component.Spec).NotTo(BeNil()) + }) + + It("Test bad admission request format", func() { + req := admission.Request{ + AdmissionRequest: admissionv1beta1.AdmissionRequest{ + Operation: admissionv1beta1.Create, + Resource: reqResource, + Object: runtime.RawExtension{Raw: []byte("bad request")}, + }, + } + resp := handler.Handle(context.TODO(), req) + Expect(resp.Allowed).Should(BeFalse()) + }) + + It("Test noop mutate admission handle", func() { + component.Spec.Workload = runtime.RawExtension{Raw: util.JSONMarshal(baseWorkload)} + + req := admission.Request{ + AdmissionRequest: admissionv1beta1.AdmissionRequest{ + Operation: admissionv1beta1.Create, + Resource: reqResource, + Object: runtime.RawExtension{Raw: util.JSONMarshal(component)}, + }, + } + resp := handler.Handle(context.TODO(), req) + Expect(resp.Allowed).Should(BeTrue()) + }) + + It("Test mutate function", func() { + // the workload that uses type to refer to the workloadDefinition + workloadWithType := unstructured.Unstructured{} + typeContent := make(map[string]interface{}) + typeContent[TypeField] = workloadTypeName + workloadWithType.SetUnstructuredContent(typeContent) + workloadWithType.SetName("workloadName") + // set up the bad type + workloadWithBadType := workloadWithType.DeepCopy() + workloadWithBadType.Object[TypeField] = workloadDef + // set up the result + mutatedWorkload := baseWorkload.DeepCopy() + mutatedWorkload.SetNamespace(component.GetNamespace()) + mutatedWorkload.SetLabels(util.MergeMap(label, map[string]string{WorkloadTypeLabel: workloadTypeName})) + tests := map[string]struct { + client client.Client + workload interface{} + errMsg string + wanted []byte + }{ + "bad workload case": { + workload: "bad workload", + errMsg: "cannot unmarshal string", + }, + "bad workload type case": { + workload: workloadWithBadType, + errMsg: "workload content has an unknown type", + }, + "no op case": { + workload: baseWorkload, + wanted: util.JSONMarshal(baseWorkload), + }, + "update gvk get failed case": { + client: &test.MockClient{ + MockGet: func(ctx context.Context, key types.NamespacedName, obj runtime.Object) error { + switch obj.(type) { + case *v1alpha2.WorkloadDefinition: + return fmt.Errorf("does not exist") + } + return nil + }, + }, + workload: workloadWithType.DeepCopyObject(), + errMsg: "does not exist", + }, + "update gvk and label case": { + client: &test.MockClient{ + MockGet: func(ctx context.Context, key types.NamespacedName, obj runtime.Object) error { + switch o := obj.(type) { + case *v1alpha2.WorkloadDefinition: + Expect(key.Name).Should(BeEquivalentTo(typeContent[TypeField])) + *o = workloadDef + case *crdv1.CustomResourceDefinition: + Expect(key.Name).Should(Equal(workloadDef.Spec.Reference.Name)) + *o = crd + } + return nil + }, + }, + workload: workloadWithType.DeepCopyObject(), + wanted: util.JSONMarshal(mutatedWorkload), + }, + } + for testCase, test := range tests { + By(fmt.Sprintf("start test : %s", testCase)) + component.Spec.Workload = runtime.RawExtension{Raw: util.JSONMarshal(test.workload)} + injc := handler.(inject.Client) + injc.InjectClient(test.client) + mutatingHandler := handler.(*MutatingHandler) + err := mutatingHandler.Mutate(&component) + if len(test.errMsg) == 0 { + Expect(err).Should(BeNil()) + Expect(component.Spec.Workload.Raw).Should(BeEquivalentTo(test.wanted)) + } else { + Expect(err.Error()).Should(ContainSubstring(test.errMsg)) + } + } + }) + }) + + It("Test validating handler", func() { + var handler admission.Handler = &ValidatingHandler{} + decoderInjector := handler.(admission.DecoderInjector) + decoderInjector.InjectDecoder(decoder) + By("Creating valid workload") + validWorkload := unstructured.Unstructured{} + validWorkload.SetAPIVersion("validAPI") + validWorkload.SetKind("validKind") + By("Creating invalid workload with type") + workloadWithType := validWorkload.DeepCopy() + typeContent := make(map[string]interface{}) + typeContent[TypeField] = "should not be here" + workloadWithType.SetUnstructuredContent(typeContent) + By("Creating invalid workload without kind") + noKindWorkload := validWorkload.DeepCopy() + noKindWorkload.SetKind("") + tests := map[string]struct { + workload interface{} + operation admissionv1beta1.Operation + pass bool + reason string + }{ + "valid create case": { + workload: validWorkload.DeepCopyObject(), + operation: admissionv1beta1.Create, + pass: true, + reason: "", + }, + "valid update case": { + workload: validWorkload.DeepCopyObject(), + operation: admissionv1beta1.Update, + pass: true, + reason: "", + }, + "malformat component": { + workload: "bad format", + operation: admissionv1beta1.Create, + pass: false, + reason: "the workload is malformat", + }, + "workload still has type": { + workload: workloadWithType.DeepCopyObject(), + operation: admissionv1beta1.Create, + pass: false, + reason: "the workload contains type info", + }, + "no kind workload component": { + workload: noKindWorkload.DeepCopyObject(), + operation: admissionv1beta1.Update, + pass: false, + reason: "the workload data missing GVK", + }, + } + for testCase, test := range tests { + By(fmt.Sprintf("start test : %s", testCase)) + component.Spec.Workload = runtime.RawExtension{Raw: util.JSONMarshal(test.workload)} + req := admission.Request{ + AdmissionRequest: admissionv1beta1.AdmissionRequest{ + Operation: test.operation, + Resource: reqResource, + Object: runtime.RawExtension{Raw: util.JSONMarshal(component)}, + }, + } + resp := handler.Handle(context.TODO(), req) + Expect(resp.Allowed).Should(Equal(test.pass)) + if !test.pass { + Expect(string(resp.Result.Reason)).Should(ContainSubstring(test.reason)) + } + } + By("Test bad admission request format") + req := admission.Request{ + AdmissionRequest: admissionv1beta1.AdmissionRequest{ + Operation: admissionv1beta1.Create, + Resource: reqResource, + Object: runtime.RawExtension{Raw: []byte("bad request")}, + }, + } + resp := handler.Handle(context.TODO(), req) + Expect(resp.Allowed).Should(BeFalse()) + }) + +}) diff --git a/pkg/webhook/v1alpha2/component/mutating_handler.go b/pkg/webhook/v1alpha2/component/mutating_handler.go new file mode 100644 index 00000000..8068dd1c --- /dev/null +++ b/pkg/webhook/v1alpha2/component/mutating_handler.go @@ -0,0 +1,160 @@ +/* +Copyright 2019 The Kruise 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 component + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + crdv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/runtime/inject" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/crossplane/oam-kubernetes-runtime/apis/core/v1alpha2" + "github.com/crossplane/oam-kubernetes-runtime/pkg/oam/util" +) + +const ( + // TypeField is the special field indicate the type of the workloadDefinition + TypeField = "type" + + // WorkloadTypeLabel indicates the type of the workloadDefinition + WorkloadTypeLabel = "workload.oam.dev/type" +) + +// MutatingHandler handles Component +type MutatingHandler struct { + Client client.Client + + // Decoder decodes objects + Decoder *admission.Decoder +} + +// log is for logging in this package. +var mutatelog = logf.Log.WithName("component mutate webhook") + +var _ admission.Handler = &MutatingHandler{} + +// Handle handles admission requests. +func (h *MutatingHandler) Handle(ctx context.Context, req admission.Request) admission.Response { + obj := &v1alpha2.Component{} + + err := h.Decoder.Decode(req, obj) + if err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + // mutate the object + if err := h.Mutate(obj); err != nil { + mutatelog.Error(err, "failed to mutate the component", "name", obj.Name) + return admission.Errored(http.StatusBadRequest, err) + } + mutatelog.Info("Print the mutated obj", "obj name", obj.Name, "mutated obj", string(obj.Spec.Workload.Raw)) + + marshalled, err := json.Marshal(obj) + if err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + + resp := admission.PatchResponseFromRaw(req.AdmissionRequest.Object.Raw, marshalled) + if len(resp.Patches) > 0 { + mutatelog.Info("admit Component", + "namespace", obj.Namespace, "name", obj.Name, "patches", util.JSONMarshal(resp.Patches)) + } + return resp +} + +// Mutate sets all the default value for the Component +func (h *MutatingHandler) Mutate(obj *v1alpha2.Component) error { + mutatelog.Info("mutate", "name", obj.Name) + var content map[string]interface{} + if err := json.Unmarshal(obj.Spec.Workload.Raw, &content); err != nil { + return err + } + if content[TypeField] != nil { + workloadType, ok := content[TypeField].(string) + if !ok { + return fmt.Errorf("workload content has an unknown type field") + } + mutatelog.Info("the component refers to workoadDefinition by type", "name", obj.Name, "workload type", workloadType) + // Fetch the corresponding workloadDefinition CR, the workloadDefinition crd is cluster scoped + workloadDefinition := &v1alpha2.WorkloadDefinition{} + if err := h.Client.Get(context.TODO(), types.NamespacedName{Name: workloadType}, workloadDefinition); err != nil { + return err + } + // fetch the CRDs definition + customResourceDefinition := &crdv1.CustomResourceDefinition{} + if err := h.Client.Get(context.TODO(), types.NamespacedName{Name: workloadDefinition.Spec.Reference.Name}, customResourceDefinition); err != nil { + return err + } + // reconstruct the workload CR + delete(content, TypeField) + workload := unstructured.Unstructured{ + Object: content, + } + // find out the GVK from the CRD definition and set + apiVersion := metav1.GroupVersion{ + Group: customResourceDefinition.Spec.Group, + Version: customResourceDefinition.Spec.Versions[0].Name, + }.String() + workload.SetAPIVersion(apiVersion) + workload.SetKind(customResourceDefinition.Spec.Names.Kind) + mutatelog.Info("Set the component workload GVK", "workload api version", workload.GetAPIVersion(), "workload Kind", workload.GetKind()) + // copy namespace/label/annotation to the workload and add workloadType label + workload.SetNamespace(obj.GetNamespace()) + workload.SetLabels(util.MergeMap(obj.GetLabels(), map[string]string{WorkloadTypeLabel: workloadType})) + workload.SetAnnotations(obj.GetAnnotations()) + // copy back the object + rawBye, err := json.Marshal(workload.Object) + if err != nil { + return err + } + obj.Spec.Workload.Raw = rawBye + } + + return nil +} + +var _ inject.Client = &MutatingHandler{} + +// InjectClient injects the client into the ComponentMutatingHandler +func (h *MutatingHandler) InjectClient(c client.Client) error { + h.Client = c + return nil +} + +var _ admission.DecoderInjector = &MutatingHandler{} + +// InjectDecoder injects the decoder into the ComponentMutatingHandler +func (h *MutatingHandler) InjectDecoder(d *admission.Decoder) error { + h.Decoder = d + return nil +} + +// RegisterMutatingHandler will register component mutation handler to the webhook +func RegisterMutatingHandler(mgr manager.Manager) { + server := mgr.GetWebhookServer() + server.Register("/mutating-core-oam-dev-v1alpha2-components", &webhook.Admission{Handler: &MutatingHandler{}}) +} diff --git a/pkg/webhook/v1alpha2/component/validating_handler.go b/pkg/webhook/v1alpha2/component/validating_handler.go new file mode 100644 index 00000000..c755c5a3 --- /dev/null +++ b/pkg/webhook/v1alpha2/component/validating_handler.go @@ -0,0 +1,127 @@ +/* +Copyright 2019 The Kruise 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 component + +import ( + "context" + "encoding/json" + "fmt" + + admissionv1beta1 "k8s.io/api/admission/v1beta1" + apimachineryvalidation "k8s.io/apimachinery/pkg/api/validation" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/validation/field" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/crossplane/oam-kubernetes-runtime/apis/core/v1alpha2" +) + +// ValidatingHandler handles Component +type ValidatingHandler struct { + // To use the client, you need to do the following: + // - uncomment it + // - import sigs.k8s.io/controller-runtime/pkg/client + // - uncomment the InjectClient method at the bottom of this file. + // Client client.Client + + // Decoder decodes objects + Decoder *admission.Decoder +} + +// log is for logging in this package. +var validatelog = logf.Log.WithName("component validate webhook") + +var _ admission.Handler = &ValidatingHandler{} + +// Handle handles admission requests. +func (h *ValidatingHandler) Handle(ctx context.Context, req admission.Request) admission.Response { + obj := &v1alpha2.Component{} + + err := h.Decoder.Decode(req, obj) + if err != nil { + validatelog.Error(err, "decoder failed", "req operation", req.AdmissionRequest.Operation, "req", + req.AdmissionRequest) + return admission.Denied(err.Error()) + } + + switch req.AdmissionRequest.Operation { + case admissionv1beta1.Create: + if allErrs := ValidateComponentObject(obj); len(allErrs) > 0 { + validatelog.Info("create failed", "name", obj.Name, "errMsg", allErrs.ToAggregate().Error()) + return admission.Denied(allErrs.ToAggregate().Error()) + } + case admissionv1beta1.Update: + if allErrs := ValidateComponentObject(obj); len(allErrs) > 0 { + validatelog.Info("update failed", "name", obj.Name, "errMsg", allErrs.ToAggregate().Error()) + return admission.Denied(allErrs.ToAggregate().Error()) + } + } + + return admission.Allowed("") +} + +// ValidateComponentObject validates the Component on creation +func ValidateComponentObject(obj *v1alpha2.Component) field.ErrorList { + validatelog.Info("validate component", "name", obj.Name) + allErrs := apimachineryvalidation.ValidateObjectMeta(&obj.ObjectMeta, true, + apimachineryvalidation.NameIsDNSSubdomain, field.NewPath("metadata")) + fldPath := field.NewPath("spec") + var content map[string]interface{} + if err := json.Unmarshal(obj.Spec.Workload.Raw, &content); err != nil { + allErrs = append(allErrs, field.Invalid(fldPath.Child("workload"), string(obj.Spec.Workload.Raw), + "the workload is malformat")) + return allErrs + } + if content[TypeField] != nil { + allErrs = append(allErrs, field.Invalid(fldPath.Child("workload"), string(obj.Spec.Workload.Raw), + "the workload contains type info")) + } + workload := unstructured.Unstructured{ + Object: content, + } + if len(workload.GetAPIVersion()) == 0 || len(workload.GetKind()) == 0 { + allErrs = append(allErrs, field.Invalid(fldPath.Child("workload"), content, + fmt.Sprintf("the workload data missing GVK, api = %s, kind = %s,", workload.GetAPIVersion(), workload.GetKind()))) + } + return allErrs +} + +/* +var _ inject.Client = &ValidatingHandler{} + +// InjectClient injects the client into the ComponentValidatingHandler +func (h *ValidatingHandler) InjectClient(c client.Client) error { + h.Client = c + return nil +} +*/ +var _ admission.DecoderInjector = &ValidatingHandler{} + +// InjectDecoder injects the decoder into the ComponentValidatingHandler +func (h *ValidatingHandler) InjectDecoder(d *admission.Decoder) error { + h.Decoder = d + return nil +} + +// RegisterValidatingHandler will regsiter component mutation handler to the webhook +func RegisterValidatingHandler(mgr manager.Manager) { + server := mgr.GetWebhookServer() + server.Register("/validating-core-oam-dev-v1alpha2-components", &webhook.Admission{Handler: &ValidatingHandler{}}) +}