diff --git a/api/v1alpha1/mcpserver_types.go b/api/v1alpha1/mcpserver_types.go index 7ba9c1c..d6f94e4 100644 --- a/api/v1alpha1/mcpserver_types.go +++ b/api/v1alpha1/mcpserver_types.go @@ -195,6 +195,11 @@ type MCPServerDeployment struct { // +optional Image string `json:"image,omitempty"` + // ImagePullPolicy defines the pull policy for the container image. + // +optional + // +kubebuilder:validation:Enum=Always;Never;IfNotPresent + ImagePullPolicy corev1.PullPolicy `json:"imagePullPolicy,omitempty"` + // Port defines the port on which the MCP server will listen. // +optional // +kubebuilder:default=3000 diff --git a/config/crd/bases/kagent.dev_mcpservers.yaml b/config/crd/bases/kagent.dev_mcpservers.yaml index bf27607..4459107 100644 --- a/config/crd/bases/kagent.dev_mcpservers.yaml +++ b/config/crd/bases/kagent.dev_mcpservers.yaml @@ -92,6 +92,14 @@ spec: description: Image defines the container image to to deploy the MCP server. type: string + imagePullPolicy: + description: ImagePullPolicy defines the pull policy for the container + image. + enum: + - Always + - Never + - IfNotPresent + type: string initContainer: description: |- InitContainer defines the configuration for the init container that copies diff --git a/pkg/controller/mcpserver_controller_test.go b/pkg/controller/mcpserver_controller_test.go index 49dd52b..127ccd7 100644 --- a/pkg/controller/mcpserver_controller_test.go +++ b/pkg/controller/mcpserver_controller_test.go @@ -262,6 +262,202 @@ var _ = ginkgo.Describe("MCPServer Controller", func() { gomega.Expect(err).NotTo(gomega.HaveOccurred()) }) }) + + ginkgo.Context("ImagePullPolicy", func() { + ctx := context.Background() + + ginkgo.It("should set ImagePullPolicy to Always when specified for stdio transport", func() { + ginkgo.By("Creating MCPServer with ImagePullPolicy Always for stdio transport") + serverName := "test-stdio-imagepullpolicy" + 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, + ImagePullPolicy: corev1.PullAlways, + }, + }, + } + + 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 ImagePullPolicy set to Always") + deployment := &appsv1.Deployment{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: serverName, + Namespace: "default", + }, deployment) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + container := deployment.Spec.Template.Spec.Containers[0] + gomega.Expect(container.ImagePullPolicy).To(gomega.Equal(corev1.PullAlways)) + + // Cleanup + err = k8sClient.Delete(ctx, server) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + + ginkgo.It("should set ImagePullPolicy to Always when specified for HTTP transport", func() { + ginkgo.By("Creating MCPServer with ImagePullPolicy Always for HTTP transport") + serverName := "test-http-imagepullpolicy" + server := &kagentdevv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: serverName, + Namespace: "default", + }, + Spec: kagentdevv1alpha1.MCPServerSpec{ + TransportType: kagentdevv1alpha1.TransportTypeHTTP, + Deployment: kagentdevv1alpha1.MCPServerDeployment{ + Image: "test-image:latest", + Port: 3000, + ImagePullPolicy: corev1.PullAlways, + }, + HTTPTransport: &kagentdevv1alpha1.HTTPTransport{ + TargetPort: 8080, + }, + }, + } + + 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 ImagePullPolicy set to Always") + deployment := &appsv1.Deployment{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: serverName, + Namespace: "default", + }, deployment) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + container := deployment.Spec.Template.Spec.Containers[0] + gomega.Expect(container.ImagePullPolicy).To(gomega.Equal(corev1.PullAlways)) + + // Cleanup + err = k8sClient.Delete(ctx, server) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + + ginkgo.It("should default ImagePullPolicy to IfNotPresent when not specified for stdio transport", func() { + ginkgo.By("Creating MCPServer without ImagePullPolicy for stdio transport") + serverName := "test-stdio-default-imagepullpolicy" + 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, + }, + }, + } + + 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 ImagePullPolicy defaulted to IfNotPresent") + deployment := &appsv1.Deployment{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: serverName, + Namespace: "default", + }, deployment) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + container := deployment.Spec.Template.Spec.Containers[0] + gomega.Expect(container.ImagePullPolicy).To(gomega.Equal(corev1.PullIfNotPresent)) + + // Cleanup + err = k8sClient.Delete(ctx, server) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + + ginkgo.It("should default ImagePullPolicy to IfNotPresent when not specified for HTTP transport", func() { + ginkgo.By("Creating MCPServer without ImagePullPolicy for HTTP transport") + serverName := "test-http-default-imagepullpolicy" + server := &kagentdevv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: serverName, + Namespace: "default", + }, + Spec: kagentdevv1alpha1.MCPServerSpec{ + TransportType: kagentdevv1alpha1.TransportTypeHTTP, + Deployment: kagentdevv1alpha1.MCPServerDeployment{ + Image: "test-image:latest", + Port: 3000, + }, + HTTPTransport: &kagentdevv1alpha1.HTTPTransport{ + TargetPort: 8080, + }, + }, + } + + 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 ImagePullPolicy defaulted to IfNotPresent") + deployment := &appsv1.Deployment{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: serverName, + Namespace: "default", + }, deployment) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + container := deployment.Spec.Template.Spec.Containers[0] + gomega.Expect(container.ImagePullPolicy).To(gomega.Equal(corev1.PullIfNotPresent)) + + // 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 40b76c9..e16fa88 100644 --- a/pkg/controller/transportadapter/transportadapter_translator.go +++ b/pkg/controller/transportadapter/transportadapter_translator.go @@ -126,6 +126,12 @@ func (t *transportAdapterTranslator) translateTransportAdapterDeployment( } } + // Determine the main container pull policy to use + mainContainerPullPolicy := server.Spec.Deployment.ImagePullPolicy + if mainContainerPullPolicy == "" { + mainContainerPullPolicy = corev1.PullIfNotPresent + } + var template corev1.PodSpec switch server.Spec.TransportType { case v1alpha1.TransportTypeStdio: @@ -149,7 +155,7 @@ func (t *transportAdapterTranslator) translateTransportAdapterDeployment( Containers: []corev1.Container{{ Name: "mcp-server", Image: image, - ImagePullPolicy: corev1.PullIfNotPresent, + ImagePullPolicy: mainContainerPullPolicy, Command: []string{ "/adapterbin/agentgateway", }, @@ -200,7 +206,7 @@ func (t *transportAdapterTranslator) translateTransportAdapterDeployment( { Name: "mcp-server", Image: image, - ImagePullPolicy: corev1.PullIfNotPresent, + ImagePullPolicy: mainContainerPullPolicy, Command: cmd, Args: server.Spec.Deployment.Args, Env: convertEnvVars(server.Spec.Deployment.Env),