diff --git a/api/v1alpha1/mcpserver_types.go b/api/v1alpha1/mcpserver_types.go index fe2c146..79aaf63 100644 --- a/api/v1alpha1/mcpserver_types.go +++ b/api/v1alpha1/mcpserver_types.go @@ -211,6 +211,7 @@ type MCPServerStatus struct { } // MCPServerDeployment +// +kubebuilder:validation:XValidation:rule="!(has(self.serviceAccount) && has(self.serviceAccountName))",message="serviceAccount and serviceAccountName are mutually exclusive" type MCPServerDeployment struct { // Image defines the container image to to deploy the MCP server. // +optional @@ -263,10 +264,14 @@ type MCPServerDeployment struct { // +optional InitContainer *InitContainerConfig `json:"initContainer,omitempty"` - // ServiceAccount defines the configuration for the ServiceAccount. + // ServiceAccount defines the configuration for the ServiceAccount to be created. // +optional ServiceAccount *ServiceAccountConfig `json:"serviceAccount,omitempty"` + // ServiceAccountName is the name of an existing ServiceAccount to use. + // +optional + ServiceAccountName string `json:"serviceAccountName,omitempty"` + // Sidecars defines additional containers to run alongside the MCP server container. // These containers will share the same pod and can share volumes with the main container. // +optional diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 0676428..691c9e3 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -22,7 +22,7 @@ package v1alpha1 import ( corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -53,7 +53,9 @@ func (in *HTTPTransportTLS) DeepCopyInto(out *HTTPTransportTLS) { // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPTransportTLS. func (in *HTTPTransportTLS) DeepCopy() *HTTPTransportTLS { - if in == nil { return nil } + if in == nil { + return nil + } out := new(HTTPTransportTLS) in.DeepCopyInto(out) return out @@ -281,7 +283,7 @@ func (in *MCPServerSpec) DeepCopyInto(out *MCPServerSpec) { if in.HTTPTransport != nil { in, out := &in.HTTPTransport, &out.HTTPTransport *out = new(HTTPTransport) - **out = **in + (*in).DeepCopyInto(*out) } } diff --git a/config/crd/bases/kagent.dev_mcpservers.yaml b/config/crd/bases/kagent.dev_mcpservers.yaml index a6f8db5..6730143 100644 --- a/config/crd/bases/kagent.dev_mcpservers.yaml +++ b/config/crd/bases/kagent.dev_mcpservers.yaml @@ -1887,7 +1887,7 @@ spec: type: object serviceAccount: description: ServiceAccount defines the configuration for the - ServiceAccount. + ServiceAccount to be created. properties: annotations: additionalProperties: @@ -1904,6 +1904,10 @@ spec: description: Labels to add to the ServiceAccount. type: object type: object + serviceAccountName: + description: ServiceAccountName is the name of an existing ServiceAccount + to use. + type: string sidecars: description: |- Sidecars defines additional containers to run alongside the MCP server container. @@ -5239,6 +5243,9 @@ spec: type: object type: array type: object + x-kubernetes-validations: + - message: serviceAccount and serviceAccountName are mutually exclusive + rule: '!(has(self.serviceAccount) && has(self.serviceAccountName))' httpTransport: description: HTTPTransport defines the configuration for a Streamable HTTP transport. diff --git a/pkg/controller/mcpserver_controller_test.go b/pkg/controller/mcpserver_controller_test.go index 6fe0e11..c1c632e 100644 --- a/pkg/controller/mcpserver_controller_test.go +++ b/pkg/controller/mcpserver_controller_test.go @@ -675,6 +675,120 @@ var _ = ginkgo.Describe("MCPServer Controller", func() { gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) }) + + ginkgo.Context("Service Account Configuration", func() { + ctx := context.Background() + + ginkgo.It("should use existing service account name when provided", func() { + ginkgo.By("Creating MCPServer with serviceAccountName") + serverName := "test-existing-sa" + existingSAName := "my-existing-sa" + server := &kagentdevv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: serverName, + Namespace: "default", + }, + Spec: kagentdevv1alpha1.MCPServerSpec{ + TransportType: kagentdevv1alpha1.TransportTypeStdio, + Deployment: kagentdevv1alpha1.MCPServerDeployment{ + Image: "test-image:latest", + Port: 3000, + ServiceAccountName: existingSAName, + }, + }, + } + + err := k8sClient.Create(ctx, server) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + ginkgo.By("Reconciling the MCPServer") + controllerReconciler := setupController() + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: serverName, + Namespace: "default", + }, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + ginkgo.By("Verifying deployment has the correct serviceAccountName") + deployment := &appsv1.Deployment{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: serverName, + Namespace: "default", + }, deployment) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(deployment.Spec.Template.Spec.ServiceAccountName).To(gomega.Equal(existingSAName)) + + ginkgo.By("Verifying no ServiceAccount was created for the MCPServer") + sa := &corev1.ServiceAccount{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: serverName, + Namespace: "default", + }, sa) + gomega.Expect(errors.IsNotFound(err)).To(gomega.BeTrue(), "ServiceAccount should not have been created") + + // Cleanup + err = k8sClient.Delete(ctx, server) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + + ginkgo.It("should create a new service account when serviceAccountName is not provided", func() { + ginkgo.By("Creating MCPServer with serviceAccountConfig") + serverName := "test-create-sa" + server := &kagentdevv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: serverName, + Namespace: "default", + }, + Spec: kagentdevv1alpha1.MCPServerSpec{ + TransportType: kagentdevv1alpha1.TransportTypeStdio, + Deployment: kagentdevv1alpha1.MCPServerDeployment{ + Image: "test-image:latest", + Port: 3000, + ServiceAccount: &kagentdevv1alpha1.ServiceAccountConfig{ + Annotations: map[string]string{"foo": "bar"}, + }, + }, + }, + } + + err := k8sClient.Create(ctx, server) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + ginkgo.By("Reconciling the MCPServer") + controllerReconciler := setupController() + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: serverName, + Namespace: "default", + }, + }) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + ginkgo.By("Verifying deployment has the correct serviceAccountName (defaulting to MCPServer name)") + deployment := &appsv1.Deployment{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: serverName, + Namespace: "default", + }, deployment) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(deployment.Spec.Template.Spec.ServiceAccountName).To(gomega.Equal(serverName)) + + ginkgo.By("Verifying the ServiceAccount was created") + sa := &corev1.ServiceAccount{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: serverName, + Namespace: "default", + }, sa) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(sa.Annotations).To(gomega.HaveKeyWithValue("foo", "bar")) + + // Cleanup + err = k8sClient.Delete(ctx, server) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) }) // Helper functions to reduce code duplication diff --git a/pkg/controller/transportadapter/transportadapter_translator.go b/pkg/controller/transportadapter/transportadapter_translator.go index b0c9932..c8e463f 100644 --- a/pkg/controller/transportadapter/transportadapter_translator.go +++ b/pkg/controller/transportadapter/transportadapter_translator.go @@ -64,10 +64,6 @@ func (t *transportAdapterTranslator) TranslateTransportAdapterOutputs( ctx context.Context, server *v1alpha1.MCPServer, ) ([]client.Object, error) { - serviceAccount, err := t.translateTransportAdapterServiceAccount(server) - if err != nil { - return nil, fmt.Errorf("failed to translate TransportAdapter service account: %w", err) - } deployment, err := t.translateTransportAdapterDeployment(server) if err != nil { return nil, fmt.Errorf("failed to translate TransportAdapter deployment: %w", err) @@ -78,14 +74,25 @@ func (t *transportAdapterTranslator) TranslateTransportAdapterOutputs( } configMap, err := t.translateTransportAdapterConfigMap(server) if err != nil { - return nil, fmt.Errorf("failed to translate TransportAdapter config map: %w", err) + return nil, fmt.Errorf("failed to marshal MCP server config to YAML: %w", err) } - return t.runPlugins(ctx, server, []client.Object{ - serviceAccount, + + objects := []client.Object{ deployment, service, configMap, - }) + } + + // Create new service account only when service account name is not specified + if server.Spec.Deployment.ServiceAccountName == "" { + serviceAccount, err := t.translateTransportAdapterServiceAccount(server) + if err != nil { + return nil, fmt.Errorf("failed to translate TransportAdapter service account: %w", err) + } + objects = append(objects, serviceAccount) + } + + return t.runPlugins(ctx, server, objects) } func (t *transportAdapterTranslator) translateTransportAdapterDeployment( @@ -154,12 +161,18 @@ func (t *transportAdapterTranslator) translateTransportAdapterDeployment( mainContainerResources = *server.Spec.Deployment.Resources } + // Determine ServiceAccountName to use + serviceAccountName := server.Name + if server.Spec.Deployment.ServiceAccountName != "" { + serviceAccountName = server.Spec.Deployment.ServiceAccountName + } + var template corev1.PodSpec switch server.Spec.TransportType { case v1alpha1.TransportTypeStdio: // copy the binary into the container when running with stdio template = corev1.PodSpec{ - ServiceAccountName: server.Name, + ServiceAccountName: serviceAccountName, SecurityContext: server.Spec.Deployment.PodSecurityContext, ImagePullSecrets: server.Spec.Deployment.ImagePullSecrets, Tolerations: server.Spec.Deployment.Tolerations, @@ -232,7 +245,7 @@ func (t *transportAdapterTranslator) translateTransportAdapterDeployment( cmd = []string{server.Spec.Deployment.Cmd} } template = corev1.PodSpec{ - ServiceAccountName: server.Name, + ServiceAccountName: serviceAccountName, SecurityContext: server.Spec.Deployment.PodSecurityContext, ImagePullSecrets: server.Spec.Deployment.ImagePullSecrets, Tolerations: server.Spec.Deployment.Tolerations,