diff --git a/cmd/controller-manager/app/server.go b/cmd/controller-manager/app/server.go index e40e7a02d..4efe39533 100644 --- a/cmd/controller-manager/app/server.go +++ b/cmd/controller-manager/app/server.go @@ -18,6 +18,8 @@ package app import ( "fmt" + "os" + "github.com/spf13/cobra" utilerrors "k8s.io/apimachinery/pkg/util/errors" cliflag "k8s.io/component-base/cli/flag" @@ -29,6 +31,7 @@ import ( appcontroller "kubesphere.io/kubesphere/pkg/controller/application" "kubesphere.io/kubesphere/pkg/controller/namespace" "kubesphere.io/kubesphere/pkg/controller/network/webhooks" + "kubesphere.io/kubesphere/pkg/controller/serviceaccount" "kubesphere.io/kubesphere/pkg/controller/user" "kubesphere.io/kubesphere/pkg/controller/workspace" "kubesphere.io/kubesphere/pkg/controller/workspacerole" @@ -43,7 +46,6 @@ import ( "kubesphere.io/kubesphere/pkg/simple/client/s3" "kubesphere.io/kubesphere/pkg/utils/metrics" "kubesphere.io/kubesphere/pkg/utils/term" - "os" application "sigs.k8s.io/application/controllers" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/manager" @@ -231,6 +233,12 @@ func run(s *options.KubeSphereControllerManagerOptions, stopCh <-chan struct{}) klog.Fatal("Unable to create application controller") } + saReconciler := &serviceaccount.Reconciler{} + + if err = saReconciler.SetupWithManager(mgr); err != nil { + klog.Fatal("Unable to create ServiceAccount controller") + } + // TODO(jeff): refactor config with CRD servicemeshEnabled := s.ServiceMeshOptions != nil && len(s.ServiceMeshOptions.IstioPilotHost) != 0 if err = addControllers(mgr, diff --git a/pkg/apis/iam/v1alpha2/types.go b/pkg/apis/iam/v1alpha2/types.go index 4d06f3ccc..03c1686b3 100644 --- a/pkg/apis/iam/v1alpha2/types.go +++ b/pkg/apis/iam/v1alpha2/types.go @@ -65,6 +65,7 @@ const ( UserReferenceLabel = "iam.kubesphere.io/user-ref" IdentifyProviderLabel = "iam.kubesphere.io/identify-provider" OriginUIDLabel = "iam.kubesphere.io/origin-uid" + ServiceAccountReferenceLabel = "iam.kubesphere.io/serviceaccount-ref" FieldEmail = "email" ExtraEmail = FieldEmail ExtraIdentityProvider = "idp" diff --git a/pkg/controller/serviceaccount/serviceaccount_controller.go b/pkg/controller/serviceaccount/serviceaccount_controller.go new file mode 100644 index 000000000..523cc20f2 --- /dev/null +++ b/pkg/controller/serviceaccount/serviceaccount_controller.go @@ -0,0 +1,134 @@ +/* +Copyright 2020 The KubeSphere Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package serviceaccount + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + iamv1alpha2 "kubesphere.io/kubesphere/pkg/apis/iam/v1alpha2" + controllerutils "kubesphere.io/kubesphere/pkg/controller/utils/controller" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +const ( + controllerName = "serviceaccount-controller" +) + +// Reconciler reconciles a ServiceAccount object +type Reconciler struct { + client.Client + logger logr.Logger + recorder record.EventRecorder + scheme *runtime.Scheme +} + +func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { + if r.Client == nil { + r.Client = mgr.GetClient() + } + if r.logger == nil { + r.logger = ctrl.Log.WithName("controllers").WithName(controllerName) + } + if r.scheme == nil { + r.scheme = mgr.GetScheme() + } + if r.recorder == nil { + r.recorder = mgr.GetEventRecorderFor(controllerName) + } + return ctrl.NewControllerManagedBy(mgr). + Named(controllerName). + For(&corev1.ServiceAccount{}). + Complete(r) +} + +// +kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=get;list;watch +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=get;list;watch;create;update;patch;delete +func (r *Reconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) { + logger := r.logger.WithValues("serivceaccount", req.NamespacedName) + ctx := context.Background() + sa := &corev1.ServiceAccount{} + if err := r.Get(ctx, req.NamespacedName, sa); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + if _, ok := sa.Annotations[iamv1alpha2.RoleAnnotation]; ok && sa.ObjectMeta.DeletionTimestamp.IsZero() { + if err := r.CreateOrUpdateRoleBinding(ctx, logger, sa); err != nil { + r.recorder.Event(sa, corev1.EventTypeWarning, controllerutils.FailedSynced, err.Error()) + return ctrl.Result{}, err + } + r.recorder.Event(sa, corev1.EventTypeNormal, controllerutils.SuccessSynced, controllerutils.MessageResourceSynced) + } + return ctrl.Result{}, nil +} + +func (r *Reconciler) CreateOrUpdateRoleBinding(ctx context.Context, logger logr.Logger, sa *corev1.ServiceAccount) error { + roleName := sa.Annotations[iamv1alpha2.RoleAnnotation] + if roleName == "" { + return nil + } + var role rbacv1.Role + if err := r.Get(ctx, types.NamespacedName{Name: roleName, Namespace: sa.Namespace}, &role); err != nil { + return err + } + + // Delete existing rolebindings. + saRoleBinding := &rbacv1.RoleBinding{} + _ = r.Client.DeleteAllOf(ctx, saRoleBinding, client.InNamespace(sa.Namespace), client.MatchingLabels{iamv1alpha2.ServiceAccountReferenceLabel: sa.Name}) + + saRoleBinding = &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: fmt.Sprintf("%s-%s-", sa.Name, roleName), + Labels: map[string]string{iamv1alpha2.ServiceAccountReferenceLabel: sa.Name}, + Namespace: sa.Namespace, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: iamv1alpha2.ResourceKindRole, + Name: roleName, + }, + Subjects: []rbacv1.Subject{ + { + Name: sa.Name, + Kind: rbacv1.ServiceAccountKind, + Namespace: sa.Namespace, + }, + }, + } + + if err := controllerutil.SetControllerReference(sa, saRoleBinding, r.scheme); err != nil { + logger.Error(err, "set controller reference failed") + return err + } + + logger.V(4).Info("create ServiceAccount rolebinding", "ServiceAccount", sa.Name) + if err := r.Client.Create(ctx, saRoleBinding); err != nil { + logger.Error(err, "create rolebinding failed") + return err + } + return nil +} diff --git a/pkg/controller/serviceaccount/serviceaccount_controller_suite_test.go b/pkg/controller/serviceaccount/serviceaccount_controller_suite_test.go new file mode 100644 index 000000000..f394cc4b3 --- /dev/null +++ b/pkg/controller/serviceaccount/serviceaccount_controller_suite_test.go @@ -0,0 +1,101 @@ +/* +Copyright 2019 The KubeSphere Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package serviceaccount + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/onsi/gomega/gexec" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/klog/klogr" + "kubesphere.io/kubesphere/pkg/apis" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/controller-runtime/pkg/envtest/printer" +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var k8sClient client.Client +var k8sManager ctrl.Manager +var testEnv *envtest.Environment + +func TestMain(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecsWithDefaultAndCustomReporters(t, + "ServiceAccount Controller Test Suite", + []Reporter{printer.NewlineReporter{}}) +} + +var _ = BeforeSuite(func(done Done) { + logf.SetLogger(klogr.New()) + + By("bootstrapping test environment") + t := true + if os.Getenv("TEST_USE_EXISTING_CLUSTER") == "true" { + testEnv = &envtest.Environment{ + UseExistingCluster: &t, + } + } else { + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crds")}, + AttachControlPlaneOutput: false, + } + } + + cfg, err := testEnv.Start() + Expect(err).ToNot(HaveOccurred()) + Expect(cfg).ToNot(BeNil()) + + err = apis.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + k8sManager, err = ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + MetricsBindAddress: "0", + }) + Expect(err).ToNot(HaveOccurred()) + + err = (&Reconciler{}).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + + go func() { + err = k8sManager.Start(ctrl.SetupSignalHandler()) + Expect(err).ToNot(HaveOccurred()) + }() + + k8sClient = k8sManager.GetClient() + Expect(k8sClient).ToNot(BeNil()) + + close(done) +}, 160) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + gexec.KillAndWait(5 * time.Second) + err := testEnv.Stop() + Expect(err).ToNot(HaveOccurred()) +}) diff --git a/pkg/controller/serviceaccount/serviceaccount_controller_test.go b/pkg/controller/serviceaccount/serviceaccount_controller_test.go new file mode 100644 index 000000000..2d65ca3d7 --- /dev/null +++ b/pkg/controller/serviceaccount/serviceaccount_controller_test.go @@ -0,0 +1,83 @@ +/* +Copyright 2019 The KubeSphere Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package serviceaccount + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + iamv1alpha2 "kubesphere.io/kubesphere/pkg/apis/iam/v1alpha2" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var _ = Describe("ServiceAccount", func() { + const ( + saName = "test-serviceaccount" + saNamespace = "default" + saRole = "test-role" + timeout = time.Second * 30 + interval = time.Second * 1 + ) + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: saRole, + Namespace: saNamespace, + }, + } + BeforeEach(func() { + // Create workspace + Expect(k8sClient.Create(context.Background(), role)).Should(Succeed()) + }) + + // Add Tests for OpenAPI validation (or additonal CRD features) specified in + // your API definition. + // Avoid adding tests for vanilla CRUD operations because they would + // test Kubernetes API server, which isn't the goal here. + Context("ServiceAccount Controller", func() { + It("Should create successfully", func() { + ctx := context.Background() + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: saName, + Namespace: saNamespace, + Annotations: map[string]string{iamv1alpha2.RoleAnnotation: saRole}, + }, + } + + By("Expecting to create serviceaccount successfully") + Expect(k8sClient.Create(ctx, sa)).Should(Succeed()) + expectedSa := &corev1.ServiceAccount{} + Eventually(func() bool { + k8sClient.Get(ctx, types.NamespacedName{Name: sa.Name, Namespace: sa.Namespace}, expectedSa) + return !expectedSa.CreationTimestamp.IsZero() + }, timeout, interval).Should(BeTrue()) + + By("Expecting to bind role successfully") + rolebindings := &rbacv1.RoleBindingList{} + Eventually(func() bool { + k8sClient.List(ctx, rolebindings, client.InNamespace(sa.Namespace), client.MatchingLabels{iamv1alpha2.ServiceAccountReferenceLabel: sa.Name}) + return len(rolebindings.Items) == 1 + }, timeout, interval).Should(BeTrue()) + }) + }) +}) diff --git a/pkg/controller/utils/controller/basecontroller.go b/pkg/controller/utils/controller/basecontroller.go index b0976aa0c..5d6bcd32e 100644 --- a/pkg/controller/utils/controller/basecontroller.go +++ b/pkg/controller/utils/controller/basecontroller.go @@ -30,6 +30,9 @@ import ( const ( // SuccessSynced is used as part of the Event 'reason' when a Foo is synced SuccessSynced = "Synced" + + // FailedSynced is used as part of the Event 'reason' when a Foo is not synced + FailedSynced = "FailedSync" // is synced successfully MessageResourceSynced = "Synced successfully" )