diff --git a/cmd/registration/main.go b/cmd/registration/main.go index 723bee431..5b3869456 100644 --- a/cmd/registration/main.go +++ b/cmd/registration/main.go @@ -15,6 +15,7 @@ import ( "github.com/open-cluster-management/registration/pkg/cmd/hub" "github.com/open-cluster-management/registration/pkg/cmd/spoke" + "github.com/open-cluster-management/registration/pkg/cmd/webhook" "github.com/open-cluster-management/registration/pkg/version" ) @@ -55,6 +56,7 @@ func newRegistrationCommand() *cobra.Command { cmd.AddCommand(hub.NewController()) cmd.AddCommand(spoke.NewAgent()) + cmd.AddCommand(webhook.NewAdmissionHook()) return cmd } diff --git a/deploy/hub/clusterrole.yaml b/deploy/hub/clusterrole.yaml index 8e4fe6fcf..33146cebf 100644 --- a/deploy/hub/clusterrole.yaml +++ b/deploy/hub/clusterrole.yaml @@ -18,6 +18,10 @@ rules: - apiGroups: ["rbac.authorization.k8s.io"] resources: ["clusterroles", "clusterrolebindings", "roles", "rolebindings"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +# Allow hub to create subjectaccessreviews +- apiGroups: ["authorization.k8s.io"] + resources: ["subjectaccessreviews"] + verbs: ["create"] # Allow hub to manage spokeclusters - apiGroups: ["cluster.open-cluster-management.io"] resources: ["spokeclusters"] @@ -28,3 +32,6 @@ rules: - apiGroups: ["register.open-cluster-management.io"] resources: ["spokeclusters/clientcertificates"] verbs: ["renew"] +- apiGroups: ["register.open-cluster-management.io"] + resources: ["spokeclusters/acceptance"] + verbs: ["update"] diff --git a/deploy/hub/crd.yaml b/deploy/hub/crd.yaml index 20856e019..8b0a2a313 100644 --- a/deploy/hub/crd.yaml +++ b/deploy/hub/crd.yaml @@ -54,19 +54,26 @@ spec: to grant the permision of access from agent on spoke. When the value is set false, the namespace representing the spoke cluster is deleted. type: boolean - spokeClientConfig: - description: SpokeClientConfig represents the apiserver address of the - spoke cluster - type: object - properties: - caBundle: - description: CABundle is the ca bundle to connect to apiserver of - the spoke cluster. System certs are used if it is not set. - type: string - format: byte - url: - description: URL is the url of apiserver endpoint of the spoke cluster. - type: string + spokeClientConfigs: + description: SpokeClientConfigs represents a list of the apiserver address + of the spoke cluster. If it is empty, spoke cluster has no accessible + address to be visited from hub. + type: array + items: + description: ClientConfig represents the apiserver address of the + spoke cluster. TODO include credential to connect to spoke cluster + kube-apiserver + type: object + properties: + caBundle: + description: CABundle is the ca bundle to connect to apiserver + of the spoke cluster. System certs are used if it is not set. + type: string + format: byte + url: + description: URL is the url of apiserver endpoint of the spoke + cluster. + type: string status: description: Status represents the current status of joined spoke cluster type: object diff --git a/deploy/webhook/apiservice.yaml b/deploy/webhook/apiservice.yaml new file mode 100644 index 000000000..dc30a2540 --- /dev/null +++ b/deploy/webhook/apiservice.yaml @@ -0,0 +1,13 @@ +apiVersion: apiregistration.k8s.io/v1 +kind: APIService +metadata: + name: v1.admission.cluster.open-cluster-management.io +spec: + group: admission.cluster.open-cluster-management.io + version: v1 + service: + name: spokecluster-admission + namespace: open-cluster-management + insecureSkipTLSVerify: true + groupPriorityMinimum: 10000 + versionPriority: 20 diff --git a/deploy/webhook/deployment.yaml b/deploy/webhook/deployment.yaml new file mode 100644 index 000000000..1fb2bcdaa --- /dev/null +++ b/deploy/webhook/deployment.yaml @@ -0,0 +1,26 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: spokecluster-admission + labels: + app: spokecluster-admission +spec: + replicas: 1 + selector: + matchLabels: + app: spokecluster-admission + template: + metadata: + labels: + app: spokecluster-admission + spec: + serviceAccountName: hub-sa + containers: + - name: spokecluster-admission + image: quay.io/open-cluster-management/registration:latest + imagePullPolicy: IfNotPresent + args: + - "/registration" + - "webhook" + - "--cert-dir=/tmp" + - "--secure-port=6443" diff --git a/deploy/webhook/kustomization.yaml b/deploy/webhook/kustomization.yaml new file mode 100644 index 000000000..bcbe8f19a --- /dev/null +++ b/deploy/webhook/kustomization.yaml @@ -0,0 +1,31 @@ + +# Adds namespace to all resources. +namespace: open-cluster-management + +# Value of this field is prepended to the +# names of all resources, e.g. a deployment named +# "wordpress" becomes "alices-wordpress". +# Note that it should also match with the prefix (text before '-') of the namespace +# field above. +#namePrefix: multicloud- + +# Labels to add to all resources and selectors. +#commonLabels: +# someName: someValue + +# Each entry in this list must resolve to an existing +# resource definition in YAML. These are the resource +# files that kustomize reads, modifies and emits as a +# YAML string, with resources separated by document +# markers ("---"). +# +# General rule here is anything deployed by OLM bundles should go here as well, +# this is used in "make deploy" for developers and should mimic what OLM deploys +# for you. CRDs are an exception to this as we don't want to have to list them all +# here. These are deployed via a "make install" dependency. + +resources: +- ./apiservice.yaml +- ./deployment.yaml +- ./service.yaml +- ./webhook.yaml diff --git a/deploy/webhook/service.yaml b/deploy/webhook/service.yaml new file mode 100644 index 000000000..31409b9f3 --- /dev/null +++ b/deploy/webhook/service.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Service +metadata: + name: spokecluster-admission +spec: + selector: + app: spokecluster-admission + ports: + - port: 443 + targetPort: 6443 diff --git a/deploy/webhook/webhook.yaml b/deploy/webhook/webhook.yaml new file mode 100644 index 000000000..d315ccb42 --- /dev/null +++ b/deploy/webhook/webhook.yaml @@ -0,0 +1,24 @@ +apiVersion: admissionregistration.k8s.io/v1beta1 +kind: ValidatingWebhookConfiguration +metadata: + name: spokeclustervalidators.admission.cluster.open-cluster-management.io +webhooks: +- name: spokeclustervalidators.admission.cluster.open-cluster-management.io + clientConfig: + service: + # reach the webhook via the registered aggregated API + namespace: default + name: kubernetes + path: /apis/admission.cluster.open-cluster-management.io/v1/spokeclustervalidators + rules: + - operations: + - CREATE + - UPDATE + apiGroups: + - cluster.open-cluster-management.io + apiVersions: + - v1 + resources: + - spokeclusters + failurePolicy: Fail + timeoutSeconds: 3 diff --git a/go.mod b/go.mod index ffb138315..a853f8cb9 100644 --- a/go.mod +++ b/go.mod @@ -8,11 +8,13 @@ require ( github.com/onsi/gomega v1.8.1 github.com/open-cluster-management/api v0.0.0-20200512175145-bed9ce79e17e github.com/openshift/build-machinery-go v0.0.0-20200211121458-5e3d6e570160 + github.com/openshift/generic-admission-server v1.14.1-0.20200514123932-ccc9079d8bdb github.com/openshift/library-go v0.0.0-20200401114229-ffab8c6e83a9 github.com/spf13/cobra v0.0.5 github.com/spf13/pflag v1.0.5 k8s.io/api v0.18.2 k8s.io/apimachinery v0.18.2 + k8s.io/apiserver v0.18.2 k8s.io/client-go v0.18.2 k8s.io/component-base v0.18.2 k8s.io/klog v1.0.0 diff --git a/go.sum b/go.sum index 362fe3ee5..9eb8077ee 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,7 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/Azure/go-autorest v11.1.2+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0= github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA= @@ -275,6 +276,7 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk= github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -297,6 +299,8 @@ github.com/openshift/build-machinery-go v0.0.0-20200211121458-5e3d6e570160 h1:V4 github.com/openshift/build-machinery-go v0.0.0-20200211121458-5e3d6e570160/go.mod h1:1CkcsT3aVebzRBzVTSbiKSkJMsC/CASqxesfqEMfJEc= github.com/openshift/client-go v0.0.0-20200326155132-2a6cd50aedd0 h1:kMiuiZXH1GdfbiMwsuAQOqGaMxlo9NCUk0wT4XAdfNM= github.com/openshift/client-go v0.0.0-20200326155132-2a6cd50aedd0/go.mod h1:uUQ4LClRO+fg5MF/P6QxjMCb1C9f7Oh4RKepftDnEJE= +github.com/openshift/generic-admission-server v1.14.1-0.20200514123932-ccc9079d8bdb h1:SfOkJiP0FDdJijQKtA808PpL/i9CkvVAgTE7yOO9ltI= +github.com/openshift/generic-admission-server v1.14.1-0.20200514123932-ccc9079d8bdb/go.mod h1:86YzeUDgE5LpiqJov3uXbAWEkcGBpX3p3t/yGLlKHqA= github.com/openshift/library-go v0.0.0-20200401114229-ffab8c6e83a9 h1:aEcgb9sKGmHK2IP9IcyUL11XYle0XNVnrG4871MXXIw= github.com/openshift/library-go v0.0.0-20200401114229-ffab8c6e83a9/go.mod h1:CfydoH0B+RYs22uQZQ36A1mz5m5zhucpMGh8t5s71v4= github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= @@ -515,6 +519,7 @@ gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -569,6 +574,8 @@ sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.7 h1:uuHDyjllyzRyCI sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.7/go.mod h1:PHgbrJT7lCHcxMU+mDHEm+nx46H4zuuHZkDP6icnhu0= sigs.k8s.io/controller-runtime v0.6.0 h1:Fzna3DY7c4BIP6KwfSlrfnj20DJ+SeMBK8HSFvOk9NM= sigs.k8s.io/controller-runtime v0.6.0/go.mod h1:CpYf5pdNY/B352A1TFLAS2JVSlnGQ5O2cftPHndTroo= +sigs.k8s.io/structured-merge-diff v0.0.0-20190817042607-6149e4549fca h1:6dsH6AYQWbyZmtttJNe8Gq1cXOeS1BdV3eW37zHilAQ= +sigs.k8s.io/structured-merge-diff v0.0.0-20190817042607-6149e4549fca/go.mod h1:IIgPezJWb76P0hotTxzDbWsMYB8APh18qZnxkomBpxA= sigs.k8s.io/structured-merge-diff/v3 v3.0.0-20200116222232-67a7b8c61874/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw= sigs.k8s.io/structured-merge-diff/v3 v3.0.0 h1:dOmIZBMfhcHS09XZkMyUgkq5trg3/jRyJYFZUiaOp8E= sigs.k8s.io/structured-merge-diff/v3 v3.0.0/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw= diff --git a/pkg/cmd/webhook/webhook.go b/pkg/cmd/webhook/webhook.go new file mode 100644 index 000000000..310c54e21 --- /dev/null +++ b/pkg/cmd/webhook/webhook.go @@ -0,0 +1,37 @@ +package webhook + +import ( + "os" + + "github.com/open-cluster-management/registration/pkg/webhook" + admissionserver "github.com/openshift/generic-admission-server/pkg/cmd/server" + "github.com/spf13/cobra" + genericapiserver "k8s.io/apiserver/pkg/server" +) + +func NewAdmissionHook() *cobra.Command { + o := admissionserver.NewAdmissionServerOptions(os.Stdout, os.Stderr, &webhook.SpokeClusterAdmissionHook{}) + + cmd := &cobra.Command{ + Use: "webhook", + Short: "Start Spoke Cluster Admission Server", + RunE: func(c *cobra.Command, args []string) error { + stopCh := genericapiserver.SetupSignalHandler() + + if err := o.Complete(); err != nil { + return err + } + if err := o.Validate(args); err != nil { + return err + } + if err := o.RunAdmissionServer(stopCh); err != nil { + return err + } + return nil + }, + } + + o.RecommendedOptions.AddFlags(cmd.Flags()) + + return cmd +} diff --git a/pkg/webhook/doc.go b/pkg/webhook/doc.go new file mode 100644 index 000000000..02a0b14f9 --- /dev/null +++ b/pkg/webhook/doc.go @@ -0,0 +1,2 @@ +// package webhook contains the spoke cluster admission hook to validate the SpokeCluster create and update operations +package webhook diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go new file mode 100644 index 000000000..626eb4b91 --- /dev/null +++ b/pkg/webhook/webhook.go @@ -0,0 +1,203 @@ +package webhook + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + admissionv1beta1 "k8s.io/api/admission/v1beta1" + authenticationv1 "k8s.io/api/authentication/v1" + authorizationv1 "k8s.io/api/authorization/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + clusterv1 "github.com/open-cluster-management/api/cluster/v1" + "github.com/open-cluster-management/registration/pkg/helpers" + operatorhelpers "github.com/openshift/library-go/pkg/operator/v1helpers" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog" +) + +// SpokeClusterAdmissionHook will validate the creating/updating spokeclusters request. +type SpokeClusterAdmissionHook struct { + kubeClient kubernetes.Interface +} + +// ValidatingResource is called by generic-admission-server on startup to register the returned REST resource through which the +// webhook is accessed by the kube apiserver. +func (a *SpokeClusterAdmissionHook) ValidatingResource() (plural schema.GroupVersionResource, singular string) { + return schema.GroupVersionResource{ + Group: "admission.cluster.open-cluster-management.io", + Version: "v1", + Resource: "spokeclustervalidators", + }, + "spokeclustervalidator" +} + +// Validate is called by generic-admission-server when the registered REST resource above is called with an admission request. +func (a *SpokeClusterAdmissionHook) Validate(admissionSpec *admissionv1beta1.AdmissionRequest) *admissionv1beta1.AdmissionResponse { + klog.V(4).Infof("validate %q operation for object %q", admissionSpec.Operation, admissionSpec.Object) + + status := &admissionv1beta1.AdmissionResponse{} + + // only validate the request for spokeclusters + if admissionSpec.Resource.Group != "cluster.open-cluster-management.io" || + admissionSpec.Resource.Version != "v1" || + admissionSpec.Resource.Resource != "spokeclusters" { + status.Allowed = true + return status + } + + switch admissionSpec.Operation { + case admissionv1beta1.Create: + return a.validateCreateRequest(admissionSpec) + case admissionv1beta1.Update: + return a.validateUpdateRequest(admissionSpec) + default: + status.Allowed = true + return status + } +} + +// Initialize is called by generic-admission-server on startup to setup initialization that spokeclusters webhook needs. +func (a *SpokeClusterAdmissionHook) Initialize(kubeClientConfig *rest.Config, stopCh <-chan struct{}) error { + var err error + a.kubeClient, err = kubernetes.NewForConfig(kubeClientConfig) + return err +} + +// validateCreateRequest validates create spoke cluster operation +func (a *SpokeClusterAdmissionHook) validateCreateRequest(request *admissionv1beta1.AdmissionRequest) *admissionv1beta1.AdmissionResponse { + status := &admissionv1beta1.AdmissionResponse{} + + // validate SpokeCluster object firstly + spokeCluster, err := a.validateSpokeClusterObj(request.Object) + if err != nil { + status.Allowed = false + status.Result = &metav1.Status{ + Status: metav1.StatusFailure, Code: http.StatusBadRequest, Reason: metav1.StatusReasonBadRequest, + Message: err.Error(), + } + return status + } + + // the HubAcceptsClient field is not changed, finish the validation process + if !spokeCluster.Spec.HubAcceptsClient { + status.Allowed = true + return status + } + + // the HubAcceptsClient field is changed, we need to check the request user whether + // has been allowed to change the HubAcceptsClient field with SubjectAccessReview api + return a.allowUpdateAcceptField(request.UserInfo) +} + +// validateUpdateRequest validates update spoke cluster operation. +func (a *SpokeClusterAdmissionHook) validateUpdateRequest(request *admissionv1beta1.AdmissionRequest) *admissionv1beta1.AdmissionResponse { + status := &admissionv1beta1.AdmissionResponse{} + + oldSpokeCluster := &clusterv1.SpokeCluster{} + if err := json.Unmarshal(request.OldObject.Raw, oldSpokeCluster); err != nil { + status.Allowed = false + status.Result = &metav1.Status{ + Status: metav1.StatusFailure, Code: http.StatusBadRequest, Reason: metav1.StatusReasonBadRequest, + Message: err.Error(), + } + return status + } + + // validate the updating SpokeCluster object firstly + newSpokeCluster, err := a.validateSpokeClusterObj(request.Object) + if err != nil { + status.Allowed = false + status.Result = &metav1.Status{ + Status: metav1.StatusFailure, Code: http.StatusBadRequest, Reason: metav1.StatusReasonBadRequest, + Message: err.Error(), + } + return status + } + + // the HubAcceptsClient field is not changed, finish the validation process + if newSpokeCluster.Spec.HubAcceptsClient == oldSpokeCluster.Spec.HubAcceptsClient { + status.Allowed = true + return status + } + + // the HubAcceptsClient field is changed, we need to check the request user whether + // has been allowed to update the HubAcceptsClient field with SubjectAccessReview api + return a.allowUpdateAcceptField(request.UserInfo) +} + +// validateSpokeClusterObj validates the fileds of SpokeCluster object +func (a *SpokeClusterAdmissionHook) validateSpokeClusterObj(requestObj runtime.RawExtension) (*clusterv1.SpokeCluster, error) { + errs := []error{} + + spokeCluster := &clusterv1.SpokeCluster{} + if err := json.Unmarshal(requestObj.Raw, spokeCluster); err != nil { + errs = append(errs, err) + } + + // there are no spoke client configs, finish the validation process + if len(spokeCluster.Spec.SpokeClientConfigs) == 0 { + return spokeCluster, operatorhelpers.NewMultiLineAggregate(errs) + } + + // validate the url in spoke client configs + for _, clientConfig := range spokeCluster.Spec.SpokeClientConfigs { + if !helpers.IsValidHTTPSURL(clientConfig.URL) { + errs = append(errs, fmt.Errorf("url %q is invalid in spoke client configs", clientConfig.URL)) + } + } + + return spokeCluster, operatorhelpers.NewMultiLineAggregate(errs) +} + +// allowUpdateHubAcceptsClientField using SubjectAccessReview API to check whether a request user has been authorized to update +// HubAcceptsClient field +func (a *SpokeClusterAdmissionHook) allowUpdateAcceptField(userInfo authenticationv1.UserInfo) *admissionv1beta1.AdmissionResponse { + status := &admissionv1beta1.AdmissionResponse{} + + extra := make(map[string]authorizationv1.ExtraValue) + for k, v := range userInfo.Extra { + extra[k] = authorizationv1.ExtraValue(v) + } + + sar := &authorizationv1.SubjectAccessReview{ + Spec: authorizationv1.SubjectAccessReviewSpec{ + User: userInfo.Username, + UID: userInfo.UID, + Groups: userInfo.Groups, + Extra: extra, + ResourceAttributes: &authorizationv1.ResourceAttributes{ + Group: "register.open-cluster-management.io", + Resource: "spokeclusters", + Verb: "update", + Subresource: "acceptance", + }, + }, + } + sar, err := a.kubeClient.AuthorizationV1().SubjectAccessReviews().Create(context.TODO(), sar, metav1.CreateOptions{}) + if err != nil { + status.Allowed = false + status.Result = &metav1.Status{ + Status: metav1.StatusFailure, Code: http.StatusForbidden, Reason: metav1.StatusReasonForbidden, + Message: err.Error(), + } + return status + } + + if !sar.Status.Allowed { + status.Allowed = false + status.Result = &metav1.Status{ + Status: metav1.StatusFailure, Code: http.StatusForbidden, Reason: metav1.StatusReasonForbidden, + Message: fmt.Sprintf("user %q cannot update the HubAcceptsClient field", userInfo.Username), + } + return status + } + + status.Allowed = true + return status +} diff --git a/pkg/webhook/webhook_test.go b/pkg/webhook/webhook_test.go new file mode 100644 index 000000000..9e4930e13 --- /dev/null +++ b/pkg/webhook/webhook_test.go @@ -0,0 +1,219 @@ +package webhook + +import ( + "encoding/json" + "net/http" + "reflect" + "testing" + + clusterv1 "github.com/open-cluster-management/api/cluster/v1" + admissionv1beta1 "k8s.io/api/admission/v1beta1" + authenticationv1 "k8s.io/api/authentication/v1" + authorizationv1 "k8s.io/api/authorization/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + kubefake "k8s.io/client-go/kubernetes/fake" + clienttesting "k8s.io/client-go/testing" +) + +var spokeclustersSchema = metav1.GroupVersionResource{ + Group: "cluster.open-cluster-management.io", + Version: "v1", + Resource: "spokeclusters", +} + +func TestSpokeClusterValidate(t *testing.T) { + cases := []struct { + name string + request *admissionv1beta1.AdmissionRequest + expectedResponse *admissionv1beta1.AdmissionResponse + allowUpdateAcceptField bool + }{ + { + name: "validate non-spokeclusters request", + request: &admissionv1beta1.AdmissionRequest{ + Resource: metav1.GroupVersionResource{ + Group: "test.open-cluster-management.io", + Version: "v1", + Resource: "tests", + }, + }, + expectedResponse: &admissionv1beta1.AdmissionResponse{ + Allowed: true, + }, + }, + { + name: "validate deleting operation", + request: &admissionv1beta1.AdmissionRequest{ + Resource: spokeclustersSchema, + Operation: admissionv1beta1.Delete, + }, + expectedResponse: &admissionv1beta1.AdmissionResponse{ + Allowed: true, + }, + }, + { + name: "validate creating SpokeCluster", + request: &admissionv1beta1.AdmissionRequest{ + Resource: spokeclustersSchema, + Operation: admissionv1beta1.Create, + Object: newSpokeClusterObj(), + }, + expectedResponse: &admissionv1beta1.AdmissionResponse{ + Allowed: true, + }, + }, + { + name: "validate creating SpokeCluster with invalid fields", + request: &admissionv1beta1.AdmissionRequest{ + Resource: spokeclustersSchema, + Operation: admissionv1beta1.Create, + Object: newSpokeClusterObjWithClientConfigs(clusterv1.ClientConfig{URL: "http://127.0.0.1:8001"}), + }, + expectedResponse: &admissionv1beta1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Status: metav1.StatusFailure, Code: http.StatusBadRequest, Reason: metav1.StatusReasonBadRequest, + Message: "url \"http://127.0.0.1:8001\" is invalid in spoke client configs", + }, + }, + }, + { + name: "validate creating an accepted SpokeCluster without update acceptance permission", + request: &admissionv1beta1.AdmissionRequest{ + Resource: spokeclustersSchema, + Operation: admissionv1beta1.Create, + Object: newSpokeClusterObjWithHubAcceptsClient(true), + UserInfo: authenticationv1.UserInfo{Username: "tester"}, + }, + expectedResponse: &admissionv1beta1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Status: metav1.StatusFailure, Code: http.StatusForbidden, Reason: metav1.StatusReasonForbidden, + Message: "user \"tester\" cannot update the HubAcceptsClient field", + }, + }, + }, + { + name: "validate creating an accepted SpokeCluster", + request: &admissionv1beta1.AdmissionRequest{ + Resource: spokeclustersSchema, + Operation: admissionv1beta1.Create, + Object: newSpokeClusterObjWithHubAcceptsClient(true), + }, + expectedResponse: &admissionv1beta1.AdmissionResponse{ + Allowed: true, + }, + allowUpdateAcceptField: true, + }, + { + name: "validate update SpokeCluster without HubAcceptsClient field changed", + request: &admissionv1beta1.AdmissionRequest{ + Resource: spokeclustersSchema, + Operation: admissionv1beta1.Update, + OldObject: newSpokeClusterObjWithClientConfigs(clusterv1.ClientConfig{URL: "https://127.0.0.1:6443"}), + Object: newSpokeClusterObjWithClientConfigs(clusterv1.ClientConfig{URL: "https://127.0.0.1:8443"}), + }, + expectedResponse: &admissionv1beta1.AdmissionResponse{ + Allowed: true, + }, + }, + { + name: "validate updating HubAcceptsClient field without update acceptance permission", + request: &admissionv1beta1.AdmissionRequest{ + Resource: spokeclustersSchema, + Operation: admissionv1beta1.Update, + OldObject: newSpokeClusterObjWithHubAcceptsClient(false), + Object: newSpokeClusterObjWithHubAcceptsClient(true), + UserInfo: authenticationv1.UserInfo{Username: "tester"}, + }, + expectedResponse: &admissionv1beta1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Status: metav1.StatusFailure, Code: http.StatusForbidden, Reason: metav1.StatusReasonForbidden, + Message: "user \"tester\" cannot update the HubAcceptsClient field", + }, + }, + }, + { + name: "validate updating HubAcceptsClient field", + request: &admissionv1beta1.AdmissionRequest{ + Resource: spokeclustersSchema, + Operation: admissionv1beta1.Update, + OldObject: newSpokeClusterObjWithHubAcceptsClient(false), + Object: newSpokeClusterObjWithHubAcceptsClient(true), + }, + expectedResponse: &admissionv1beta1.AdmissionResponse{ + Allowed: true, + }, + allowUpdateAcceptField: true, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + kubeClient := kubefake.NewSimpleClientset() + kubeClient.PrependReactor( + "create", + "subjectaccessreviews", + func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) { + return true, &authorizationv1.SubjectAccessReview{ + Status: authorizationv1.SubjectAccessReviewStatus{ + Allowed: c.allowUpdateAcceptField, + }, + }, nil + }, + ) + + admissionHook := &SpokeClusterAdmissionHook{kubeClient: kubeClient} + + actualResponse := admissionHook.Validate(c.request) + + if !reflect.DeepEqual(actualResponse, c.expectedResponse) { + t.Errorf("expected %#v but got: %#v", c.expectedResponse.Result, actualResponse.Result) + } + }) + } +} + +func newSpokeClusterObj() runtime.RawExtension { + spokeCluster := &clusterv1.SpokeCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testspokecluster", + }, + } + clusterObj, _ := json.Marshal(spokeCluster) + return runtime.RawExtension{ + Raw: clusterObj, + } +} + +func newSpokeClusterObjWithHubAcceptsClient(hubAcceptsClient bool) runtime.RawExtension { + spokeCluster := &clusterv1.SpokeCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testspokecluster", + }, + Spec: clusterv1.SpokeClusterSpec{ + HubAcceptsClient: hubAcceptsClient, + }, + } + clusterObj, _ := json.Marshal(spokeCluster) + return runtime.RawExtension{ + Raw: clusterObj, + } +} + +func newSpokeClusterObjWithClientConfigs(clientConfig clusterv1.ClientConfig) runtime.RawExtension { + spokeCluster := &clusterv1.SpokeCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "testspokecluster", + }, + Spec: clusterv1.SpokeClusterSpec{ + SpokeClientConfigs: []clusterv1.ClientConfig{clientConfig}, + }, + } + clusterObj, _ := json.Marshal(spokeCluster) + return runtime.RawExtension{ + Raw: clusterObj, + } +} diff --git a/vendor/github.com/openshift/generic-admission-server/LICENSE b/vendor/github.com/openshift/generic-admission-server/LICENSE new file mode 100644 index 000000000..8dada3eda --- /dev/null +++ b/vendor/github.com/openshift/generic-admission-server/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + 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. diff --git a/vendor/github.com/openshift/generic-admission-server/pkg/apiserver/apiserver.go b/vendor/github.com/openshift/generic-admission-server/pkg/apiserver/apiserver.go new file mode 100644 index 000000000..fa6df5dd2 --- /dev/null +++ b/vendor/github.com/openshift/generic-admission-server/pkg/apiserver/apiserver.go @@ -0,0 +1,267 @@ +package apiserver + +import ( + "fmt" + "strings" + + admissionv1beta1 "k8s.io/api/admission/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/version" + "k8s.io/apiserver/pkg/registry/rest" + genericapiserver "k8s.io/apiserver/pkg/server" + restclient "k8s.io/client-go/rest" + + "github.com/openshift/generic-admission-server/pkg/registry/admissionreview" +) + +var ( + Scheme = runtime.NewScheme() + Codecs = serializer.NewCodecFactory(Scheme) +) + +type AdmissionHook interface { + // Initialize is called as a post-start hook + Initialize(kubeClientConfig *restclient.Config, stopCh <-chan struct{}) error +} + +type ValidatingAdmissionHook interface { + AdmissionHook + + // ValidatingResource is the resource to use for hosting your admission webhook. If the hook implements + // MutatingAdmissionHook as well, the two resources for validating and mutating admission must be different. + // Note: this is (usually) not the same as the payload resource! + ValidatingResource() (plural schema.GroupVersionResource, singular string) + + // Validate is called to decide whether to accept the admission request. The returned AdmissionResponse + // must not use the Patch field. + Validate(admissionSpec *admissionv1beta1.AdmissionRequest) *admissionv1beta1.AdmissionResponse +} + +type MutatingAdmissionHook interface { + AdmissionHook + + // MutatingResource is the resource to use for hosting your admission webhook. If the hook implements + // ValidatingAdmissionHook as well, the two resources for validating and mutating admission must be different. + // Note: this is (usually) not the same as the payload resource! + MutatingResource() (plural schema.GroupVersionResource, singular string) + + // Admit is called to decide whether to accept the admission request. The returned AdmissionResponse may + // use the Patch field to mutate the object from the passed AdmissionRequest. + Admit(admissionSpec *admissionv1beta1.AdmissionRequest) *admissionv1beta1.AdmissionResponse +} + +func init() { + admissionv1beta1.AddToScheme(Scheme) + + // we need to add the options to empty v1 + // TODO fix the server code to avoid this + metav1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"}) + + // TODO: keep the generic API server from wanting this + unversioned := schema.GroupVersion{Group: "", Version: "v1"} + Scheme.AddUnversionedTypes(unversioned, + &metav1.Status{}, + &metav1.APIVersions{}, + &metav1.APIGroupList{}, + &metav1.APIGroup{}, + &metav1.APIResourceList{}, + ) +} + +type Config struct { + GenericConfig *genericapiserver.RecommendedConfig + ExtraConfig ExtraConfig +} + +type ExtraConfig struct { + AdmissionHooks []AdmissionHook +} + +// AdmissionServer contains state for a Kubernetes cluster master/api server. +type AdmissionServer struct { + GenericAPIServer *genericapiserver.GenericAPIServer +} + +type completedConfig struct { + GenericConfig genericapiserver.CompletedConfig + ExtraConfig *ExtraConfig +} + +type CompletedConfig struct { + // Embed a private pointer that cannot be instantiated outside of this package. + *completedConfig +} + +// Complete fills in any fields not set that are required to have valid data. It's mutating the receiver. +func (c *Config) Complete() CompletedConfig { + completedCfg := completedConfig{ + c.GenericConfig.Complete(), + &c.ExtraConfig, + } + + completedCfg.GenericConfig.Version = &version.Info{ + Major: "1", + Minor: "1", + } + + return CompletedConfig{&completedCfg} +} + +// New returns a new instance of AdmissionServer from the given config. +func (c completedConfig) New() (*AdmissionServer, error) { + genericServer, err := c.GenericConfig.New("admission-server", genericapiserver.NewEmptyDelegate()) // completion is done in Complete, no need for a second time + if err != nil { + return nil, err + } + + s := &AdmissionServer{ + GenericAPIServer: genericServer, + } + + inClusterConfig, err := restclient.InClusterConfig() + if err != nil { + return nil, err + } + + for _, versionMap := range admissionHooksByGroupThenVersion(c.ExtraConfig.AdmissionHooks...) { + // TODO we're going to need a later k8s.io/apiserver so that we can get discovery to list a different group version for + // our endpoint which we'll use to back some custom storage which will consume the AdmissionReview type and give back the correct response + apiGroupInfo := genericapiserver.APIGroupInfo{ + VersionedResourcesStorageMap: map[string]map[string]rest.Storage{}, + // TODO unhardcode this. It was hardcoded before, but we need to re-evaluate + OptionsExternalVersion: &schema.GroupVersion{Version: "v1"}, + Scheme: Scheme, + ParameterCodec: metav1.ParameterCodec, + NegotiatedSerializer: Codecs, + } + + for _, admissionHooks := range versionMap { + for i := range admissionHooks { + admissionHook := admissionHooks[i] + admissionResource, _ := admissionHook.Resource() + admissionVersion := admissionResource.GroupVersion() + + // just overwrite the groupversion with a random one. We don't really care or know. + apiGroupInfo.PrioritizedVersions = appendUniqueGroupVersion(apiGroupInfo.PrioritizedVersions, admissionVersion) + + admissionReview := admissionreview.NewREST(admissionHook.Admission) + v1alpha1storage, ok := apiGroupInfo.VersionedResourcesStorageMap[admissionVersion.Version] + if !ok { + v1alpha1storage = map[string]rest.Storage{} + } + v1alpha1storage[admissionResource.Resource] = admissionReview + apiGroupInfo.VersionedResourcesStorageMap[admissionVersion.Version] = v1alpha1storage + } + } + + if err := s.GenericAPIServer.InstallAPIGroup(&apiGroupInfo); err != nil { + return nil, err + } + } + + for i := range c.ExtraConfig.AdmissionHooks { + admissionHook := c.ExtraConfig.AdmissionHooks[i] + postStartName := postStartHookName(admissionHook) + if len(postStartName) == 0 { + continue + } + s.GenericAPIServer.AddPostStartHookOrDie(postStartName, + func(context genericapiserver.PostStartHookContext) error { + return admissionHook.Initialize(inClusterConfig, context.StopCh) + }, + ) + } + + return s, nil +} + +func appendUniqueGroupVersion(slice []schema.GroupVersion, elems ...schema.GroupVersion) []schema.GroupVersion { + m := map[schema.GroupVersion]bool{} + for _, gv := range slice { + m[gv] = true + } + for _, e := range elems { + m[e] = true + } + out := make([]schema.GroupVersion, 0, len(m)) + for gv := range m { + out = append(out, gv) + } + return out +} + +func postStartHookName(hook AdmissionHook) string { + var ns []string + if mutatingHook, ok := hook.(MutatingAdmissionHook); ok { + gvr, _ := mutatingHook.MutatingResource() + ns = append(ns, fmt.Sprintf("mutating-%s.%s.%s", gvr.Resource, gvr.Version, gvr.Group)) + } + if validatingHook, ok := hook.(ValidatingAdmissionHook); ok { + gvr, _ := validatingHook.ValidatingResource() + ns = append(ns, fmt.Sprintf("validating-%s.%s.%s", gvr.Resource, gvr.Version, gvr.Group)) + } + if len(ns) == 0 { + return "" + } + return strings.Join(append(ns, "init"), "-") +} + +func admissionHooksByGroupThenVersion(admissionHooks ...AdmissionHook) map[string]map[string][]admissionHookWrapper { + ret := map[string]map[string][]admissionHookWrapper{} + + for i := range admissionHooks { + if mutatingHook, ok := admissionHooks[i].(MutatingAdmissionHook); ok { + gvr, _ := mutatingHook.MutatingResource() + group, ok := ret[gvr.Group] + if !ok { + group = map[string][]admissionHookWrapper{} + ret[gvr.Group] = group + } + group[gvr.Version] = append(group[gvr.Version], mutatingAdmissionHookWrapper{mutatingHook}) + } + if validatingHook, ok := admissionHooks[i].(ValidatingAdmissionHook); ok { + gvr, _ := validatingHook.ValidatingResource() + group, ok := ret[gvr.Group] + if !ok { + group = map[string][]admissionHookWrapper{} + ret[gvr.Group] = group + } + group[gvr.Version] = append(group[gvr.Version], validatingAdmissionHookWrapper{validatingHook}) + } + } + + return ret +} + +// admissionHookWrapper wraps either a validating or mutating admission hooks, calling the respective resource and admission method. +type admissionHookWrapper interface { + Resource() (plural schema.GroupVersionResource, singular string) + Admission(admissionSpec *admissionv1beta1.AdmissionRequest) *admissionv1beta1.AdmissionResponse +} + +type mutatingAdmissionHookWrapper struct { + hook MutatingAdmissionHook +} + +func (h mutatingAdmissionHookWrapper) Resource() (plural schema.GroupVersionResource, singular string) { + return h.hook.MutatingResource() +} + +func (h mutatingAdmissionHookWrapper) Admission(admissionSpec *admissionv1beta1.AdmissionRequest) *admissionv1beta1.AdmissionResponse { + return h.hook.Admit(admissionSpec) +} + +type validatingAdmissionHookWrapper struct { + hook ValidatingAdmissionHook +} + +func (h validatingAdmissionHookWrapper) Resource() (plural schema.GroupVersionResource, singular string) { + return h.hook.ValidatingResource() +} + +func (h validatingAdmissionHookWrapper) Admission(admissionSpec *admissionv1beta1.AdmissionRequest) *admissionv1beta1.AdmissionResponse { + return h.hook.Validate(admissionSpec) +} diff --git a/vendor/github.com/openshift/generic-admission-server/pkg/cmd/server/start.go b/vendor/github.com/openshift/generic-admission-server/pkg/cmd/server/start.go new file mode 100644 index 000000000..d44b572bb --- /dev/null +++ b/vendor/github.com/openshift/generic-admission-server/pkg/cmd/server/start.go @@ -0,0 +1,114 @@ +package server + +import ( + "fmt" + "io" + "net" + + "github.com/spf13/cobra" + + admissionv1beta1 "k8s.io/api/admission/v1beta1" + genericapiserver "k8s.io/apiserver/pkg/server" + genericoptions "k8s.io/apiserver/pkg/server/options" + + "github.com/openshift/generic-admission-server/pkg/apiserver" +) + +const defaultEtcdPathPrefix = "/registry/online.openshift.io" + +type AdmissionServerOptions struct { + RecommendedOptions *genericoptions.RecommendedOptions + + AdmissionHooks []apiserver.AdmissionHook + + StdOut io.Writer + StdErr io.Writer +} + +func NewAdmissionServerOptions(out, errOut io.Writer, admissionHooks ...apiserver.AdmissionHook) *AdmissionServerOptions { + o := &AdmissionServerOptions{ + // TODO we will nil out the etcd storage options. This requires a later level of k8s.io/apiserver + RecommendedOptions: genericoptions.NewRecommendedOptions( + defaultEtcdPathPrefix, + apiserver.Codecs.LegacyCodec(admissionv1beta1.SchemeGroupVersion), + nil, + ), + + AdmissionHooks: admissionHooks, + + StdOut: out, + StdErr: errOut, + } + o.RecommendedOptions.Etcd = nil + o.RecommendedOptions.Admission = nil + + return o +} + +// NewCommandStartMaster provides a CLI handler for 'start master' command +func NewCommandStartAdmissionServer(out, errOut io.Writer, stopCh <-chan struct{}, admissionHooks ...apiserver.AdmissionHook) *cobra.Command { + o := NewAdmissionServerOptions(out, errOut, admissionHooks...) + + cmd := &cobra.Command{ + Short: "Launch a namespace reservation API server", + Long: "Launch a namespace reservation API server", + RunE: func(c *cobra.Command, args []string) error { + if err := o.Complete(); err != nil { + return err + } + if err := o.Validate(args); err != nil { + return err + } + if err := o.RunAdmissionServer(stopCh); err != nil { + return err + } + return nil + }, + } + + flags := cmd.Flags() + o.RecommendedOptions.AddFlags(flags) + + return cmd +} + +func (o AdmissionServerOptions) Validate(args []string) error { + return nil +} + +func (o *AdmissionServerOptions) Complete() error { + return nil +} + +func (o AdmissionServerOptions) Config() (*apiserver.Config, error) { + // TODO have a "real" external address + if err := o.RecommendedOptions.SecureServing.MaybeDefaultWithSelfSignedCerts("localhost", nil, []net.IP{net.ParseIP("127.0.0.1")}); err != nil { + return nil, fmt.Errorf("error creating self-signed certificates: %v", err) + } + + serverConfig := genericapiserver.NewRecommendedConfig(apiserver.Codecs) + if err := o.RecommendedOptions.ApplyTo(serverConfig); err != nil { + return nil, err + } + + config := &apiserver.Config{ + GenericConfig: serverConfig, + ExtraConfig: apiserver.ExtraConfig{ + AdmissionHooks: o.AdmissionHooks, + }, + } + return config, nil +} + +func (o AdmissionServerOptions) RunAdmissionServer(stopCh <-chan struct{}) error { + config, err := o.Config() + if err != nil { + return err + } + + server, err := config.Complete().New() + if err != nil { + return err + } + return server.GenericAPIServer.PrepareRun().Run(stopCh) +} diff --git a/vendor/github.com/openshift/generic-admission-server/pkg/registry/admissionreview/admission_review.go b/vendor/github.com/openshift/generic-admission-server/pkg/registry/admissionreview/admission_review.go new file mode 100644 index 000000000..cbf950b84 --- /dev/null +++ b/vendor/github.com/openshift/generic-admission-server/pkg/registry/admissionreview/admission_review.go @@ -0,0 +1,45 @@ +package admissionreview + +import ( + "context" + + admissionv1beta1 "k8s.io/api/admission/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/registry/rest" +) + +type AdmissionHookFunc func(admissionSpec *admissionv1beta1.AdmissionRequest) *admissionv1beta1.AdmissionResponse + +type REST struct { + hookFn AdmissionHookFunc +} + +var _ rest.Creater = &REST{} +var _ rest.Scoper = &REST{} +var _ rest.GroupVersionKindProvider = &REST{} + +func NewREST(hookFn AdmissionHookFunc) *REST { + return &REST{ + hookFn: hookFn, + } +} + +func (r *REST) New() runtime.Object { + return &admissionv1beta1.AdmissionReview{} +} + +func (r *REST) GroupVersionKind(containingGV schema.GroupVersion) schema.GroupVersionKind { + return admissionv1beta1.SchemeGroupVersion.WithKind("AdmissionReview") +} + +func (r *REST) NamespaceScoped() bool { + return false +} + +func (r *REST) Create(ctx context.Context, obj runtime.Object, _ rest.ValidateObjectFunc, _ *metav1.CreateOptions) (runtime.Object, error) { + admissionReview := obj.(*admissionv1beta1.AdmissionReview) + admissionReview.Response = r.hookFn(admissionReview.Request) + return admissionReview, nil +} diff --git a/vendor/modules.txt b/vendor/modules.txt index e75fb0ad8..eab6eabbe 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -201,6 +201,10 @@ github.com/openshift/build-machinery-go/make/targets/golang github.com/openshift/build-machinery-go/make/targets/openshift github.com/openshift/build-machinery-go/make/targets/openshift/operator github.com/openshift/build-machinery-go/scripts +# github.com/openshift/generic-admission-server v1.14.1-0.20200514123932-ccc9079d8bdb +github.com/openshift/generic-admission-server/pkg/apiserver +github.com/openshift/generic-admission-server/pkg/cmd/server +github.com/openshift/generic-admission-server/pkg/registry/admissionreview # github.com/openshift/library-go v0.0.0-20200401114229-ffab8c6e83a9 github.com/openshift/library-go/pkg/assets github.com/openshift/library-go/pkg/config/client