diff --git a/api/v1alpha1/mcpserver_types.go b/api/v1alpha1/mcpserver_types.go index 556d2118..4f2aa20f 100644 --- a/api/v1alpha1/mcpserver_types.go +++ b/api/v1alpha1/mcpserver_types.go @@ -80,6 +80,12 @@ type MCPServerSpec struct { // +optional ConfigMapVolumeName string `json:"configMapVolumeName,omitempty"` + // Replicas is the number of MCP server pod replicas to run. + // Defaults to 1 if not specified. + // +optional + // +kubebuilder:validation:Minimum=1 + Replicas *int32 `json:"replicas,omitempty"` + // ServiceAccountName is the name of the ServiceAccount to use for the MCP server pods. // The ServiceAccount should have appropriate RBAC permissions for the MCP server's operations. // If not specified, the default ServiceAccount for the namespace will be used. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 95b6b7e6..10b6e778 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -98,6 +98,11 @@ func (in *MCPServerSpec) DeepCopyInto(out *MCPServerSpec) { *out = new(v1.LocalObjectReference) **out = **in } + if in.Replicas != nil { + in, out := &in.Replicas, &out.Replicas + *out = new(int32) + **out = **in + } if in.Env != nil { in, out := &in.Env, &out.Env *out = make([]v1.EnvVar, len(*in)) diff --git a/config/crd/bases/mcp.x-k8s.io_mcpservers.yaml b/config/crd/bases/mcp.x-k8s.io_mcpservers.yaml index 78bf8ee1..2bf61c72 100644 --- a/config/crd/bases/mcp.x-k8s.io_mcpservers.yaml +++ b/config/crd/bases/mcp.x-k8s.io_mcpservers.yaml @@ -558,6 +558,13 @@ spec: maximum: 65535 minimum: 1 type: integer + replicas: + description: |- + Replicas is the number of MCP server pod replicas to run. + Defaults to 1 if not specified. + format: int32 + minimum: 1 + type: integer secretKey: description: |- SecretKey specifies the key of the Secret to mount. diff --git a/internal/controller/mcpserver_controller.go b/internal/controller/mcpserver_controller.go index 4d00da3e..afe68c40 100644 --- a/internal/controller/mcpserver_controller.go +++ b/internal/controller/mcpserver_controller.go @@ -244,9 +244,11 @@ func (r *MCPServerReconciler) reconcileDeployment( !equality.Semantic.DeepEqual(oldPodSpec.SecurityContext, newPodSpec.SecurityContext) || !equality.Semantic.DeepEqual(oldPodSpec.Volumes, newPodSpec.Volumes) || !equality.Semantic.DeepEqual(oldPodSpec.Containers[0].VolumeMounts, newPodSpec.Containers[0].VolumeMounts) || - oldPodSpec.ServiceAccountName != newPodSpec.ServiceAccountName + oldPodSpec.ServiceAccountName != newPodSpec.ServiceAccountName || + !equality.Semantic.DeepEqual(existingDeployment.Spec.Replicas, deployment.Spec.Replicas) if needsUpdate { logger.Info("Updating Deployment", "name", existingDeployment.Name) + existingDeployment.Spec.Replicas = deployment.Spec.Replicas existingDeployment.Spec.Template.Spec = deployment.Spec.Template.Spec if err := r.Update(ctx, existingDeployment); err != nil { logger.Error(err, "Failed to update Deployment") @@ -262,6 +264,9 @@ func (r *MCPServerReconciler) reconcileDeployment( // createDeployment creates a Deployment for the MCPServer func (r *MCPServerReconciler) createDeployment(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) (*appsv1.Deployment, error) { replicas := int32(1) + if mcpServer.Spec.Replicas != nil { + replicas = *mcpServer.Spec.Replicas + } labels := map[string]string{ "app": "mcp-server", "mcp-server": mcpServer.Name, diff --git a/internal/controller/mcpserver_controller_test.go b/internal/controller/mcpserver_controller_test.go index cebdd393..8f41d1fa 100644 --- a/internal/controller/mcpserver_controller_test.go +++ b/internal/controller/mcpserver_controller_test.go @@ -28,6 +28,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -539,6 +540,194 @@ var _ = Describe("MCPServer Controller", func() { }) }) + Context("When reconciling a resource with replicas", func() { + const resourceName = "test-resource-replicas" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + }) + + It("should set replicas on deployment when specified", func() { + resource := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image:latest", + Port: 8080, + Replicas: ptr.To(int32(3)), + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + + controllerReconciler := &MCPServerReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + deployment := &appsv1.Deployment{} + err = k8sClient.Get(ctx, client.ObjectKey{ + Name: resourceName, + Namespace: "default", + }, deployment) + Expect(err).NotTo(HaveOccurred()) + Expect(*deployment.Spec.Replicas).To(Equal(int32(3))) + }) + + It("should default to 1 replica when not specified", func() { + resource := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image:latest", + Port: 8080, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + + controllerReconciler := &MCPServerReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + deployment := &appsv1.Deployment{} + err = k8sClient.Get(ctx, client.ObjectKey{ + Name: resourceName, + Namespace: "default", + }, deployment) + Expect(err).NotTo(HaveOccurred()) + Expect(*deployment.Spec.Replicas).To(Equal(int32(1))) + }) + + It("should update deployment when replicas changes", func() { + resource := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image:latest", + Port: 8080, + Replicas: ptr.To(int32(2)), + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + + controllerReconciler := &MCPServerReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + By("Reconciling to create the initial deployment with 2 replicas") + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + deployment := &appsv1.Deployment{} + err = k8sClient.Get(ctx, client.ObjectKey{ + Name: resourceName, + Namespace: "default", + }, deployment) + Expect(err).NotTo(HaveOccurred()) + Expect(*deployment.Spec.Replicas).To(Equal(int32(2))) + + By("Updating replicas to 5") + mcpServer := &mcpv1alpha1.MCPServer{} + Expect(k8sClient.Get(ctx, typeNamespacedName, mcpServer)).To(Succeed()) + mcpServer.Spec.Replicas = ptr.To(int32(5)) + Expect(k8sClient.Update(ctx, mcpServer)).To(Succeed()) + + By("Reconciling again to pick up the change") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + err = k8sClient.Get(ctx, client.ObjectKey{ + Name: resourceName, + Namespace: "default", + }, deployment) + Expect(err).NotTo(HaveOccurred()) + Expect(*deployment.Spec.Replicas).To(Equal(int32(5))) + }) + + It("should update deployment when replicas is removed", func() { + resource := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image:latest", + Port: 8080, + Replicas: ptr.To(int32(3)), + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + + controllerReconciler := &MCPServerReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + By("Reconciling to create the initial deployment with 3 replicas") + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + deployment := &appsv1.Deployment{} + err = k8sClient.Get(ctx, client.ObjectKey{ + Name: resourceName, + Namespace: "default", + }, deployment) + Expect(err).NotTo(HaveOccurred()) + Expect(*deployment.Spec.Replicas).To(Equal(int32(3))) + + By("Removing replicas from the MCPServer") + mcpServer := &mcpv1alpha1.MCPServer{} + Expect(k8sClient.Get(ctx, typeNamespacedName, mcpServer)).To(Succeed()) + mcpServer.Spec.Replicas = nil + Expect(k8sClient.Update(ctx, mcpServer)).To(Succeed()) + + By("Reconciling again to pick up the removal") + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + err = k8sClient.Get(ctx, client.ObjectKey{ + Name: resourceName, + Namespace: "default", + }, deployment) + Expect(err).NotTo(HaveOccurred()) + Expect(*deployment.Spec.Replicas).To(Equal(int32(1))) + }) + }) + Context("When reconciling a resource with envFrom", func() { const resourceName = "test-resource-envfrom"