From ccc2a489e7bea16a941c813fabfa8b11b36ddbc0 Mon Sep 17 00:00:00 2001 From: liuwei Date: Wed, 8 Apr 2020 16:38:08 +0800 Subject: [PATCH] hub csr controller --- Makefile | 1 + pkg/hub/csr/bindata/bindata.go | 262 +++++++++++++++ pkg/hub/csr/controller.go | 195 ++++++++++++ pkg/hub/csr/controller_test.go | 299 ++++++++++++++++++ pkg/hub/csr/doc.go | 3 + pkg/hub/csr/manifests/clusterrole.yaml | 13 + pkg/hub/csr/manifests/clusterrolebinding.yaml | 12 + pkg/hub/manager.go | 12 + pkg/hub/spokecluster/bindata/bindata.go | 6 +- .../spokecluster-clusterrolebinding.yaml | 2 +- .../manifests/spokecluster-namespace.yaml | 2 +- 11 files changed, 803 insertions(+), 4 deletions(-) create mode 100644 pkg/hub/csr/bindata/bindata.go create mode 100644 pkg/hub/csr/controller.go create mode 100644 pkg/hub/csr/controller_test.go create mode 100644 pkg/hub/csr/doc.go create mode 100644 pkg/hub/csr/manifests/clusterrole.yaml create mode 100644 pkg/hub/csr/manifests/clusterrolebinding.yaml diff --git a/Makefile b/Makefile index cdb18ff31..7541a98cf 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ include $(addprefix ./vendor/github.com/openshift/build-machinery-go/make/, \ ) $(call add-bindata,spokecluster,./pkg/hub/spokecluster/manifests/...,bindata,bindata,./pkg/hub/spokecluster/bindata/bindata.go) +$(call add-bindata,csr,./pkg/hub/csr/manifests/...,bindata,bindata,./pkg/hub/csr/bindata/bindata.go) clean: $(RM) ./registration diff --git a/pkg/hub/csr/bindata/bindata.go b/pkg/hub/csr/bindata/bindata.go new file mode 100644 index 000000000..8fe20f056 --- /dev/null +++ b/pkg/hub/csr/bindata/bindata.go @@ -0,0 +1,262 @@ +// Code generated by go-bindata. +// sources: +// pkg/hub/csr/manifests/clusterrole.yaml +// pkg/hub/csr/manifests/clusterrolebinding.yaml +// DO NOT EDIT! + +package bindata + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + "time" +) + +type asset struct { + bytes []byte + info os.FileInfo +} + +type bindataFileInfo struct { + name string + size int64 + mode os.FileMode + modTime time.Time +} + +func (fi bindataFileInfo) Name() string { + return fi.name +} +func (fi bindataFileInfo) Size() int64 { + return fi.size +} +func (fi bindataFileInfo) Mode() os.FileMode { + return fi.mode +} +func (fi bindataFileInfo) ModTime() time.Time { + return fi.modTime +} +func (fi bindataFileInfo) IsDir() bool { + return false +} +func (fi bindataFileInfo) Sys() interface{} { + return nil +} + +var _pkgHubCsrManifestsClusterroleYaml = []byte(`apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: system:open-cluster-management:spokecluster:{{ .SpokeClusterName }}-csrapproved +rules: +# Allow spoke agent to create/get/list/watch certificatesigningrequests +- apiGroups: ["certificates.k8s.io"] + resources: ["certificatesigningrequests"] + verbs: ["create", "get", "list", "watch"] +# Allow spoke agent to create/get/list/watch spoke cluster +- apiGroups: ["cluster.open-cluster-management.io"] + resources: ["spokeclusters"] + verbs: ["create", "get", "list", "watch"] +`) + +func pkgHubCsrManifestsClusterroleYamlBytes() ([]byte, error) { + return _pkgHubCsrManifestsClusterroleYaml, nil +} + +func pkgHubCsrManifestsClusterroleYaml() (*asset, error) { + bytes, err := pkgHubCsrManifestsClusterroleYamlBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "pkg/hub/csr/manifests/clusterrole.yaml", size: 0, mode: os.FileMode(0), modTime: time.Unix(0, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _pkgHubCsrManifestsClusterrolebindingYaml = []byte(`apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: system:open-cluster-management:spokecluster:{{ .SpokeClusterName }}-csrapproved +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:open-cluster-management:spokecluster:{{ .SpokeClusterName }}-csrapproved +subjects: +- kind: Group + apiGroup: rbac.authorization.k8s.io + name: system:open-cluster-management:{{ .SpokeClusterName }} +`) + +func pkgHubCsrManifestsClusterrolebindingYamlBytes() ([]byte, error) { + return _pkgHubCsrManifestsClusterrolebindingYaml, nil +} + +func pkgHubCsrManifestsClusterrolebindingYaml() (*asset, error) { + bytes, err := pkgHubCsrManifestsClusterrolebindingYamlBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "pkg/hub/csr/manifests/clusterrolebinding.yaml", size: 0, mode: os.FileMode(0), modTime: time.Unix(0, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +// Asset loads and returns the asset for the given name. +// It returns an error if the asset could not be found or +// could not be loaded. +func Asset(name string) ([]byte, error) { + cannonicalName := strings.Replace(name, "\\", "/", -1) + if f, ok := _bindata[cannonicalName]; ok { + a, err := f() + if err != nil { + return nil, fmt.Errorf("Asset %s can't read by error: %v", name, err) + } + return a.bytes, nil + } + return nil, fmt.Errorf("Asset %s not found", name) +} + +// MustAsset is like Asset but panics when Asset would return an error. +// It simplifies safe initialization of global variables. +func MustAsset(name string) []byte { + a, err := Asset(name) + if err != nil { + panic("asset: Asset(" + name + "): " + err.Error()) + } + + return a +} + +// AssetInfo loads and returns the asset info for the given name. +// It returns an error if the asset could not be found or +// could not be loaded. +func AssetInfo(name string) (os.FileInfo, error) { + cannonicalName := strings.Replace(name, "\\", "/", -1) + if f, ok := _bindata[cannonicalName]; ok { + a, err := f() + if err != nil { + return nil, fmt.Errorf("AssetInfo %s can't read by error: %v", name, err) + } + return a.info, nil + } + return nil, fmt.Errorf("AssetInfo %s not found", name) +} + +// AssetNames returns the names of the assets. +func AssetNames() []string { + names := make([]string, 0, len(_bindata)) + for name := range _bindata { + names = append(names, name) + } + return names +} + +// _bindata is a table, holding each asset generator, mapped to its name. +var _bindata = map[string]func() (*asset, error){ + "pkg/hub/csr/manifests/clusterrole.yaml": pkgHubCsrManifestsClusterroleYaml, + "pkg/hub/csr/manifests/clusterrolebinding.yaml": pkgHubCsrManifestsClusterrolebindingYaml, +} + +// AssetDir returns the file names below a certain +// directory embedded in the file by go-bindata. +// For example if you run go-bindata on data/... and data contains the +// following hierarchy: +// data/ +// foo.txt +// img/ +// a.png +// b.png +// then AssetDir("data") would return []string{"foo.txt", "img"} +// AssetDir("data/img") would return []string{"a.png", "b.png"} +// AssetDir("foo.txt") and AssetDir("notexist") would return an error +// AssetDir("") will return []string{"data"}. +func AssetDir(name string) ([]string, error) { + node := _bintree + if len(name) != 0 { + cannonicalName := strings.Replace(name, "\\", "/", -1) + pathList := strings.Split(cannonicalName, "/") + for _, p := range pathList { + node = node.Children[p] + if node == nil { + return nil, fmt.Errorf("Asset %s not found", name) + } + } + } + if node.Func != nil { + return nil, fmt.Errorf("Asset %s not found", name) + } + rv := make([]string, 0, len(node.Children)) + for childName := range node.Children { + rv = append(rv, childName) + } + return rv, nil +} + +type bintree struct { + Func func() (*asset, error) + Children map[string]*bintree +} + +var _bintree = &bintree{nil, map[string]*bintree{ + "pkg": {nil, map[string]*bintree{ + "hub": {nil, map[string]*bintree{ + "csr": {nil, map[string]*bintree{ + "manifests": {nil, map[string]*bintree{ + "clusterrole.yaml": {pkgHubCsrManifestsClusterroleYaml, map[string]*bintree{}}, + "clusterrolebinding.yaml": {pkgHubCsrManifestsClusterrolebindingYaml, map[string]*bintree{}}, + }}, + }}, + }}, + }}, +}} + +// RestoreAsset restores an asset under the given directory +func RestoreAsset(dir, name string) error { + data, err := Asset(name) + if err != nil { + return err + } + info, err := AssetInfo(name) + if err != nil { + return err + } + err = os.MkdirAll(_filePath(dir, filepath.Dir(name)), os.FileMode(0755)) + if err != nil { + return err + } + err = ioutil.WriteFile(_filePath(dir, name), data, info.Mode()) + if err != nil { + return err + } + err = os.Chtimes(_filePath(dir, name), info.ModTime(), info.ModTime()) + if err != nil { + return err + } + return nil +} + +// RestoreAssets restores an asset under the given directory recursively +func RestoreAssets(dir, name string) error { + children, err := AssetDir(name) + // File + if err != nil { + return RestoreAsset(dir, name) + } + // Dir + for _, child := range children { + err = RestoreAssets(dir, filepath.Join(name, child)) + if err != nil { + return err + } + } + return nil +} + +func _filePath(dir, name string) string { + cannonicalName := strings.Replace(name, "\\", "/", -1) + return filepath.Join(append([]string{dir}, strings.Split(cannonicalName, "/")...)...) +} diff --git a/pkg/hub/csr/controller.go b/pkg/hub/csr/controller.go new file mode 100644 index 000000000..caeeebb53 --- /dev/null +++ b/pkg/hub/csr/controller.go @@ -0,0 +1,195 @@ +package csr + +import ( + "context" + "crypto/x509" + "encoding/pem" + "fmt" + "path/filepath" + "strings" + + "github.com/open-cluster-management/registration/pkg/hub/csr/bindata" + "github.com/openshift/library-go/pkg/assets" + "github.com/openshift/library-go/pkg/controller/factory" + "github.com/openshift/library-go/pkg/operator/events" + "github.com/openshift/library-go/pkg/operator/resource/resourceapply" + operatorhelpers "github.com/openshift/library-go/pkg/operator/v1helpers" + authorizationv1 "k8s.io/api/authorization/v1" + certificatesv1beta1 "k8s.io/api/certificates/v1beta1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes" + "k8s.io/klog" +) + +const ( + manifestDir = "pkg/hub/csr" + subjectPrefix = "system:open-cluster-management:" +) + +// csrController reconciles instances of spoke cluster CertificateSigningRequests on the hub. +type csrController struct { + kubeClient kubernetes.Interface + eventRecorder events.Recorder +} + +// NewCSRController creates a new csr controller +func NewCSRController(kubeClient kubernetes.Interface, csrInformer factory.Informer, recorder events.Recorder) factory.Controller { + c := &csrController{ + kubeClient: kubeClient, + eventRecorder: recorder.WithComponentSuffix("csr-controller"), + } + return factory.New(). + WithInformersQueueKeyFunc(func(obj runtime.Object) string { + accessor, _ := meta.Accessor(obj) + return accessor.GetName() + }, csrInformer). + WithSync(c.sync). + ToController("CSRController", recorder) +} + +func (c *csrController) sync(ctx context.Context, syncCtx factory.SyncContext) error { + csrName := syncCtx.QueueKey() + klog.V(4).Infof("Reconciling CertificateSigningRequests %q", csrName) + csr, err := c.kubeClient.CertificatesV1beta1().CertificateSigningRequests().Get(ctx, csrName, metav1.GetOptions{}) + if errors.IsNotFound(err) { + return nil + } + if err != nil { + return err + } + + // Current csr is denied, do nothing. + approved, denied := getCertApprovalCondition(&csr.Status) + if denied { + return nil + } + + // Recognize whether current csr is a spoker clsuter csr. + recognized, spokeClusterName := recognize(csr) + if !recognized { + klog.V(4).Infof("CSR %q was not recognized", csr.Name) + return nil + } + + // Current spoker clsuter csr is approved, apply the corresponding clusterrole/clusterrolebinding. + if approved { + resourceResults := resourceapply.ApplyDirectly( + resourceapply.NewKubeClientHolder(c.kubeClient), + syncCtx.Recorder(), + func(name string) ([]byte, error) { + config := struct { + SpokeClusterName string + }{ + SpokeClusterName: spokeClusterName, + } + return assets.MustCreateAssetFromTemplate(name, bindata.MustAsset(filepath.Join(manifestDir, name)), config).Data, nil + }, + "manifests/clusterrole.yaml", + "manifests/clusterrolebinding.yaml", + ) + errs := []error{} + for _, result := range resourceResults { + if result.Error != nil { + errs = append(errs, fmt.Errorf("%q (%T): %w", result.File, result.Type, result.Error)) + } + } + return operatorhelpers.NewMultiLineAggregate(errs) + } + + // Using SubjectAccessReview API to authorize whether the current spoke agent has been authorized to renew its csr. + // A spoke agent is authorized after its cluster is accepted by hub cluster admin. + allowed, err := c.authorize(ctx, csr) + if err != nil { + return err + } + if !allowed { + klog.V(4).Infof("Spoke cluster csr %q cannont be auto approved due to subject access review was not approved", csr.Name) + return nil + } + + // Auto approve the spoker clsuter csr + csr.Status.Conditions = append(csr.Status.Conditions, certificatesv1beta1.CertificateSigningRequestCondition{ + Type: certificatesv1beta1.CertificateApproved, + Reason: "AutoApproved", + Message: "Auto approving spoke cluster agent certificate after SubjectAccessReview.", + }) + _, err = c.kubeClient.CertificatesV1beta1().CertificateSigningRequests().UpdateApproval(ctx, csr, metav1.UpdateOptions{}) + if err != nil { + return err + } + c.eventRecorder.Eventf("SpokeClusterCSRAutoApproved", "spoke cluster csr %q is auto approved by hub csr controller", csr.Name) + return nil +} + +func (c *csrController) authorize(ctx context.Context, csr *certificatesv1beta1.CertificateSigningRequest) (bool, error) { + extra := make(map[string]authorizationv1.ExtraValue) + for k, v := range csr.Spec.Extra { + extra[k] = authorizationv1.ExtraValue(v) + } + + sar := &authorizationv1.SubjectAccessReview{ + Spec: authorizationv1.SubjectAccessReviewSpec{ + User: csr.Spec.Username, + UID: csr.Spec.UID, + Groups: csr.Spec.Groups, + Extra: extra, + ResourceAttributes: &authorizationv1.ResourceAttributes{ + Group: "certificates.k8s.io", + Resource: "certificatesigningrequests", + Verb: "create", + Subresource: "spokeclusteragent", + }, + }, + } + sar, err := c.kubeClient.AuthorizationV1().SubjectAccessReviews().Create(ctx, sar, metav1.CreateOptions{}) + if err != nil { + return false, err + } + return sar.Status.Allowed, nil +} + +// To recognize a valid spoke cluster csr, we check +// 1. if organization field and commonName field in csr request is valid. +// 2. if user name in csr is the same as commonName field in csr request. +func recognize(csr *certificatesv1beta1.CertificateSigningRequest) (recognized bool, spokeClusterName string) { + block, _ := pem.Decode(csr.Spec.Request) + if block == nil || block.Type != "CERTIFICATE REQUEST" { + klog.V(4).Infof("csr %q was not recognized: PEM block type is not CERTIFICATE REQUEST", csr.Name) + return false, "" + } + + x509cr, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + klog.V(4).Infof("csr %q was not recognized: %w", csr.Name, err) + return false, "" + } + + if len(x509cr.Subject.Organization) != 1 { + return false, "" + } + organization := x509cr.Subject.Organization[0] + if !strings.HasPrefix(organization, subjectPrefix) { + return false, "" + } + + if !strings.HasPrefix(x509cr.Subject.CommonName, organization) { + return false, "" + } + + return csr.Spec.Username == x509cr.Subject.CommonName, strings.TrimPrefix(organization, subjectPrefix) +} + +func getCertApprovalCondition(status *certificatesv1beta1.CertificateSigningRequestStatus) (approved, denied bool) { + for _, c := range status.Conditions { + if c.Type == certificatesv1beta1.CertificateApproved { + approved = true + } + if c.Type == certificatesv1beta1.CertificateDenied { + denied = true + } + } + return +} diff --git a/pkg/hub/csr/controller_test.go b/pkg/hub/csr/controller_test.go new file mode 100644 index 000000000..3573322f6 --- /dev/null +++ b/pkg/hub/csr/controller_test.go @@ -0,0 +1,299 @@ +package csr + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/rand" + "net" + "testing" + + "github.com/openshift/library-go/pkg/operator/events" + "github.com/openshift/library-go/pkg/operator/events/eventstesting" + authorizationv1 "k8s.io/api/authorization/v1" + certificatesv1beta1 "k8s.io/api/certificates/v1beta1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/runtime" + kubefake "k8s.io/client-go/kubernetes/fake" + clienttesting "k8s.io/client-go/testing" + "k8s.io/client-go/util/workqueue" +) + +const testCSRName = "test_csr" + +func TestSync(t *testing.T) { + cases := []struct { + name string + startingCSRs []runtime.Object + autoApprovingAllowed bool + validateActions func(t *testing.T, actions []clienttesting.Action) + expectedErr string + }{ + { + name: "sync a deleted csr", + startingCSRs: []runtime.Object{}, + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 1 { + t.Errorf("expected 1 call but got: %#v", actions) + } + assertAction(t, actions[0], "get") + }, + }, + { + name: "sync a denied csr", + startingCSRs: []runtime.Object{newDeniedCSR()}, + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 1 { + t.Errorf("expected 1 call but got: %#v", actions) + } + assertAction(t, actions[0], "get") + }, + }, + { + name: "sync an unrecognized csr", + startingCSRs: []runtime.Object{newUnrecognizedCSR()}, + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 1 { + t.Errorf("expected 1 call but got: %#v", actions) + } + assertAction(t, actions[0], "get") + }, + }, + { + name: "sync a hub cluster admin approved csr", + startingCSRs: []runtime.Object{newApprovedCSR()}, + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 5 { + t.Errorf("expected 5 call but got: %#v", actions) + } + assertAction(t, actions[4], "create") + assertClusterRoleBindingCreated(t, actions[4].(clienttesting.CreateActionImpl).Object) + }, + }, + { + name: "deny an auto approving csr", + startingCSRs: []runtime.Object{newRecognizedCSR()}, + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 2 { + t.Errorf("expected 2 call but got: %#v", actions) + } + assertAction(t, actions[1], "create") + assertSubjectAccessReviewCreated(t, actions[1].(clienttesting.CreateActionImpl).Object) + }, + }, + { + name: "allow an auto approving csr", + startingCSRs: []runtime.Object{newRecognizedCSR()}, + autoApprovingAllowed: true, + validateActions: func(t *testing.T, actions []clienttesting.Action) { + if len(actions) != 3 { + t.Errorf("expected 3 call but got: %#v", actions) + } + assertAction(t, actions[2], "update") + assertCondition(t, actions[2].(clienttesting.UpdateActionImpl).Object, certificatesv1beta1.CertificateApproved, "AutoApproved") + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + kubeClient := kubefake.NewSimpleClientset(c.startingCSRs...) + kubeClient.PrependReactor( + "create", + "subjectaccessreviews", + func(action clienttesting.Action) (handled bool, ret runtime.Object, err error) { + return true, &authorizationv1.SubjectAccessReview{ + Status: authorizationv1.SubjectAccessReviewStatus{ + Allowed: c.autoApprovingAllowed, + }, + }, nil + }, + ) + + ctrl := &csrController{kubeClient, eventstesting.NewTestingEventRecorder(t)} + syncErr := ctrl.sync(context.TODO(), newFakeSyncContext(t)) + if len(c.expectedErr) > 0 && syncErr == nil { + t.Errorf("expected %q error", c.expectedErr) + return + } + if len(c.expectedErr) > 0 && syncErr != nil && syncErr.Error() != c.expectedErr { + t.Errorf("expected %q error, got %q", c.expectedErr, syncErr.Error()) + return + } + if syncErr != nil { + t.Errorf("unexpected err: %v", syncErr) + } + + c.validateActions(t, kubeClient.Actions()) + }) + } +} + +func TestRecognize(t *testing.T) { + cases := []struct { + name string + csr *certificatesv1beta1.CertificateSigningRequest + recognized bool + expectedClusterName string + }{ + { + name: "a wrong block type", + csr: newCSR("", []string{}, "", "RSA PRIVATE KEY"), + recognized: false, + }, + { + name: "an empty organization", + csr: newCSR("", []string{}, "", "CERTIFICATE REQUEST"), + recognized: false, + }, + { + name: "an invalid organization", + csr: newCSR("", []string{"test"}, "", "CERTIFICATE REQUEST"), + recognized: false, + }, + { + name: "an invalid common name", + csr: newCSR("", []string{"system:open-cluster-management:spokecluster1"}, "", "CERTIFICATE REQUEST"), + recognized: false, + }, + { + name: "a recognied csr", + csr: newRecognizedCSR(), + recognized: true, + expectedClusterName: "spokecluster1", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + recognized, clusterName := recognize(c.csr) + if recognized != c.recognized { + t.Errorf("expected %t, but failed", c.recognized) + } + if clusterName != c.expectedClusterName { + t.Errorf("expected %q, but got %q", c.expectedClusterName, clusterName) + } + }) + } +} + +func newCSR(cn string, orgs []string, username string, reqBlockType string) *certificatesv1beta1.CertificateSigningRequest { + insecureRand := rand.New(rand.NewSource(0)) + signerName := "tester" + pk, err := ecdsa.GenerateKey(elliptic.P256(), insecureRand) + if err != nil { + panic(err) + } + csrb, err := x509.CreateCertificateRequest(insecureRand, &x509.CertificateRequest{ + Subject: pkix.Name{ + CommonName: cn, + Organization: orgs, + }, + DNSNames: []string{}, + EmailAddresses: []string{}, + IPAddresses: []net.IP{}, + }, pk) + if err != nil { + panic(err) + } + return &certificatesv1beta1.CertificateSigningRequest{ + Spec: certificatesv1beta1.CertificateSigningRequestSpec{ + Username: username, + Usages: []certificatesv1beta1.KeyUsage{}, + SignerName: &signerName, + Request: pem.EncodeToMemory(&pem.Block{Type: reqBlockType, Bytes: csrb}), + }, + } +} + +func newRecognizedCSR() *certificatesv1beta1.CertificateSigningRequest { + csr := newCSR( + "system:open-cluster-management:spokecluster1:spokeagent1", + []string{"system:open-cluster-management:spokecluster1"}, + "system:open-cluster-management:spokecluster1:spokeagent1", + "CERTIFICATE REQUEST", + ) + csr.Name = testCSRName + return csr +} + +func newUnrecognizedCSR() *certificatesv1beta1.CertificateSigningRequest { + csr := newCSR( + "system:open-cluster-management:spokecluster1:spokeagent2", + []string{"system:open-cluster-management:spokecluster1"}, + "system:open-cluster-management:spokecluster1:spokeagent1", + "CERTIFICATE REQUEST", + ) + csr.Name = testCSRName + return csr +} + +func newDeniedCSR() *certificatesv1beta1.CertificateSigningRequest { + csr := newRecognizedCSR() + csr.Status.Conditions = append(csr.Status.Conditions, certificatesv1beta1.CertificateSigningRequestCondition{ + Type: certificatesv1beta1.CertificateDenied, + }) + return csr +} + +func newApprovedCSR() *certificatesv1beta1.CertificateSigningRequest { + csr := newRecognizedCSR() + csr.Status.Conditions = append(csr.Status.Conditions, certificatesv1beta1.CertificateSigningRequestCondition{ + Type: certificatesv1beta1.CertificateApproved, + }) + return csr +} + +func assertAction(t *testing.T, actual clienttesting.Action, expected string) { + if actual.GetVerb() != expected { + t.Errorf("expected %s action but got: %#v", expected, actual) + } +} + +func assertClusterRoleBindingCreated(t *testing.T, actual runtime.Object) { + _, ok := actual.(*rbacv1.ClusterRoleBinding) + if !ok { + t.Errorf("expected clusterrolebinding created, but got: %#v", actual) + } +} + +func assertSubjectAccessReviewCreated(t *testing.T, actual runtime.Object) { + _, ok := actual.(*authorizationv1.SubjectAccessReview) + if !ok { + t.Errorf("expected subjectaccessreview created, but got: %#v", actual) + } +} + +func assertCondition(t *testing.T, actual runtime.Object, expectedCondition certificatesv1beta1.RequestConditionType, expectedReason string) { + csr := actual.(*certificatesv1beta1.CertificateSigningRequest) + conditions := csr.Status.Conditions + if len(conditions) != 1 { + t.Errorf("expected 1 condition but got: %#v", conditions) + } + condition := conditions[0] + if condition.Type != expectedCondition { + t.Errorf("expected %s but got: %s", expectedCondition, condition.Type) + } + if condition.Reason != expectedReason { + t.Errorf("expected %s but got: %s", expectedReason, condition.Reason) + } +} + +type fakeSyncContext struct { + csrName string + recorder events.Recorder +} + +func newFakeSyncContext(t *testing.T) *fakeSyncContext { + return &fakeSyncContext{ + csrName: testCSRName, + recorder: eventstesting.NewTestingEventRecorder(t), + } +} + +func (f fakeSyncContext) Queue() workqueue.RateLimitingInterface { return nil } +func (f fakeSyncContext) QueueKey() string { return f.csrName } +func (f fakeSyncContext) Recorder() events.Recorder { return f.recorder } diff --git a/pkg/hub/csr/doc.go b/pkg/hub/csr/doc.go new file mode 100644 index 000000000..3d4eae314 --- /dev/null +++ b/pkg/hub/csr/doc.go @@ -0,0 +1,3 @@ +// package csr contains the hub-side reconciler for the spoke cluster CertificateSigningRequests +// resource. +package csr diff --git a/pkg/hub/csr/manifests/clusterrole.yaml b/pkg/hub/csr/manifests/clusterrole.yaml new file mode 100644 index 000000000..6753dec4e --- /dev/null +++ b/pkg/hub/csr/manifests/clusterrole.yaml @@ -0,0 +1,13 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: system:open-cluster-management:spokecluster:{{ .SpokeClusterName }}-csrapproved +rules: +# Allow spoke agent to create/get/list/watch certificatesigningrequests +- apiGroups: ["certificates.k8s.io"] + resources: ["certificatesigningrequests"] + verbs: ["create", "get", "list", "watch"] +# Allow spoke agent to create/get/list/watch spoke cluster +- apiGroups: ["cluster.open-cluster-management.io"] + resources: ["spokeclusters"] + verbs: ["create", "get", "list", "watch"] diff --git a/pkg/hub/csr/manifests/clusterrolebinding.yaml b/pkg/hub/csr/manifests/clusterrolebinding.yaml new file mode 100644 index 000000000..3d8bacf6f --- /dev/null +++ b/pkg/hub/csr/manifests/clusterrolebinding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: system:open-cluster-management:spokecluster:{{ .SpokeClusterName }}-csrapproved +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:open-cluster-management:spokecluster:{{ .SpokeClusterName }}-csrapproved +subjects: +- kind: Group + apiGroup: rbac.authorization.k8s.io + name: system:open-cluster-management:{{ .SpokeClusterName }} diff --git a/pkg/hub/manager.go b/pkg/hub/manager.go index 03a7aab63..37ef45e0f 100644 --- a/pkg/hub/manager.go +++ b/pkg/hub/manager.go @@ -9,7 +9,9 @@ import ( clusterv1client "github.com/open-cluster-management/api/client/cluster/clientset/versioned" clusterv1informers "github.com/open-cluster-management/api/client/cluster/informers/externalversions" + "github.com/open-cluster-management/registration/pkg/hub/csr" "github.com/open-cluster-management/registration/pkg/hub/spokecluster" + kubeinformers "k8s.io/client-go/informers" ) // RunControllerManager starts the controllers on hub to manage spoke cluster registraiton. @@ -23,7 +25,9 @@ func RunControllerManager(ctx context.Context, controllerContext *controllercmd. if err != nil { return err } + clusterInformers := clusterv1informers.NewSharedInformerFactory(clusterClient, 10*time.Minute) + csrInformers := kubeinformers.NewSharedInformerFactory(kubeClient, 10*time.Minute) spokeClusterController := spokecluster.NewSpokeClusterController( kubeClient, @@ -32,9 +36,17 @@ func RunControllerManager(ctx context.Context, controllerContext *controllercmd. controllerContext.EventRecorder, ) + csrController := csr.NewCSRController( + kubeClient, + csrInformers.Certificates().V1beta1().CertificateSigningRequests().Informer(), + controllerContext.EventRecorder, + ) + go clusterInformers.Start(ctx.Done()) + go csrInformers.Start(ctx.Done()) go spokeClusterController.Run(ctx, 1) + go csrController.Run(ctx, 1) <-ctx.Done() return nil diff --git a/pkg/hub/spokecluster/bindata/bindata.go b/pkg/hub/spokecluster/bindata/bindata.go index 8886b2811..4c9692ecb 100644 --- a/pkg/hub/spokecluster/bindata/bindata.go +++ b/pkg/hub/spokecluster/bindata/bindata.go @@ -96,7 +96,8 @@ roleRef: subjects: - kind: Group apiGroup: rbac.authorization.k8s.io - name: system:open-cluster-management:spokecluster:{{ .SpokeClusterName }}`) + name: system:open-cluster-management:{{ .SpokeClusterName }} +`) func pkgHubSpokeclusterManifestsSpokeclusterClusterrolebindingYamlBytes() ([]byte, error) { return _pkgHubSpokeclusterManifestsSpokeclusterClusterrolebindingYaml, nil @@ -116,7 +117,8 @@ func pkgHubSpokeclusterManifestsSpokeclusterClusterrolebindingYaml() (*asset, er var _pkgHubSpokeclusterManifestsSpokeclusterNamespaceYaml = []byte(`apiVersion: v1 kind: Namespace metadata: - name: {{ .SpokeClusterName }}`) + name: {{ .SpokeClusterName }} +`) func pkgHubSpokeclusterManifestsSpokeclusterNamespaceYamlBytes() ([]byte, error) { return _pkgHubSpokeclusterManifestsSpokeclusterNamespaceYaml, nil diff --git a/pkg/hub/spokecluster/manifests/spokecluster-clusterrolebinding.yaml b/pkg/hub/spokecluster/manifests/spokecluster-clusterrolebinding.yaml index 8bfac4b59..3d3bc76ef 100644 --- a/pkg/hub/spokecluster/manifests/spokecluster-clusterrolebinding.yaml +++ b/pkg/hub/spokecluster/manifests/spokecluster-clusterrolebinding.yaml @@ -9,4 +9,4 @@ roleRef: subjects: - kind: Group apiGroup: rbac.authorization.k8s.io - name: system:open-cluster-management:spokecluster:{{ .SpokeClusterName }} \ No newline at end of file + name: system:open-cluster-management:{{ .SpokeClusterName }} diff --git a/pkg/hub/spokecluster/manifests/spokecluster-namespace.yaml b/pkg/hub/spokecluster/manifests/spokecluster-namespace.yaml index 49634a41f..a4351ab9b 100644 --- a/pkg/hub/spokecluster/manifests/spokecluster-namespace.yaml +++ b/pkg/hub/spokecluster/manifests/spokecluster-namespace.yaml @@ -1,4 +1,4 @@ apiVersion: v1 kind: Namespace metadata: - name: {{ .SpokeClusterName }} \ No newline at end of file + name: {{ .SpokeClusterName }}