diff --git a/OWNERS b/OWNERS index 9e831d51..bec8eb4a 100644 --- a/OWNERS +++ b/OWNERS @@ -3,17 +3,21 @@ approvers: - alexeykazakov - MatousJobanek -- sbryzak - xcoulon - rajivnathan - ranakan19 - mfrancisc +- fbm3307 +- metlos +- rsoaresd reviewers: - alexeykazakov - MatousJobanek -- sbryzak - xcoulon - rajivnathan - ranakan19 -- mfrancisc \ No newline at end of file +- mfrancisc +- fbm3307 +- metlos +- rsoaresd \ No newline at end of file diff --git a/README.adoc b/README.adoc index e3dc2449..1ed2832f 100644 --- a/README.adoc +++ b/README.adoc @@ -4,7 +4,7 @@ image:https://goreportcard.com/badge/github.com/codeready-toolchain/member-opera image:https://godoc.org/github.com/codeready-toolchain/member-operator?status.png[GoDoc,link="https://godoc.org/github.com/codeready-toolchain/member-operator"] image:https://codecov.io/gh/codeready-toolchain/member-operator/branch/master/graph/badge.svg[Codecov.io,link="https://codecov.io/gh/codeready-toolchain/member-operator"] image:https://github.com/codeready-toolchain/member-operator/actions/workflows/operator-cd.yml/badge.svg[Operator CD,link="https://github.com/codeready-toolchain/member-operator/actions/workflows/operator-cd.yml"] -image:https://quay.io/repository/codeready-toolchain/member-operator/status["Docker Repository on Quay", link="https://quay.io/repository/codeready-toolchain/member-operator"] +image:https://quay.io/repository/codeready-toolchain/member-operator/status["Image Repository on Quay", link="https://quay.io/repository/codeready-toolchain/member-operator"] This is the CodeReady Toolchain Member Operator repository. It contains the OpenShift Operator that is deployed on the "member" cluster in the SaaS. @@ -32,7 +32,7 @@ then the release has to be fixed manually. In such a case, please follow these s 1. Log in to quay.io using an account that has the write permissions in quay.io/codeready-toolchain/member-operator repo. 2. Checkout to the problematic (missing) commit that failed in the pipeline and that has to be manually released. -3. Run `make docker-push QUAY_NAMESPACE=codeready-toolchain` +3. Run `make podman-push QUAY_NAMESPACE=codeready-toolchain` 4. Run `make push-to-quay-staging QUAY_NAMESPACE=codeready-toolchain` @@ -69,7 +69,7 @@ There are two Makefile targets that will execute the e2e tests: * `make test-e2e-local` - this target doesn't clone anything, but it runs run e2e tests for both operators from the directory `../toolchain-e2e`. As deployment for `member-operator` it uses the current code that is in the local repository. The tests executed within https://github.com/codeready-toolchain/toolchain-e2e[toolchain-e2e] repo will take care of creating all needed namespaces with random names (or see below for enforcing some specific namespace names). -It will also create all required CRDs, role and role bindings for the service accounts, build the Docker images for both operators and push them to the OpenShift container registry. Finally, it will deploy the operators and run the tests using the operator-sdk. +It will also create all required CRDs, role and role bindings for the service accounts, build the images for both operators and push them to the image registry. Finally, it will deploy the operators and run the tests. NOTE: you can override the default namespace names where the end-to-end tests are going to be executed - eg.: `make test-e2e HOST_NS=my-host MEMBER_NS=my-member` file. diff --git a/cmd/webhook/main.go b/cmd/webhook/main.go index f289f0ce..4970d47a 100644 --- a/cmd/webhook/main.go +++ b/cmd/webhook/main.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "flag" + "fmt" "net/http" "os" "os/signal" @@ -106,6 +107,9 @@ func main() { sspRequestValidator := &validatingwebhook.SSPRequestValidator{ Client: cl, } + vmRequestValidator := &validatingwebhook.VMRequestValidator{ + Client: cl, + } mux := http.NewServeMux() mux.HandleFunc("/mutate-users-pods", mutatingwebhook.HandleMutateUserPods) @@ -113,6 +117,7 @@ func main() { mux.HandleFunc("/validate-users-rolebindings", rolebindingValidator.HandleValidate) mux.HandleFunc("/validate-spacebindingrequests", spacebindingrequestValidator.HandleValidate) mux.HandleFunc("/validate-ssprequests", sspRequestValidator.HandleValidate) // SSP is a CNV specific resource + mux.HandleFunc("/validate-vmrequests", vmRequestValidator.HandleValidate) webhookServer := &http.Server{ //nolint:gosec //TODO: configure ReadHeaderTimeout (gosec G112) Addr: ":8443", @@ -120,6 +125,14 @@ func main() { TLSConfig: &tls.Config{ MinVersion: tls.VersionTLS12, NextProtos: []string{"http/1.1"}, // disable HTTP/2 for now + + GetCertificate: func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { + cert, err := tls.LoadX509KeyPair("/etc/webhook/certs/"+cert.ServerCert, "/etc/webhook/certs/"+cert.ServerKey) + if err != nil { + return nil, fmt.Errorf("could not load TLS certs: %w", err) + } + return &cert, err + }, }, } @@ -127,7 +140,7 @@ func main() { go func() { setupLog.Info("Listening...") - if err := webhookServer.ListenAndServeTLS("/etc/webhook/certs/"+cert.ServerCert, "/etc/webhook/certs/"+cert.ServerKey); err != nil { + if err := webhookServer.ListenAndServeTLS("", ""); err != nil { setupLog.Error(err, "Listening and serving TLS failed") os.Exit(1) } diff --git a/deploy/webhook/member-operator-webhook.yaml b/deploy/webhook/member-operator-webhook.yaml index b4519c33..8fffbe8f 100644 --- a/deploy/webhook/member-operator-webhook.yaml +++ b/deploy/webhook/member-operator-webhook.yaml @@ -180,7 +180,7 @@ objects: rules: - operations: ["CREATE"] apiGroups: ["kubevirt.io"] - apiVersions: ["v1"] + apiVersions: ["*"] resources: ["virtualmachines"] scope: "Namespaced" sideEffects: None @@ -251,6 +251,9 @@ objects: namespaceSelector: matchLabels: toolchain.dev.openshift.com/provider: codeready-toolchain + # The users.virtualmachines.ssp.webhook.sandbox webhook validates SSP CRs, + # Specifically it blocks the creation/update of SSP resources by sandbox users because it should only be managed by the Virtualization operator + # The webhook code is available at member-operator/pkg/webhook/validatingwebhook/validate_ssp_request.go - name: users.virtualmachines.ssp.webhook.sandbox admissionReviewVersions: - v1 @@ -275,6 +278,33 @@ objects: namespaceSelector: matchLabels: toolchain.dev.openshift.com/provider: codeready-toolchain + # The users.virtualmachines.validating.webhook.sandbox webhook validates VirtualMachine CRs, + # Specifically it blocks the creation/update of VirtualMachine resources that have '.spec.RunStrategy' set because it interferes with the Idler. + # The webhook code is available at member-operator/pkg/webhook/validatingwebhook/validate_vm_request.go + - name: users.virtualmachines.validating.webhook.sandbox + admissionReviewVersions: + - v1 + clientConfig: + caBundle: ${CA_BUNDLE} + service: + name: member-operator-webhook + namespace: ${NAMESPACE} + path: "/validate-vmrequests" + port: 443 + matchPolicy: Equivalent + rules: + - operations: ["CREATE", "UPDATE"] + apiGroups: ["kubevirt.io"] + apiVersions: ["*"] + resources: ["virtualmachines"] + scope: "Namespaced" + sideEffects: None + timeoutSeconds: 5 + reinvocationPolicy: Never + failurePolicy: Fail + namespaceSelector: + matchLabels: + toolchain.dev.openshift.com/provider: codeready-toolchain parameters: - name: NAMESPACE value: 'toolchain-member-operator' diff --git a/make/docker.mk b/make/docker.mk deleted file mode 100644 index a249d58c..00000000 --- a/make/docker.mk +++ /dev/null @@ -1,61 +0,0 @@ -QUAY_NAMESPACE ?= ${GO_PACKAGE_ORG_NAME} -TARGET_REGISTRY := quay.io -IMAGE_TAG ?= ${GIT_COMMIT_ID_SHORT} -IMAGE ?= ${TARGET_REGISTRY}/${QUAY_NAMESPACE}/${GO_PACKAGE_REPO_NAME}:${IMAGE_TAG} -QUAY_USERNAME ?= ${QUAY_NAMESPACE} -WEBHOOK_IMAGE ?= ${TARGET_REGISTRY}/${QUAY_NAMESPACE}/${GO_PACKAGE_REPO_NAME}-webhook:${IMAGE_TAG} -IMAGE_PLATFORM ?= linux/amd64 - -.PHONY: docker-image -## Build the binary image -docker-image: build - $(Q)docker build --platform ${IMAGE_PLATFORM} -f build/Dockerfile -t ${IMAGE} . - $(Q)docker build --platform ${IMAGE_PLATFORM} -f build/Dockerfile.webhook -t ${WEBHOOK_IMAGE} . - -.PHONY: docker-push -## Push the binary image to quay.io registry -docker-push: check-namespace docker-image - $(Q)docker push ${IMAGE} - $(Q)docker push ${WEBHOOK_IMAGE} - -.PHONY: podman-image -## Build the binary image -podman-image: build - $(Q)podman build --platform ${IMAGE_PLATFORM} -f build/Dockerfile -t ${IMAGE} . - $(Q)podman build --platform ${IMAGE_PLATFORM} -f build/Dockerfile.webhook -t ${WEBHOOK_IMAGE} . - -.PHONY: podman-push -## Push the binary image to quay.io registry -podman-push: check-namespace podman-image - $(Q)podman push ${IMAGE} - $(Q)podman push ${WEBHOOK_IMAGE} - -.PHONY: check-namespace -check-namespace: -ifeq ($(QUAY_NAMESPACE),${GO_PACKAGE_ORG_NAME}) - @echo "#################################################### WARNING ####################################################" - @echo you are going to push to $(QUAY_NAMESPACE) namespace, make sure you have set QUAY_NAMESPACE variable appropriately - @echo "#################################################################################################################" -endif - -.PHONY: docker-push-to-local -## Push the docker image to the local docker.io registry -docker-push-to-local: set-local-registry docker-image docker-push - -.PHONY: set-local-registry -## Sets TARGET_REGISTRY:=docker.io -set-local-registry: - $(eval TARGET_REGISTRY:=docker.io) - -.PHONY: docker-push-to-os -## Push the docker image to the OS internal registry -docker-push-to-os: set-os-registry docker-image docker-push - -.PHONY: set-os-registry -## Sets TARGET_REGISTRY:=$(shell oc get images.config.openshift.io/cluster -o jsonpath={.status.externalRegistryHostnames[0]}) -set-os-registry: - $(eval TARGET_REGISTRY:=$(shell oc get images.config.openshift.io/cluster -o jsonpath={.status.externalRegistryHostnames[0]})) - -.PHONY: docker-login -docker-login: - @echo "${DOCKER_PASSWORD}" | docker login quay.io -u "${QUAY_USERNAME}" --password-stdin \ No newline at end of file diff --git a/make/podman.mk b/make/podman.mk new file mode 100644 index 00000000..e637e7bc --- /dev/null +++ b/make/podman.mk @@ -0,0 +1,27 @@ +QUAY_NAMESPACE ?= ${GO_PACKAGE_ORG_NAME} +TARGET_REGISTRY := quay.io +IMAGE_TAG ?= ${GIT_COMMIT_ID_SHORT} +IMAGE ?= ${TARGET_REGISTRY}/${QUAY_NAMESPACE}/${GO_PACKAGE_REPO_NAME}:${IMAGE_TAG} +QUAY_USERNAME ?= ${QUAY_NAMESPACE} +WEBHOOK_IMAGE ?= ${TARGET_REGISTRY}/${QUAY_NAMESPACE}/${GO_PACKAGE_REPO_NAME}-webhook:${IMAGE_TAG} +IMAGE_PLATFORM ?= linux/amd64 + +.PHONY: podman-image +## Build the binary image +podman-image: build + $(Q)podman build --platform ${IMAGE_PLATFORM} -f build/Dockerfile -t ${IMAGE} . + $(Q)podman build --platform ${IMAGE_PLATFORM} -f build/Dockerfile.webhook -t ${WEBHOOK_IMAGE} . + +.PHONY: podman-push +## Push the binary image to quay.io registry +podman-push: check-namespace podman-image + $(Q)podman push ${IMAGE} + $(Q)podman push ${WEBHOOK_IMAGE} + +.PHONY: check-namespace +check-namespace: +ifeq ($(QUAY_NAMESPACE),${GO_PACKAGE_ORG_NAME}) + @echo "#################################################### WARNING ####################################################" + @echo you are going to push to $(QUAY_NAMESPACE) namespace, make sure you have set QUAY_NAMESPACE variable appropriately + @echo "#################################################################################################################" +endif diff --git a/pkg/webhook/deploy/deployment_test.go b/pkg/webhook/deploy/deployment_test.go index 8d135cdf..4c968f7b 100644 --- a/pkg/webhook/deploy/deployment_test.go +++ b/pkg/webhook/deploy/deployment_test.go @@ -223,11 +223,11 @@ func deployment(namespace, sa string, image string) string { } func mutatingWebhookConfig(namespace, caBundle string) string { - return fmt.Sprintf(`{"apiVersion":"admissionregistration.k8s.io/v1","kind":"MutatingWebhookConfiguration","metadata":{"name":"member-operator-webhook-%[2]s","labels":{"app":"member-operator-webhook","toolchain.dev.openshift.com/provider":"codeready-toolchain"}},"webhooks":[{"name":"users.pods.webhook.sandbox","admissionReviewVersions":["v1"],"clientConfig":{"caBundle":"%[1]s","service":{"name":"member-operator-webhook","namespace":"%[2]s","path":"/mutate-users-pods","port":443}},"matchPolicy":"Equivalent","rules":[{"operations":["CREATE"],"apiGroups":[""],"apiVersions":["v1"],"resources":["pods"],"scope":"Namespaced"}],"sideEffects":"None","timeoutSeconds":5,"reinvocationPolicy":"Never","failurePolicy":"Ignore","namespaceSelector":{"matchLabels":{"toolchain.dev.openshift.com/provider":"codeready-toolchain"}}},{"name":"users.virtualmachines.webhook.sandbox","admissionReviewVersions":["v1"],"clientConfig":{"caBundle":"%[1]s","service":{"name":"member-operator-webhook","namespace":"%[2]s","path":"/mutate-virtual-machines","port":443}},"matchPolicy":"Equivalent","rules":[{"operations":["CREATE"],"apiGroups":["kubevirt.io"],"apiVersions":["v1"],"resources":["virtualmachines"],"scope":"Namespaced"}],"sideEffects":"None","timeoutSeconds":5,"reinvocationPolicy":"Never","failurePolicy":"Fail","namespaceSelector":{"matchLabels":{"toolchain.dev.openshift.com/provider":"codeready-toolchain"}}}]}`, caBundle, namespace) + return fmt.Sprintf(`{"apiVersion":"admissionregistration.k8s.io/v1","kind":"MutatingWebhookConfiguration","metadata":{"name":"member-operator-webhook-%[2]s","labels":{"app":"member-operator-webhook","toolchain.dev.openshift.com/provider":"codeready-toolchain"}},"webhooks":[{"name":"users.pods.webhook.sandbox","admissionReviewVersions":["v1"],"clientConfig":{"caBundle":"%[1]s","service":{"name":"member-operator-webhook","namespace":"%[2]s","path":"/mutate-users-pods","port":443}},"matchPolicy":"Equivalent","rules":[{"operations":["CREATE"],"apiGroups":[""],"apiVersions":["v1"],"resources":["pods"],"scope":"Namespaced"}],"sideEffects":"None","timeoutSeconds":5,"reinvocationPolicy":"Never","failurePolicy":"Ignore","namespaceSelector":{"matchLabels":{"toolchain.dev.openshift.com/provider":"codeready-toolchain"}}},{"name":"users.virtualmachines.webhook.sandbox","admissionReviewVersions":["v1"],"clientConfig":{"caBundle":"%[1]s","service":{"name":"member-operator-webhook","namespace":"%[2]s","path":"/mutate-virtual-machines","port":443}},"matchPolicy":"Equivalent","rules":[{"operations":["CREATE"],"apiGroups":["kubevirt.io"],"apiVersions":["*"],"resources":["virtualmachines"],"scope":"Namespaced"}],"sideEffects":"None","timeoutSeconds":5,"reinvocationPolicy":"Never","failurePolicy":"Fail","namespaceSelector":{"matchLabels":{"toolchain.dev.openshift.com/provider":"codeready-toolchain"}}}]}`, caBundle, namespace) } func validatingWebhookConfig(namespace, caBundle string) string { - return fmt.Sprintf(`{"apiVersion": "admissionregistration.k8s.io/v1","kind": "ValidatingWebhookConfiguration","metadata": {"labels": {"app": "member-operator-webhook","toolchain.dev.openshift.com/provider": "codeready-toolchain"},"name": "member-operator-validating-webhook-%[2]s"},"webhooks": [{"admissionReviewVersions": ["v1"],"clientConfig": {"caBundle": "%[1]s","service": {"name": "member-operator-webhook","namespace": "%[2]s","path": "/validate-users-rolebindings","port": 443}},"failurePolicy": "Ignore","matchPolicy": "Equivalent","name": "users.rolebindings.webhook.sandbox","namespaceSelector": {"matchLabels": {"toolchain.dev.openshift.com/provider": "codeready-toolchain"}},"reinvocationPolicy": "Never","rules": [{"apiGroups": ["rbac.authorization.k8s.io","authorization.openshift.io"],"apiVersions": ["v1"],"operations": ["CREATE","UPDATE"],"resources": ["rolebindings"],"scope": "Namespaced"}],"sideEffects": "None","timeoutSeconds": 5},{"admissionReviewVersions": ["v1"],"clientConfig": {"caBundle": "%[1]s","service": {"name": "member-operator-webhook","namespace": "%[2]s","path": "/validate-spacebindingrequests","port": 443}},"failurePolicy": "Fail","matchPolicy": "Equivalent","name": "users.spacebindingrequests.webhook.sandbox","namespaceSelector": {"matchLabels": {"toolchain.dev.openshift.com/provider": "codeready-toolchain"}},"reinvocationPolicy": "Never","rules": [{"apiGroups": ["toolchain.dev.openshift.com"],"apiVersions": ["v1alpha1"],"operations": ["CREATE","UPDATE"],"resources": ["spacebindingrequests"],"scope": "Namespaced"}],"sideEffects": "None","timeoutSeconds": 5},{"admissionReviewVersions": ["v1"],"clientConfig": {"caBundle": "%[1]s","service": {"name": "member-operator-webhook","namespace": "%[2]s","path": "/validate-ssprequests","port": 443}},"failurePolicy": "Fail","matchPolicy": "Equivalent","name": "users.virtualmachines.ssp.webhook.sandbox","namespaceSelector": {"matchLabels": {"toolchain.dev.openshift.com/provider": "codeready-toolchain"}},"reinvocationPolicy": "Never","rules": [{"apiGroups": ["ssp.kubevirt.io"],"apiVersions": ["*"],"operations": ["CREATE","UPDATE"],"resources": ["ssps"],"scope": "Namespaced"}],"sideEffects": "None","timeoutSeconds": 5}]}`, caBundle, namespace) + return fmt.Sprintf(`{"apiVersion": "admissionregistration.k8s.io/v1","kind": "ValidatingWebhookConfiguration","metadata": {"labels": {"app": "member-operator-webhook","toolchain.dev.openshift.com/provider": "codeready-toolchain"},"name": "member-operator-validating-webhook-%[2]s"},"webhooks": [{"admissionReviewVersions": ["v1"],"clientConfig": {"caBundle": "%[1]s","service": {"name": "member-operator-webhook","namespace": "%[2]s","path": "/validate-users-rolebindings","port": 443}},"failurePolicy": "Ignore","matchPolicy": "Equivalent","name": "users.rolebindings.webhook.sandbox","namespaceSelector": {"matchLabels": {"toolchain.dev.openshift.com/provider": "codeready-toolchain"}},"reinvocationPolicy": "Never","rules": [{"apiGroups": ["rbac.authorization.k8s.io","authorization.openshift.io"],"apiVersions": ["v1"],"operations": ["CREATE","UPDATE"],"resources": ["rolebindings"],"scope": "Namespaced"}],"sideEffects": "None","timeoutSeconds": 5},{"admissionReviewVersions": ["v1"],"clientConfig": {"caBundle": "%[1]s","service": {"name": "member-operator-webhook","namespace": "%[2]s","path": "/validate-spacebindingrequests","port": 443}},"failurePolicy": "Fail","matchPolicy": "Equivalent","name": "users.spacebindingrequests.webhook.sandbox","namespaceSelector": {"matchLabels": {"toolchain.dev.openshift.com/provider": "codeready-toolchain"}},"reinvocationPolicy": "Never","rules": [{"apiGroups": ["toolchain.dev.openshift.com"],"apiVersions": ["v1alpha1"],"operations": ["CREATE","UPDATE"],"resources": ["spacebindingrequests"],"scope": "Namespaced"}],"sideEffects": "None","timeoutSeconds": 5},{"admissionReviewVersions": ["v1"],"clientConfig": {"caBundle": "%[1]s","service": {"name": "member-operator-webhook","namespace": "%[2]s","path": "/validate-ssprequests","port": 443}},"failurePolicy": "Fail","matchPolicy": "Equivalent","name": "users.virtualmachines.ssp.webhook.sandbox","namespaceSelector": {"matchLabels": {"toolchain.dev.openshift.com/provider": "codeready-toolchain"}},"reinvocationPolicy": "Never","rules": [{"apiGroups": ["ssp.kubevirt.io"],"apiVersions": ["*"],"operations": ["CREATE","UPDATE"],"resources": ["ssps"],"scope": "Namespaced"}],"sideEffects": "None","timeoutSeconds": 5},{"admissionReviewVersions": ["v1"],"clientConfig": {"caBundle": "%[1]s","service": {"name": "member-operator-webhook","namespace": "%[2]s","path": "/validate-vmrequests","port": 443}},"failurePolicy": "Fail","matchPolicy": "Equivalent","name": "users.virtualmachines.validating.webhook.sandbox","namespaceSelector": {"matchLabels": {"toolchain.dev.openshift.com/provider": "codeready-toolchain"}},"reinvocationPolicy": "Never","rules": [{"apiGroups": ["kubevirt.io"],"apiVersions": ["*"],"operations": ["CREATE","UPDATE"],"resources": ["virtualmachines"],"scope": "Namespaced"}],"sideEffects": "None","timeoutSeconds": 5}]}`, caBundle, namespace) } func serviceAccount(namespace string) string { diff --git a/pkg/webhook/validatingwebhook/test/verify_utils.go b/pkg/webhook/validatingwebhook/test/verify_utils.go index 3aecef3f..11c39a56 100644 --- a/pkg/webhook/validatingwebhook/test/verify_utils.go +++ b/pkg/webhook/validatingwebhook/test/verify_utils.go @@ -12,9 +12,9 @@ import ( func VerifyRequestBlocked(t *testing.T, response []byte, msg string, UID string) { reviewResponse := toReviewResponse(t, response) assert.False(t, reviewResponse.Allowed) - assert.NotEmpty(t, reviewResponse.Result) - assert.Contains(t, reviewResponse.Result.Message, msg) assert.Equal(t, UID, string(reviewResponse.UID)) + require.NotEmpty(t, reviewResponse.Result) + assert.Contains(t, reviewResponse.Result.Message, msg) } func VerifyRequestAllowed(t *testing.T, response []byte, UID string) { diff --git a/pkg/webhook/validatingwebhook/validate_ssp_request_test.go b/pkg/webhook/validatingwebhook/validate_ssp_request_test.go index 32c0ec9a..43306bcc 100644 --- a/pkg/webhook/validatingwebhook/validate_ssp_request_test.go +++ b/pkg/webhook/validatingwebhook/validate_ssp_request_test.go @@ -54,7 +54,7 @@ func TestValidateSSPAdmissionRequest(t *testing.T) { t.Run("sandbox user trying to update a SSP resource is denied", func(t *testing.T) { // given v := newSSPRequestValidator(t, "johnsmith", true) - req := newCreateSSPAdmissionRequest(t, SSPAdmReviewTmplParams{"CREATE", "johnsmith"}) + req := newCreateSSPAdmissionRequest(t, SSPAdmReviewTmplParams{"UPDATE", "johnsmith"}) // when response := v.validate(context.TODO(), req) diff --git a/pkg/webhook/validatingwebhook/validate_vm_request.go b/pkg/webhook/validatingwebhook/validate_vm_request.go new file mode 100644 index 00000000..b68753cd --- /dev/null +++ b/pkg/webhook/validatingwebhook/validate_vm_request.go @@ -0,0 +1,76 @@ +package validatingwebhook + +import ( + "html" + "io" + "net/http" + + "github.com/pkg/errors" + admissionv1 "k8s.io/api/admission/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + runtimeClient "sigs.k8s.io/controller-runtime/pkg/client" +) + +type VMRequestValidator struct { + Client runtimeClient.Client +} + +func (v VMRequestValidator) HandleValidate(w http.ResponseWriter, r *http.Request) { + var respBody []byte + body, err := io.ReadAll(r.Body) + defer func() { + if err := r.Body.Close(); err != nil { + log.Error(err, "unable to close the body") + } + }() + if err != nil { + log.Error(err, "unable to read the body of the request") + w.WriteHeader(http.StatusInternalServerError) + respBody = []byte("unable to read the body of the request") + } else { + // validate the request + respBody = v.validate(body) + w.WriteHeader(http.StatusOK) + } + if _, err := io.WriteString(w, string(respBody)); err != nil { + log.Error(err, "unable to write response") + } +} + +func (v VMRequestValidator) validate(body []byte) []byte { + log.Info("incoming request", "body", string(body)) + admReview := admissionv1.AdmissionReview{} + if _, _, err := deserializer.Decode(body, nil, &admReview); err != nil { + // sanitize the body + escapedBody := html.EscapeString(string(body)) + log.Error(err, "unable to deserialize the admission review object", "body", escapedBody) + return denyAdmissionRequest(admReview, errors.Wrapf(err, "unable to deserialize the admission review object - body: %v", escapedBody)) + } + + unstructuredRequestObj := &unstructured.Unstructured{} + if err := unstructuredRequestObj.UnmarshalJSON(admReview.Request.Object.Raw); err != nil { + log.Error(err, "unable to check runStrategy in VirtualMachine", "VirtualMachine", unstructuredRequestObj) + return denyAdmissionRequest(admReview, errors.New("failed to validate VirtualMachine request")) + } + + hasRunStrategy, err := hasRunningStrategy(unstructuredRequestObj) + if err != nil { + log.Error(err, "failed to unmarshal VirtualMachine json object", "AdmissionReview", admReview) + return denyAdmissionRequest(admReview, errors.New("failed to validate VirtualMachine request")) + } + if hasRunStrategy { + log.Info("sandbox user is trying to create a VM with RunStrategy configured", "AdmissionReview", admReview) // not allowed because it interferes with the Dev Sandbox Idler + return denyAdmissionRequest(admReview, errors.New("this is a Dev Sandbox enforced restriction. Configuring RunStrategy is not allowed")) + } + // the user is not creating a VM with the 'runStrategy' configured, allowing the request. + return allowAdmissionRequest(admReview) +} + +func hasRunningStrategy(unstructuredObj *unstructured.Unstructured) (bool, error) { + _, runStrategyFound, err := unstructured.NestedString(unstructuredObj.Object, "spec", "runStrategy") + if err != nil { + return runStrategyFound, err + } + + return runStrategyFound, nil +} diff --git a/pkg/webhook/validatingwebhook/validate_vm_request_test.go b/pkg/webhook/validatingwebhook/validate_vm_request_test.go new file mode 100644 index 00000000..87dd2d5c --- /dev/null +++ b/pkg/webhook/validatingwebhook/validate_vm_request_test.go @@ -0,0 +1,224 @@ +package validatingwebhook + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" + "text/template" + + "github.com/codeready-toolchain/member-operator/pkg/webhook/validatingwebhook/test" + + userv1 "github.com/openshift/api/user/v1" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestHandleValidateVMAdmissionRequestBlocked(t *testing.T) { + v := newVMRequestValidator(t) + // given + ts := httptest.NewServer(http.HandlerFunc(v.HandleValidate)) + defer ts.Close() + + t.Run("sandbox user trying to create a VM resource with RunStrategy is denied", func(t *testing.T) { + // when + resp, err := http.Post(ts.URL, "application/json", bytes.NewBuffer(newCreateVMAdmissionRequest(t, VMAdmReviewTmplParams{"CREATE", "johnsmith"}, createVMWithRunStrategyJSONTmpl))) + + // then + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + defer func() { + require.NoError(t, resp.Body.Close()) + }() + require.NoError(t, err) + test.VerifyRequestBlocked(t, body, "this is a Dev Sandbox enforced restriction. Configuring RunStrategy is not allowed", "b6ae2ab4-782b-11ee-b962-0242ac120002") + }) + + t.Run("sandbox user trying to update a VM resource with RunStrategy is denied", func(t *testing.T) { + // when + resp, err := http.Post(ts.URL, "application/json", bytes.NewBuffer(newCreateVMAdmissionRequest(t, VMAdmReviewTmplParams{"UPDATE", "johnsmith"}, createVMWithRunStrategyJSONTmpl))) + + // then + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + defer func() { + require.NoError(t, resp.Body.Close()) + }() + require.NoError(t, err) + test.VerifyRequestBlocked(t, body, "this is a Dev Sandbox enforced restriction. Configuring RunStrategy is not allowed", "b6ae2ab4-782b-11ee-b962-0242ac120002") + }) + + t.Run("sandbox user trying to create a VM resource without RunStrategy is allowed", func(t *testing.T) { + // when + resp, err := http.Post(ts.URL, "application/json", bytes.NewBuffer(newCreateVMAdmissionRequest(t, VMAdmReviewTmplParams{"CREATE", "johnsmith"}, createVMWithoutRunStrategyJSONTmpl))) + + // then + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + defer func() { + require.NoError(t, resp.Body.Close()) + }() + require.NoError(t, err) + + test.VerifyRequestAllowed(t, body, "b6ae2ab4-782b-11ee-b962-0242ac120002") + }) + + t.Run("sandbox user trying to update a VM resource without RunStrategy is allowed", func(t *testing.T) { + // when + resp, err := http.Post(ts.URL, "application/json", bytes.NewBuffer(newCreateVMAdmissionRequest(t, VMAdmReviewTmplParams{"UPDATE", "johnsmith"}, createVMWithoutRunStrategyJSONTmpl))) + + // then + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + defer func() { + require.NoError(t, resp.Body.Close()) + }() + require.NoError(t, err) + + test.VerifyRequestAllowed(t, body, "b6ae2ab4-782b-11ee-b962-0242ac120002") + }) + +} + +func newVMRequestValidator(t *testing.T) *VMRequestValidator { + s := scheme.Scheme + err := userv1.Install(s) + require.NoError(t, err) + testUser := &userv1.User{ + ObjectMeta: metav1.ObjectMeta{ + Name: "johnsmith", + }, + } + + cl := fake.NewClientBuilder().WithScheme(s).WithObjects(testUser).Build() + return &VMRequestValidator{ + Client: cl, + } + +} + +func newCreateVMAdmissionRequest(t *testing.T, params VMAdmReviewTmplParams, tmplJSON string) []byte { + tmpl, err := template.New("admission request").Parse(tmplJSON) + require.NoError(t, err) + req := &bytes.Buffer{} + err = tmpl.Execute(req, params) + require.NoError(t, err) + return req.Bytes() +} + +type VMAdmReviewTmplParams struct { + ReqType string + Username string +} + +var createVMWithRunStrategyJSONTmpl = `{ + "kind": "AdmissionReview", + "apiVersion": "admission.k8s.io/v1", + "request": { + "uid": "b6ae2ab4-782b-11ee-b962-0242ac120002", + "kind": { + "group": "kubevirt.io", + "version": "v1", + "kind": "VirtualMachine" + }, + "resource": { + "group": "kubevirt.io", + "version": "v1", + "resource": "virtualmachines" + }, + "requestKind": { + "group": "kubevirt.io", + "version": "v1", + "kind": "VirtualMachine" + }, + "requestResource": { + "group": "kubevirt.io", + "version": "v1", + "resource": "virtualmachines" + }, + "name": "test", + "namespace": "{{.Username}}-dev", + "operation": "{{.ReqType}}", + "userInfo": { + "username": "{{.Username}}", + "groups": [ + "system:authenticated" + ] + }, + "object": { + "apiVersion": "kubevirt.io", + "kind": "VirtualMachine", + "metadata": { + "name": "{{.Username}}", + "namespace": "{{.Username}}-dev" + }, + "spec": { + "runStrategy": "Always" + } + }, + "oldObject": null, + "dryRun": false, + "options": { + "kind": "CreateOptions", + "apiVersion": "meta.k8s.io/v1", + "fieldManager": "kubectl-client-side-apply", + "fieldValidation": "Ignore" + } + } +}` + +var createVMWithoutRunStrategyJSONTmpl = `{ + "kind": "AdmissionReview", + "apiVersion": "admission.k8s.io/v1", + "request": { + "uid": "b6ae2ab4-782b-11ee-b962-0242ac120002", + "kind": { + "group": "kubevirt.io", + "version": "v1", + "kind": "VirtualMachine" + }, + "resource": { + "group": "kubevirt.io", + "version": "v1", + "resource": "virtualmachines" + }, + "requestKind": { + "group": "kubevirt.io", + "version": "v1", + "kind": "VirtualMachine" + }, + "requestResource": { + "group": "kubevirt.io", + "version": "v1", + "resource": "virtualmachines" + }, + "name": "test", + "namespace": "{{.Username}}-dev", + "operation": "{{.ReqType}}", + "userInfo": { + "username": "{{.Username}}", + "groups": [ + "system:authenticated" + ] + }, + "object": { + "apiVersion": "kubevirt.io", + "kind": "VirtualMachine", + "metadata": { + "name": "{{.Username}}", + "namespace": "{{.Username}}-dev" + } + }, + "oldObject": null, + "dryRun": false, + "options": { + "kind": "CreateOptions", + "apiVersion": "meta.k8s.io/v1", + "fieldManager": "kubectl-client-side-apply", + "fieldValidation": "Ignore" + } + } +}`