diff --git a/README.md b/README.md index 339e474a..9dae097d 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,12 @@ metadata: name: test-server namespace: default spec: - image: aliok/mcp-server-streamable-http:latest - port: 8081 + source: + type: ContainerImage + containerImage: + ref: aliok/mcp-server-streamable-http:latest + config: + port: 8081 EOF ``` @@ -150,8 +154,12 @@ metadata: name: streamable-http-server namespace: default spec: - image: aliok/mcp-server-streamable-http:latest - port: 8081 + source: + type: ContainerImage + containerImage: + ref: aliok/mcp-server-streamable-http:latest + config: + port: 8081 ``` ### Custom MCP Server @@ -163,8 +171,12 @@ metadata: name: custom-server namespace: default spec: - image: my-registry.io/custom-mcp-server:1.0.0 - port: 8000 + source: + type: ContainerImage + containerImage: + ref: my-registry.io/custom-mcp-server:1.0.0 + config: + port: 8000 ``` ## Development diff --git a/api/v1alpha1/mcpserver_types.go b/api/v1alpha1/mcpserver_types.go index 559a8dab..a7ea96df 100644 --- a/api/v1alpha1/mcpserver_types.go +++ b/api/v1alpha1/mcpserver_types.go @@ -27,72 +27,155 @@ import ( // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. -// MCPServerSpec defines the desired state of MCPServer -type MCPServerSpec struct { - // Image is the container image containing the MCP server implementation. +// SourceType defines the type of source for the MCP server. +// +kubebuilder:validation:Enum=ContainerImage +type SourceType string + +const ( + // SourceTypeContainerImage indicates the source is a container image. + SourceTypeContainerImage SourceType = "ContainerImage" +) + +// ContainerImageSource defines a container image source. +type ContainerImageSource struct { + // Ref is the container image containing the MCP server implementation. + // Must be a valid OCI image reference. // Examples: // - ghcr.io/modelcontextprotocol/servers/filesystem:latest // - ghcr.io/modelcontextprotocol/servers/github:v1.0.0 // - custom-registry.io/my-mcp-server:1.2.3 - // +required + // - custom-registry.io/my-mcp-server@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef // +kubebuilder:validation:Required - // +kubebuilder:validation:MinLength=1 - Image string `json:"image"` + // +kubebuilder:validation:MinLength:=1 + // +kubebuilder:validation:MaxLength:=1000 + // +kubebuilder:validation:XValidation:rule="self.matches('^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])((\\\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+)?(:[0-9]+)?\\\\b')",message="must start with a valid domain. valid domains must be alphanumeric characters (lowercase and uppercase) separated by the \".\" character." + // +kubebuilder:validation:XValidation:rule="self.find('(\\\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?((\\\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?)+)?)') != \"\"",message="a valid name is required. valid names must contain lowercase alphanumeric characters separated only by the \".\", \"_\", \"__\", \"-\" characters." + // +kubebuilder:validation:XValidation:rule="self.find('(@.*:)') != \"\" || self.find(':.*$') != \"\"",message="must end with a digest or a tag" + // +kubebuilder:validation:XValidation:rule="self.find('(@.*:)') == \"\" ? (self.find(':.*$') != \"\" ? self.find(':.*$').substring(1).size() <= 127 : true) : true",message="tag is invalid. the tag must not be more than 127 characters" + // +kubebuilder:validation:XValidation:rule="self.find('(@.*:)') == \"\" ? (self.find(':.*$') != \"\" ? self.find(':.*$').matches(':[\\\\w][\\\\w.-]*$') : true) : true",message="tag is invalid. valid tags must begin with a word character (alphanumeric + \"_\") followed by word characters or \".\", and \"-\" characters" + // +kubebuilder:validation:XValidation:rule="self.find('(@.*:)') != \"\" ? self.find('(@.*:)').matches('(@[A-Za-z][A-Za-z0-9]*([-_+.][A-Za-z][A-Za-z0-9]*)*[:])') : true",message="digest algorithm is not valid. valid algorithms must start with an uppercase or lowercase alpha character followed by alphanumeric characters and may contain the \"-\", \"_\", \"+\", and \".\" characters." + // +kubebuilder:validation:XValidation:rule="self.find('(@.*:)') != \"\" ? self.find(':.*$').substring(1).size() >= 32 : true",message="digest is not valid. the encoded string must be at least 32 characters" + // +kubebuilder:validation:XValidation:rule="self.find('(@.*:)') != \"\" ? self.find(':.*$').matches(':[0-9A-Fa-f]*$') : true",message="digest is not valid. the encoded string must only contain hex characters (A-F, a-f, 0-9)" + Ref string `json:"ref,omitempty"` + // NOTE: the validation rules above are taken from + // https://github.com/operator-framework/operator-controller/blob/475e1341d0aa045c4fcb6a93a1ffeb2d16484ca7/api/v1/clustercatalog_types.go#L275-L321 + + // Future fields could include: + // - ImagePullSecrets + // - PullPolicy +} - // Port is the port number on which the MCP server listens for connections. - // Must be between 1 and 65535. - // Should match the port the MCP server container exposes. - // +required +// Source defines where the MCP server's container image (or other source types in the future) is located. +// +kubebuilder:validation:XValidation:rule="self.type == 'ContainerImage' ? has(self.containerImage) : !has(self.containerImage)",message="containerImage must be set when type is ContainerImage and must not be set otherwise" +type Source struct { + // Type is a required field that configures how the MCP server should be sourced. + // Allowed values are: ContainerImage. + // When set to ContainerImage, the MCP server will be sourced directly from an OCI + // container image following the configuration specified in containerImage. // +kubebuilder:validation:Required - // +kubebuilder:validation:Minimum=1 - // +kubebuilder:validation:Maximum=65535 - Port int32 `json:"port"` + Type SourceType `json:"type,omitempty"` - // Args are additional command line arguments for the MCP server container. - // Use this to pass configuration flags to the server. - // Example: ["--config", "/etc/mcp-config/config.toml", "--verbose"] + // ContainerImage specifies container image details when Type is ContainerImage. // +optional - Args []string `json:"args,omitempty"` + ContainerImage *ContainerImageSource `json:"containerImage,omitempty"` +} - // ConfigMapRef references a ConfigMap containing configuration file(s). - // The ConfigMap will be mounted as a read-only volume. - // Use ConfigMapMountPath to specify where to mount it (defaults to /etc/mcp-config). - // Use ConfigMapVolumeName to specify the volume name (defaults to mcp-config). - // Use the Args field to point the server to the config file. - // Example: - // configMapRef: - // name: my-server-config - // configMapMountPath: /etc/mcp-config - // configMapVolumeName: mcp-config - // args: - // - --config - // - /etc/mcp-config/config.toml - // +optional - ConfigMapRef *corev1.LocalObjectReference `json:"configMapRef,omitempty"` +// StorageType defines the type of storage mount. +// +kubebuilder:validation:Enum=ConfigMap;Secret +type StorageType string - // ConfigMapMountPath specifies the path where the ConfigMap should be mounted. - // Only used when ConfigMapRef is set. Defaults to /etc/mcp-config if not specified. +const ( + // StorageTypeConfigMap indicates a ConfigMap volume source. + StorageTypeConfigMap StorageType = "ConfigMap" + // StorageTypeSecret indicates a Secret volume source. + StorageTypeSecret StorageType = "Secret" +) + +// MountPermissions defines the access permissions for a volume mount. +// +kubebuilder:validation:Enum=ReadOnly;ReadWrite;RecursiveReadOnly +type MountPermissions string + +const ( + // MountPermissionsReadOnly indicates the mount is read-only. + MountPermissionsReadOnly MountPermissions = "ReadOnly" + // MountPermissionsReadWrite indicates the mount is read-write. + MountPermissionsReadWrite MountPermissions = "ReadWrite" + // MountPermissionsRecursiveReadOnly indicates the mount and all its submounts are recursively read-only. + // This provides stronger guarantees than ReadOnly alone. + MountPermissionsRecursiveReadOnly MountPermissions = "RecursiveReadOnly" +) + +// StorageSource defines the source of the storage to mount (ConfigMap or Secret). +// +kubebuilder:validation:XValidation:rule="self.type == 'ConfigMap' ? has(self.configMap) : !has(self.configMap)",message="configMap must be set when type is ConfigMap and must not be set otherwise" +// +kubebuilder:validation:XValidation:rule="self.type == 'Secret' ? has(self.secret) : !has(self.secret)",message="secret must be set when type is Secret and must not be set otherwise" +type StorageSource struct { + // Type is a required field that specifies the type of volume source. + // Allowed values are: ConfigMap, Secret. + // This determines which volume source field (configMap or secret) should be configured. + // +kubebuilder:validation:Required + Type StorageType `json:"type,omitempty"` + + // ConfigMap specifies a ConfigMap volume source (when Type is ConfigMap). + // Uses native Kubernetes ConfigMapVolumeSource type for full feature parity. // +optional - ConfigMapMountPath string `json:"configMapMountPath,omitempty"` + ConfigMap *corev1.ConfigMapVolumeSource `json:"configMap,omitempty"` - // ConfigMapVolumeName specifies the name of the volume for the ConfigMap mount. - // Only used when ConfigMapRef is set. Defaults to mcp-config if not specified. + // Secret specifies a Secret volume source (when Type is Secret). + // Uses native Kubernetes SecretVolumeSource type for full feature parity. // +optional - ConfigMapVolumeName string `json:"configMapVolumeName,omitempty"` + Secret *corev1.SecretVolumeSource `json:"secret,omitempty"` +} - // Replicas is the number of MCP server pod replicas to run. - // Defaults to 1 if not specified. +// StorageMount defines a storage mount combining volume source and mount configuration. +// The Path and Permissions fields apply to all storage types, while Source contains +// the type-specific configuration (ConfigMap or Secret). +type StorageMount struct { + // Path is a required field that specifies where the volume should be mounted in the container. + // Must be an absolute path (starting with /). + // The ConfigMap or Secret data will be accessible to the MCP server process at this location. + // Must be between 1 and 4096 characters, start with '/', and must not contain ':'. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=4096 + // +kubebuilder:validation:XValidation:rule="self.startsWith('/')",message="path must be an absolute path (must start with '/')" + // +kubebuilder:validation:XValidation:rule="!self.contains(':')",message="path must not contain ':' character" + Path string `json:"path,omitempty"` + + // Permissions specifies the access permissions for the mount. + // Allowed values are ReadOnly, ReadWrite, and RecursiveReadOnly. + // When set to ReadOnly, the mount is read-only. + // When set to ReadWrite, the mount is read-write. + // When set to RecursiveReadOnly, the mount and all submounts are recursively read-only. + // Defaults to ReadOnly for ConfigMap and Secret mounts. // +optional + // +kubebuilder:default=ReadOnly + Permissions MountPermissions `json:"permissions,omitempty"` + + // Source defines where the storage data comes from (ConfigMap or Secret). + // +kubebuilder:validation:Required + Source StorageSource `json:"source,omitzero"` +} + +// ServerConfig defines how the MCP server should be configured when it runs. +type ServerConfig struct { + // Port is a required field that specifies the port number on which the MCP server listens for connections. + // Must be between 1 and 65535. + // This should match the port that the MCP server container exposes and will be used for + // configuring the Kubernetes Service. + // +kubebuilder:validation:Required // +kubebuilder:validation:Minimum=1 - Replicas *int32 `json:"replicas,omitempty"` + // +kubebuilder:validation:Maximum=65535 + Port int32 `json:"port,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. - // Example: For kubernetes-mcp-server with read-only access, create a ServiceAccount - // and bind it to the 'view' ClusterRole. + // Arguments are command line arguments for the MCP server container. + // Use this to pass configuration flags to the server. + // Example: ["--config", "/etc/mcp-config/config.toml", "--verbose"] + // When not specified, the container image's default arguments (CMD/ENTRYPOINT) are used. + // An empty array [] is allowed and will override the container image's default arguments with no arguments. + // Empty strings within the array are not allowed. // +optional - ServiceAccountName string `json:"serviceAccountName,omitempty"` + // +kubebuilder:validation:XValidation:rule="self.all(arg, arg.size() > 0)",message="arguments must not contain empty strings" + Arguments []string `json:"arguments,omitempty"` // Env is a list of environment variables to set in the MCP server container. // Supports the full Kubernetes EnvVar API including valueFrom for secrets and configmaps. @@ -106,6 +189,49 @@ type MCPServerSpec struct { // +optional EnvFrom []corev1.EnvFromSource `json:"envFrom,omitempty"` + // Storage defines storage mounts for ConfigMaps and Secrets. + // Each item uses native Kubernetes volume source types for consistency and feature parity. + // If specified, must contain at least 1 item. Maximum 64 items. + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=64 + Storage []StorageMount `json:"storage,omitempty"` + + // Path is the HTTP path where the MCP server listens for SSE/Streamable HTTP connections. + // This path is appended to the service address in the status URL. + // Must be a valid URI path component starting with '/'. + // Maximum 253 characters. Cannot contain spaces, control characters, or query/fragment separators (? #). + // Examples: /mcp, /api/v1/mcp, /services/mcp-server + // Defaults to /mcp if not specified. + // +optional + // +kubebuilder:default="/mcp" + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:XValidation:rule="self.startsWith('/')",message="path must start with '/'" + // +kubebuilder:validation:XValidation:rule="!self.contains(' ')",message="path must not contain spaces" + // +kubebuilder:validation:XValidation:rule="!self.contains('?')",message="path must not contain query string separator '?'" + // +kubebuilder:validation:XValidation:rule="!self.contains('#')",message="path must not contain fragment separator '#'" + // +kubebuilder:validation:XValidation:rule="!self.contains('\\n') && !self.contains('\\r') && !self.contains('\\t')",message="path must not contain control characters (newlines, tabs)" + Path string `json:"path,omitempty"` +} + +// SecurityConfig defines security-related configuration. +// If not specified, default security settings will be applied. +// See individual field documentation for specific defaults. +type SecurityConfig struct { + // 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. + // Must be a string that follows the DNS1123 subdomain format. + // Must be at most 253 characters in length, and must consist only of lower case alphanumeric characters, '-' + // and '.', and must start and end with an alphanumeric character. + // Example: For kubernetes-mcp-server with read-only access, create a ServiceAccount + // and bind it to the 'view' ClusterRole. + // +optional + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:XValidation:rule="self == '' || !format.dns1123Subdomain().validate(self).hasValue()",message="serviceAccountName must be a valid DNS subdomain name: a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character." + ServiceAccountName string `json:"serviceAccountName,omitempty"` + // PodSecurityContext specifies the security context for the MCP server pod. // +optional PodSecurityContext *corev1.PodSecurityContext `json:"podSecurityContext,omitempty"` @@ -113,36 +239,43 @@ type MCPServerSpec struct { // SecurityContext specifies the security context for the MCP server container. // +optional SecurityContext *corev1.SecurityContext `json:"securityContext,omitempty"` +} - // SecretRef references a Secret containing credentials - // The Secret will be mounted as a read-only volume. - // Use SecretMountPath to specify where to mount it. - // Use SecretVolumeName to specify the volume name. - // Use SecretKey to specify the key of the Secret to mount. +// RuntimeConfig defines runtime management configuration for the MCP server. +// If not specified, default runtime settings will be applied. +// See individual field documentation for specific defaults. +type RuntimeConfig struct { + // Replicas is the number of MCP server pod replicas to run. + // Defaults to 1 if not specified. + // Set to 0 to scale down the deployment. // +optional - SecretRef *corev1.LocalObjectReference `json:"secretRef,omitempty"` + // +kubebuilder:validation:Minimum=0 + Replicas *int32 `json:"replicas,omitempty"` - // SecretMountPath specifies the path where the Secret should be mounted. - // Only used when SecretRef is set. Defaults to /etc/mcp-secrets if not specified. + // Security defines security-related configuration. + // If not specified, default security settings will be applied. // +optional - SecretMountPath string `json:"secretMountPath,omitempty"` + Security SecurityConfig `json:"security,omitzero"` +} - // SecretKey specifies the key of the Secret to mount. - // Only used when SecretRef is set. - // +optional - SecretKey string `json:"secretKey,omitempty"` +// MCPServerSpec defines the desired state of MCPServer. +type MCPServerSpec struct { + // Source is a required field that defines where the MCP server should be sourced from. + // Currently supports container images, with potential for additional source types in the future. + // This configuration determines how the MCP server will be deployed and run. + // +kubebuilder:validation:Required + Source Source `json:"source,omitzero"` - // SecretVolumeName specifies the name of the volume for the Secret mount. - // Only used when SecretRef is set. Defaults to mcp-secrets if not specified. - // +optional - SecretVolumeName string `json:"secretVolumeName,omitempty"` + // Config is a required field that defines how the MCP server should be configured when it runs. + // This includes runtime settings such as the server port, command-line arguments, + // environment variables, and storage mounts. + // +kubebuilder:validation:Required + Config ServerConfig `json:"config,omitzero"` - // Path is the HTTP path where the MCP server listens for SSE/Streamable HTTP connections. - // This path is appended to the service address in the status URL. - // Defaults to /mcp if not specified. + // Runtime defines runtime management configuration. + // If not specified, default runtime settings will be applied. // +optional - // +kubebuilder:default="/mcp" - Path string `json:"path,omitempty"` + Runtime RuntimeConfig `json:"runtime,omitzero"` } // MCPServerAddress contains the address information for the MCPServer. @@ -175,10 +308,10 @@ type MCPServerStatus struct { // Conditions represent the current state of the MCPServer resource. // Each condition has a unique type and reflects the status of a specific aspect of the resource. // - // Standard condition types include: - // - "Ready": the resource is fully functional and available - // - "Progressing": the resource is being created or updated - // - "Degraded": the resource failed to reach or maintain its desired state + // Standard condition types include "Ready", "Progressing", and "Degraded". + // The "Ready" condition indicates the resource is fully functional and available. + // The "Progressing" condition indicates the resource is being created or updated. + // The "Degraded" condition indicates the resource failed to reach or maintain its desired state. // // The status of each condition is one of True, False, or Unknown. // +listType=map @@ -190,8 +323,8 @@ type MCPServerStatus struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` -// +kubebuilder:printcolumn:name="Image",type=string,JSONPath=`.spec.image` -// +kubebuilder:printcolumn:name="Port",type=integer,JSONPath=`.spec.port` +// +kubebuilder:printcolumn:name="Image",type=string,JSONPath=`.spec.source.containerImage.ref` +// +kubebuilder:printcolumn:name="Port",type=integer,JSONPath=`.spec.config.port` // +kubebuilder:printcolumn:name="Address",type=string,JSONPath=`.status.address.url` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` diff --git a/api/v1alpha1/mcpserver_validation_test.go b/api/v1alpha1/mcpserver_validation_test.go new file mode 100644 index 00000000..41e4c364 --- /dev/null +++ b/api/v1alpha1/mcpserver_validation_test.go @@ -0,0 +1,1625 @@ +/* +Copyright 2026 The Kubernetes 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 v1alpha1 + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +var ( + ctx context.Context + cancel context.CancelFunc + testEnv *envtest.Environment + cfg *rest.Config + k8sClient client.Client +) + +func TestValidation(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "MCPServer Validation Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + var err error + err = AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + // Retrieve the first found binary directory to allow running tests from IDEs + if binaryDir := getFirstFoundEnvTestBinaryDir(); binaryDir != "" { + testEnv.BinaryAssetsDirectory = binaryDir + } + + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + cancel() + Eventually(func() error { + return testEnv.Stop() + }, time.Minute, time.Second).Should(Succeed()) +}) + +func getFirstFoundEnvTestBinaryDir() string { + basePath := filepath.Join("..", "..", "bin", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + // Directory not existing is normal in many test environments, don't log it as an error + if !os.IsNotExist(err) { + logf.Log.Error(err, "Failed to read directory", "path", basePath) + } + return "" + } + for _, entry := range entries { + if entry.IsDir() { + return filepath.Join(basePath, entry.Name()) + } + } + return "" +} + +var _ = Describe("MCPServer Validation", func() { + var namespace *corev1.Namespace + + BeforeEach(func() { + // Create a unique namespace for each test + namespace = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-validation-", + }, + } + Expect(k8sClient.Create(ctx, namespace)).To(Succeed()) + }) + + AfterEach(func() { + // Clean up namespace + if namespace != nil { + Expect(k8sClient.Delete(ctx, namespace)).To(Succeed()) + } + }) + + Context("Source validation", func() { + It("should accept valid Source with type=ContainerImage and containerImage set", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-source", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should reject Source with type=ContainerImage but no containerImage", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-source-missing-image", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: nil, // Missing containerImage + }, + Config: ServerConfig{ + Port: 8080, + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("containerImage must be set when type is ContainerImage")) + }) + }) + + Context("StorageMount validation", func() { + It("should accept valid StorageMount with type=ConfigMap and configMap set", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-configmap-storage", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Storage: []StorageMount{ + { + Path: "/etc/config", + Source: StorageSource{ + Type: StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-config", + }, + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should accept valid StorageMount with type=Secret and secret set", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-secret-storage", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Storage: []StorageMount{ + { + Path: "/etc/secret", + Source: StorageSource{ + Type: StorageTypeSecret, + Secret: &corev1.SecretVolumeSource{ + SecretName: "test-secret", + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should reject StorageMount with both configMap and secret set", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-both-sources", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Storage: []StorageMount{ + { + Path: "/etc/config", + Source: StorageSource{ + Type: StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-config", + }, + }, + Secret: &corev1.SecretVolumeSource{ + SecretName: "test-secret", + }, + }, + }, + }, + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + // CEL validation ensures only the field matching the type is set + Expect(err.Error()).To(ContainSubstring("secret must be set when type is Secret and must not be set otherwise")) + }) + + It("should reject StorageMount with type=ConfigMap but secret set instead", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-type-mismatch-configmap", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Storage: []StorageMount{ + { + Path: "/etc/config", + Source: StorageSource{ + Type: StorageTypeConfigMap, // Type says ConfigMap + Secret: &corev1.SecretVolumeSource{ // But Secret is set + SecretName: "test-secret", + }, + }, + }, + }, + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("configMap must be set when type is ConfigMap")) + }) + + It("should reject StorageMount with type=Secret but configMap set instead", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-type-mismatch-secret", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Storage: []StorageMount{ + { + Path: "/etc/secret", + Source: StorageSource{ + Type: StorageTypeSecret, // Type says Secret + ConfigMap: &corev1.ConfigMapVolumeSource{ // But ConfigMap is set + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-config", + }, + }, + }, + }, + }, + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("secret must be set when type is Secret")) + }) + + It("should reject StorageMount with type=ConfigMap but neither field set", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-no-source", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Storage: []StorageMount{ + { + Path: "/etc/config", + Source: StorageSource{ + Type: StorageTypeConfigMap, + ConfigMap: nil, + Secret: nil, + }, + }, + }, + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("configMap must be set when type is ConfigMap")) + }) + + It("should reject StorageMount with relative path", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-relative-path", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Storage: []StorageMount{ + { + Path: "etc/config", // Invalid: relative path + Source: StorageSource{ + Type: StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-config", + }, + }, + }, + }, + }, + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(Or( + ContainSubstring("path"), + ContainSubstring("pattern"), + )) + }) + + It("should accept StorageMount with valid absolute path", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-absolute-path", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Storage: []StorageMount{ + { + Path: "/etc/config", // Valid: absolute path + Source: StorageSource{ + Type: StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-config", + }, + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should accept StorageMount with permissions=ReadOnly", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-permissions-readonly", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Storage: []StorageMount{ + { + Path: "/etc/config", + Permissions: MountPermissionsReadOnly, + Source: StorageSource{ + Type: StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-config", + }, + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should accept StorageMount with permissions=ReadWrite", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-permissions-readwrite", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Storage: []StorageMount{ + { + Path: "/etc/config", + Permissions: MountPermissionsReadWrite, + Source: StorageSource{ + Type: StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-config", + }, + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should accept StorageMount with permissions=RecursiveReadOnly", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-permissions-recursivereadonly", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Storage: []StorageMount{ + { + Path: "/etc/config", + Permissions: MountPermissionsRecursiveReadOnly, + Source: StorageSource{ + Type: StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-config", + }, + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should use default permissions (ReadOnly) when not specified", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-permissions-default", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Storage: []StorageMount{ + { + Path: "/etc/config", + // Permissions not specified - should default to ReadOnly + Source: StorageSource{ + Type: StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-config", + }, + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should reject invalid permissions value", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-permissions", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Storage: []StorageMount{ + { + Path: "/etc/config", + Permissions: "InvalidValue", // Invalid enum value + Source: StorageSource{ + Type: StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-config", + }, + }, + }, + }, + }, + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("permissions")) + }) + + It("should accept path with special characters", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-path-special-chars", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Storage: []StorageMount{ + { + Path: "/etc/config-123_test.dir/sub-dir", // Valid: common special chars + Source: StorageSource{ + Type: StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-config", + }, + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should reject path not starting with /", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-path-not-absolute", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Storage: []StorageMount{ + { + Path: "etc/config", // Invalid: not absolute path + Source: StorageSource{ + Type: StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-config", + }, + }, + }, + }, + }, + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("path")) + }) + + It("should reject path that is too long", func() { + longPath := "/" + string(make([]byte, 4096)) // 4097 characters total + for i := range longPath[1:] { + longPath = longPath[:i+1] + "a" + longPath[i+2:] + } + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-path-too-long", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Storage: []StorageMount{ + { + Path: longPath, // Invalid: >4096 characters + Source: StorageSource{ + Type: StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-config", + }, + }, + }, + }, + }, + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("path")) + }) + + It("should reject path containing colon", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-path-colon", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Storage: []StorageMount{ + { + Path: "/etc/config:data", // Invalid: contains colon + Source: StorageSource{ + Type: StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-config", + }, + }, + }, + }, + }, + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("path")) + }) + }) + + Context("ServerConfig validation", func() { + It("should reject Port below minimum (0)", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-port-low", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 0, // Invalid: below minimum of 1 + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("port")) + }) + + It("should reject Port above maximum (65536)", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-port-high", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 65536, // Invalid: above maximum of 65535 + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("port")) + }) + + It("should accept valid Port range (1-65535)", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-port", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + }) + + Context("ContainerImageSource validation", func() { + It("should reject empty Ref", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-empty-ref", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "", // Invalid: empty string + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(Or( + ContainSubstring("ref"), + ContainSubstring("minLength"), + )) + }) + }) + + Context("Multiple storage mounts", func() { + It("should accept multiple valid storage mounts", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "multiple-storage", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Storage: []StorageMount{ + { + Path: "/etc/config", + Source: StorageSource{ + Type: StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-config", + }, + }, + }, + }, + { + Path: "/etc/secret", + Source: StorageSource{ + Type: StorageTypeSecret, + Secret: &corev1.SecretVolumeSource{ + SecretName: "test-secret", + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + }) + + Context("RuntimeConfig validation", func() { + It("should accept empty RuntimeConfig (runtime: {})", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-empty-runtime", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + Runtime: RuntimeConfig{}, // Valid: empty struct with zero values + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should accept RuntimeConfig with replicas set", func() { + replicas := int32(2) + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-runtime-replicas", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + Runtime: RuntimeConfig{ + Replicas: &replicas, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should accept RuntimeConfig with replicas set to 0 for scale-to-zero", func() { + replicas := int32(0) + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-runtime-replicas-zero", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + Runtime: RuntimeConfig{ + Replicas: &replicas, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should accept RuntimeConfig with security set", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-runtime-security", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + Runtime: RuntimeConfig{ + Security: SecurityConfig{ + ServiceAccountName: "custom-sa", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should accept MCPServer without runtime field (omitted)", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-no-runtime", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + // Omitted - should use defaults + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + }) + + Context("SecurityConfig validation", func() { + It("should accept empty SecurityConfig (security: {})", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-empty-security", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + Runtime: RuntimeConfig{ + Security: SecurityConfig{}, // Valid: empty struct with zero values + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should accept SecurityConfig with serviceAccountName set", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-security-sa", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + Runtime: RuntimeConfig{ + Security: SecurityConfig{ + ServiceAccountName: "custom-sa", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should accept SecurityConfig with podSecurityContext set", func() { + runAsNonRoot := true + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-security-pod-ctx", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + Runtime: RuntimeConfig{ + Security: SecurityConfig{ + PodSecurityContext: &corev1.PodSecurityContext{ + RunAsNonRoot: &runAsNonRoot, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should accept SecurityConfig with securityContext set", func() { + allowPrivilegeEscalation := false + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-security-container-ctx", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + Runtime: RuntimeConfig{ + Security: SecurityConfig{ + SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: &allowPrivilegeEscalation, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should accept MCPServer without security field (omitted)", func() { + replicas := int32(1) + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-no-security", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + Runtime: RuntimeConfig{ + Replicas: &replicas, + // Omitted - should use defaults + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should accept valid DNS subdomain ServiceAccountName", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-sa-dns-subdomain", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + Runtime: RuntimeConfig{ + Security: SecurityConfig{ + ServiceAccountName: "my-service-account", // Valid DNS subdomain + }, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should accept ServiceAccountName with dots", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-sa-with-dots", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + Runtime: RuntimeConfig{ + Security: SecurityConfig{ + ServiceAccountName: "my.service.account", // Valid with dots + }, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should reject ServiceAccountName with uppercase letters", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-sa-uppercase", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + Runtime: RuntimeConfig{ + Security: SecurityConfig{ + ServiceAccountName: "MyServiceAccount", // Invalid: uppercase + }, + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("serviceAccountName")) + }) + + It("should reject ServiceAccountName with invalid characters", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-sa-chars", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + Runtime: RuntimeConfig{ + Security: SecurityConfig{ + ServiceAccountName: "my_service_account", // Invalid: underscore not allowed + }, + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("serviceAccountName")) + }) + + It("should reject ServiceAccountName starting with hyphen", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-sa-start-hyphen", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + Runtime: RuntimeConfig{ + Security: SecurityConfig{ + ServiceAccountName: "-invalid-start", // Invalid: starts with hyphen + }, + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("serviceAccountName")) + }) + + It("should reject ServiceAccountName ending with hyphen", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-sa-end-hyphen", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + Runtime: RuntimeConfig{ + Security: SecurityConfig{ + ServiceAccountName: "invalid-end-", // Invalid: ends with hyphen + }, + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("serviceAccountName")) + }) + }) + + Context("MCPServerSpec.Path validation", func() { + It("should accept valid HTTP path", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-path", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Path: "/api/v1/mcp", + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should accept path with hyphens and underscores", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-path-chars", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Path: "/mcp-server_v1", + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should use default path /mcp when not specified", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default-path", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + }, + // Path not specified - should default to /mcp + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should reject path not starting with /", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-path-no-slash", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Path: "relative/path", + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("must start with '/'")) + }) + + It("should reject path containing spaces", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-path-space", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Path: "/mcp server/path", + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("must not contain spaces")) + }) + + It("should reject path containing query string separator", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-path-query", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Path: "/mcp?query=param", + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("must not contain query string separator")) + }) + + It("should reject path containing fragment separator", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-path-fragment", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Path: "/mcp#fragment", + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("must not contain fragment separator")) + }) + + It("should reject path that is too long", func() { + longPath := "/" + strings.Repeat("a", 253) // 254 chars total + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-path-toolong", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Path: longPath, + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(Or( + ContainSubstring("Too long"), + ContainSubstring("max length"), + )) + }) + + It("should accept valid arguments array", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "valid-arguments", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Arguments: []string{"--config", "/etc/config.toml", "--verbose"}, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should accept empty arguments array", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "empty-arguments", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Arguments: []string{}, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + }) + + It("should reject arguments array containing empty strings", func() { + mcpServer := &MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-empty-string-arg", + Namespace: namespace.Name, + }, + Spec: MCPServerSpec{ + Source: Source{ + Type: SourceTypeContainerImage, + ContainerImage: &ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: ServerConfig{ + Port: 8080, + Arguments: []string{"--config", "", "--verbose"}, + }, + }, + } + err := k8sClient.Create(ctx, mcpServer) + Expect(err).To(HaveOccurred()) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("arguments must not contain empty strings")) + }) + }) +}) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 08cddffe..8caa9ceb 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -26,6 +26,21 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ContainerImageSource) DeepCopyInto(out *ContainerImageSource) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ContainerImageSource. +func (in *ContainerImageSource) DeepCopy() *ContainerImageSource { + if in == nil { + return nil + } + out := new(ContainerImageSource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPServer) DeepCopyInto(out *MCPServer) { *out = *in @@ -103,21 +118,102 @@ func (in *MCPServerList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPServerSpec) DeepCopyInto(out *MCPServerSpec) { *out = *in - if in.Args != nil { - in, out := &in.Args, &out.Args - *out = make([]string, len(*in)) - copy(*out, *in) + in.Source.DeepCopyInto(&out.Source) + in.Config.DeepCopyInto(&out.Config) + in.Runtime.DeepCopyInto(&out.Runtime) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerSpec. +func (in *MCPServerSpec) DeepCopy() *MCPServerSpec { + if in == nil { + return nil } - if in.ConfigMapRef != nil { - in, out := &in.ConfigMapRef, &out.ConfigMapRef - *out = new(v1.LocalObjectReference) + out := new(MCPServerSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPServerStatus) DeepCopyInto(out *MCPServerStatus) { + *out = *in + if in.Address != nil { + in, out := &in.Address, &out.Address + *out = new(MCPServerAddress) **out = **in } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerStatus. +func (in *MCPServerStatus) DeepCopy() *MCPServerStatus { + if in == nil { + return nil + } + out := new(MCPServerStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RuntimeConfig) DeepCopyInto(out *RuntimeConfig) { + *out = *in if in.Replicas != nil { in, out := &in.Replicas, &out.Replicas *out = new(int32) **out = **in } + in.Security.DeepCopyInto(&out.Security) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RuntimeConfig. +func (in *RuntimeConfig) DeepCopy() *RuntimeConfig { + if in == nil { + return nil + } + out := new(RuntimeConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecurityConfig) DeepCopyInto(out *SecurityConfig) { + *out = *in + if in.PodSecurityContext != nil { + in, out := &in.PodSecurityContext, &out.PodSecurityContext + *out = new(v1.PodSecurityContext) + (*in).DeepCopyInto(*out) + } + if in.SecurityContext != nil { + in, out := &in.SecurityContext, &out.SecurityContext + *out = new(v1.SecurityContext) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecurityConfig. +func (in *SecurityConfig) DeepCopy() *SecurityConfig { + if in == nil { + return nil + } + out := new(SecurityConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServerConfig) DeepCopyInto(out *ServerConfig) { + *out = *in + if in.Arguments != nil { + in, out := &in.Arguments, &out.Arguments + *out = make([]string, len(*in)) + copy(*out, *in) + } if in.Env != nil { in, out := &in.Env, &out.Env *out = make([]v1.EnvVar, len(*in)) @@ -132,56 +228,82 @@ func (in *MCPServerSpec) DeepCopyInto(out *MCPServerSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.PodSecurityContext != nil { - in, out := &in.PodSecurityContext, &out.PodSecurityContext - *out = new(v1.PodSecurityContext) - (*in).DeepCopyInto(*out) + if in.Storage != nil { + in, out := &in.Storage, &out.Storage + *out = make([]StorageMount, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } - if in.SecurityContext != nil { - in, out := &in.SecurityContext, &out.SecurityContext - *out = new(v1.SecurityContext) - (*in).DeepCopyInto(*out) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServerConfig. +func (in *ServerConfig) DeepCopy() *ServerConfig { + if in == nil { + return nil } - if in.SecretRef != nil { - in, out := &in.SecretRef, &out.SecretRef - *out = new(v1.LocalObjectReference) + out := new(ServerConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Source) DeepCopyInto(out *Source) { + *out = *in + if in.ContainerImage != nil { + in, out := &in.ContainerImage, &out.ContainerImage + *out = new(ContainerImageSource) **out = **in } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerSpec. -func (in *MCPServerSpec) DeepCopy() *MCPServerSpec { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Source. +func (in *Source) DeepCopy() *Source { if in == nil { return nil } - out := new(MCPServerSpec) + out := new(Source) in.DeepCopyInto(out) return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPServerStatus) DeepCopyInto(out *MCPServerStatus) { +func (in *StorageMount) DeepCopyInto(out *StorageMount) { *out = *in - if in.Address != nil { - in, out := &in.Address, &out.Address - *out = new(MCPServerAddress) - **out = **in + in.Source.DeepCopyInto(&out.Source) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StorageMount. +func (in *StorageMount) DeepCopy() *StorageMount { + if in == nil { + return nil } - if in.Conditions != nil { - in, out := &in.Conditions, &out.Conditions - *out = make([]metav1.Condition, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } + out := new(StorageMount) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StorageSource) DeepCopyInto(out *StorageSource) { + *out = *in + if in.ConfigMap != nil { + in, out := &in.ConfigMap, &out.ConfigMap + *out = new(v1.ConfigMapVolumeSource) + (*in).DeepCopyInto(*out) + } + if in.Secret != nil { + in, out := &in.Secret, &out.Secret + *out = new(v1.SecretVolumeSource) + (*in).DeepCopyInto(*out) } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerStatus. -func (in *MCPServerStatus) DeepCopy() *MCPServerStatus { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StorageSource. +func (in *StorageSource) DeepCopy() *StorageSource { if in == nil { return nil } - out := new(MCPServerStatus) + out := new(StorageSource) in.DeepCopyInto(out) return out } diff --git a/config/crd/bases/mcp.x-k8s.io_mcpservers.yaml b/config/crd/bases/mcp.x-k8s.io_mcpservers.yaml index 0b7b4ca4..1dd364b2 100644 --- a/config/crd/bases/mcp.x-k8s.io_mcpservers.yaml +++ b/config/crd/bases/mcp.x-k8s.io_mcpservers.yaml @@ -18,10 +18,10 @@ spec: - jsonPath: .status.phase name: Phase type: string - - jsonPath: .spec.image + - jsonPath: .spec.source.containerImage.ref name: Image type: string - - jsonPath: .spec.port + - jsonPath: .spec.config.port name: Port type: integer - jsonPath: .status.address.url @@ -55,86 +55,199 @@ spec: spec: description: spec defines the desired state of MCPServer properties: - args: + config: description: |- - Args are additional command line arguments for the MCP server container. - Use this to pass configuration flags to the server. - Example: ["--config", "/etc/mcp-config/config.toml", "--verbose"] - items: - type: string - type: array - configMapMountPath: - description: |- - ConfigMapMountPath specifies the path where the ConfigMap should be mounted. - Only used when ConfigMapRef is set. Defaults to /etc/mcp-config if not specified. - type: string - configMapRef: - description: |- - ConfigMapRef references a ConfigMap containing configuration file(s). - The ConfigMap will be mounted as a read-only volume. - Use ConfigMapMountPath to specify where to mount it (defaults to /etc/mcp-config). - Use ConfigMapVolumeName to specify the volume name (defaults to mcp-config). - Use the Args field to point the server to the config file. - Example: - configMapRef: - name: my-server-config - configMapMountPath: /etc/mcp-config - configMapVolumeName: mcp-config - args: - - --config - - /etc/mcp-config/config.toml + Config is a required field that defines how the MCP server should be configured when it runs. + This includes runtime settings such as the server port, command-line arguments, + environment variables, and storage mounts. properties: - name: - default: "" + arguments: description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - type: object - x-kubernetes-map-type: atomic - configMapVolumeName: - description: |- - ConfigMapVolumeName specifies the name of the volume for the ConfigMap mount. - Only used when ConfigMapRef is set. Defaults to mcp-config if not specified. - type: string - env: - description: |- - Env is a list of environment variables to set in the MCP server container. - Supports the full Kubernetes EnvVar API including valueFrom for secrets and configmaps. - items: - description: EnvVar represents an environment variable present in - a Container. - properties: - name: - description: |- - Name of the environment variable. - May consist of any printable ASCII characters except '='. - type: string - value: - description: |- - Variable references $(VAR_NAME) are expanded - using the previously defined environment variables in the container and - any service environment variables. If a variable cannot be resolved, - the reference in the input string will be unchanged. Double $$ are reduced - to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. - "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". - Escaped references will never be expanded, regardless of whether the variable - exists or not. - Defaults to "". + Arguments are command line arguments for the MCP server container. + Use this to pass configuration flags to the server. + Example: ["--config", "/etc/mcp-config/config.toml", "--verbose"] + When not specified, the container image's default arguments (CMD/ENTRYPOINT) are used. + An empty array [] is allowed and will override the container image's default arguments with no arguments. + Empty strings within the array are not allowed. + items: type: string - valueFrom: - description: Source for the environment variable's value. Cannot - be used if value is not empty. + type: array + x-kubernetes-validations: + - message: arguments must not contain empty strings + rule: self.all(arg, arg.size() > 0) + env: + description: |- + Env is a list of environment variables to set in the MCP server container. + Supports the full Kubernetes EnvVar API including valueFrom for secrets and configmaps. + items: + description: EnvVar represents an environment variable present + in a Container. properties: - configMapKeyRef: - description: Selects a key of a ConfigMap. + name: + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's value. + Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in the + specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount containing + the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of the + exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + envFrom: + description: |- + EnvFrom is a list of sources to populate environment variables in the MCP server container. + Each entry injects all key-value pairs from a Secret or ConfigMap as environment variables. + The keys become the variable names. Useful when a Secret's keys already match + the expected env var names (e.g., GITHUB_TOKEN). + items: + description: EnvFromSource represents the source of a set of + ConfigMaps or Secrets + properties: + configMapRef: + description: The ConfigMap to select from properties: - key: - description: The key to select. - type: string name: default: "" description: |- @@ -145,98 +258,18 @@ spec: More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names type: string optional: - description: Specify whether the ConfigMap or its key - must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - fieldRef: - description: |- - Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, - spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. - properties: - apiVersion: - description: Version of the schema the FieldPath is - written in terms of, defaults to "v1". - type: string - fieldPath: - description: Path of the field to select in the specified - API version. - type: string - required: - - fieldPath - type: object - x-kubernetes-map-type: atomic - fileKeyRef: - description: |- - FileKeyRef selects a key of the env file. - Requires the EnvFiles feature gate to be enabled. - properties: - key: - description: |- - The key within the env file. An invalid key will prevent the pod from starting. - The keys defined within a source may consist of any printable ASCII characters except '='. - During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. - type: string - optional: - default: false - description: |- - Specify whether the file or its key must be defined. If the file or key - does not exist, then the env var is not published. - If optional is set to true and the specified key does not exist, - the environment variable will not be set in the Pod's containers. - - If optional is set to false and the specified key does not exist, - an error will be returned during Pod creation. + description: Specify whether the ConfigMap must be defined type: boolean - path: - description: |- - The path within the volume from which to select the file. - Must be relative and may not contain the '..' path or start with '..'. - type: string - volumeName: - description: The name of the volume mount containing - the env file. - type: string - required: - - key - - path - - volumeName type: object x-kubernetes-map-type: atomic - resourceFieldRef: + prefix: description: |- - Selects a resource of the container: only resources limits and requests - (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. - properties: - containerName: - description: 'Container name: required for volumes, - optional for env vars' - type: string - divisor: - anyOf: - - type: integer - - type: string - description: Specifies the output format of the exposed - resources, defaults to "1" - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - resource: - description: 'Required: resource to select' - type: string - required: - - resource - type: object - x-kubernetes-map-type: atomic - secretKeyRef: - description: Selects a key of a secret in the pod's namespace + Optional text to prepend to the name of each environment variable. + May consist of any printable ASCII characters except '='. + type: string + secretRef: + description: The Secret to select from properties: - key: - description: The key of the secret to select from. Must - be a valid secret key. - type: string name: default: "" description: |- @@ -247,570 +280,803 @@ spec: More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names type: string optional: - description: Specify whether the Secret or its key must - be defined + description: Specify whether the Secret must be defined type: boolean - required: - - key type: object x-kubernetes-map-type: atomic type: object - required: - - name - type: object - type: array - envFrom: - description: |- - EnvFrom is a list of sources to populate environment variables in the MCP server container. - Each entry injects all key-value pairs from a Secret or ConfigMap as environment variables. - The keys become the variable names. Useful when a Secret's keys already match - the expected env var names (e.g., GITHUB_TOKEN). - items: - description: EnvFromSource represents the source of a set of ConfigMaps - or Secrets - properties: - configMapRef: - description: The ConfigMap to select from + type: array + path: + default: /mcp + description: |- + Path is the HTTP path where the MCP server listens for SSE/Streamable HTTP connections. + This path is appended to the service address in the status URL. + Must be a valid URI path component starting with '/'. + Maximum 253 characters. Cannot contain spaces, control characters, or query/fragment separators (? #). + Examples: /mcp, /api/v1/mcp, /services/mcp-server + Defaults to /mcp if not specified. + maxLength: 253 + minLength: 1 + type: string + x-kubernetes-validations: + - message: path must start with '/' + rule: self.startsWith('/') + - message: path must not contain spaces + rule: '!self.contains('' '')' + - message: path must not contain query string separator '?' + rule: '!self.contains(''?'')' + - message: path must not contain fragment separator '#' + rule: '!self.contains(''#'')' + - message: path must not contain control characters (newlines, + tabs) + rule: '!self.contains(''\n'') && !self.contains(''\r'') && !self.contains(''\t'')' + port: + description: |- + Port is a required field that specifies the port number on which the MCP server listens for connections. + Must be between 1 and 65535. + This should match the port that the MCP server container exposes and will be used for + configuring the Kubernetes Service. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + storage: + description: |- + Storage defines storage mounts for ConfigMaps and Secrets. + Each item uses native Kubernetes volume source types for consistency and feature parity. + If specified, must contain at least 1 item. Maximum 64 items. + items: + description: |- + StorageMount defines a storage mount combining volume source and mount configuration. + The Path and Permissions fields apply to all storage types, while Source contains + the type-specific configuration (ConfigMap or Secret). properties: - name: - default: "" + path: description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + Path is a required field that specifies where the volume should be mounted in the container. + Must be an absolute path (starting with /). + The ConfigMap or Secret data will be accessible to the MCP server process at this location. + Must be between 1 and 4096 characters, start with '/', and must not contain ':'. + maxLength: 4096 + minLength: 1 type: string - optional: - description: Specify whether the ConfigMap must be defined - type: boolean - type: object - x-kubernetes-map-type: atomic - prefix: - description: |- - Optional text to prepend to the name of each environment variable. - May consist of any printable ASCII characters except '='. - type: string - secretRef: - description: The Secret to select from - properties: - name: - default: "" + x-kubernetes-validations: + - message: path must be an absolute path (must start with + '/') + rule: self.startsWith('/') + - message: path must not contain ':' character + rule: '!self.contains('':'')' + permissions: + default: ReadOnly description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + Permissions specifies the access permissions for the mount. + Allowed values are ReadOnly, ReadWrite, and RecursiveReadOnly. + When set to ReadOnly, the mount is read-only. + When set to ReadWrite, the mount is read-write. + When set to RecursiveReadOnly, the mount and all submounts are recursively read-only. + Defaults to ReadOnly for ConfigMap and Secret mounts. + enum: + - ReadOnly + - ReadWrite + - RecursiveReadOnly type: string - optional: - description: Specify whether the Secret must be defined - type: boolean + source: + description: Source defines where the storage data comes + from (ConfigMap or Secret). + properties: + configMap: + description: |- + ConfigMap specifies a ConfigMap volume source (when Type is ConfigMap). + Uses native Kubernetes ConfigMapVolumeSource type for full feature parity. + properties: + defaultMode: + description: |- + defaultMode is optional: mode bits used to set permissions on created files by default. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + Defaults to 0644. + Directories within the path are not affected by this setting. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + secret: + description: |- + Secret specifies a Secret volume source (when Type is Secret). + Uses native Kubernetes SecretVolumeSource type for full feature parity. + properties: + defaultMode: + description: |- + defaultMode is Optional: mode bits used to set permissions on created files by default. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values + for mode bits. Defaults to 0644. + Directories within the path are not affected by this setting. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + items: + description: |- + items If unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + optional: + description: optional field specify whether the + Secret or its keys must be defined + type: boolean + secretName: + description: |- + secretName is the name of the secret in the pod's namespace to use. + More info: https://kubernetes.io/docs/concepts/storage/volumes#secret + type: string + type: object + type: + description: |- + Type is a required field that specifies the type of volume source. + Allowed values are: ConfigMap, Secret. + This determines which volume source field (configMap or secret) should be configured. + enum: + - ConfigMap + - Secret + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: configMap must be set when type is ConfigMap + and must not be set otherwise + rule: 'self.type == ''ConfigMap'' ? has(self.configMap) + : !has(self.configMap)' + - message: secret must be set when type is Secret and must + not be set otherwise + rule: 'self.type == ''Secret'' ? has(self.secret) : !has(self.secret)' + required: + - path + - source type: object - x-kubernetes-map-type: atomic - type: object - type: array - image: - description: |- - Image is the container image containing the MCP server implementation. - Examples: - - ghcr.io/modelcontextprotocol/servers/filesystem:latest - - ghcr.io/modelcontextprotocol/servers/github:v1.0.0 - - custom-registry.io/my-mcp-server:1.2.3 - minLength: 1 - type: string - path: - default: /mcp + maxItems: 64 + minItems: 1 + type: array + required: + - port + type: object + runtime: description: |- - Path is the HTTP path where the MCP server listens for SSE/Streamable HTTP connections. - This path is appended to the service address in the status URL. - Defaults to /mcp if not specified. - type: string - podSecurityContext: - description: PodSecurityContext specifies the security context for - the MCP server pod. + Runtime defines runtime management configuration. + If not specified, default runtime settings will be applied. properties: - appArmorProfile: + replicas: description: |- - appArmorProfile is the AppArmor options to use by the containers in this pod. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile loaded on the node that should be used. - The profile must be preconfigured on the node to work. - Must match the loaded name of the profile. - Must be set if and only if type is "Localhost". - type: string - type: - description: |- - type indicates which kind of AppArmor profile will be applied. - Valid options are: - Localhost - a profile pre-loaded on the node. - RuntimeDefault - the container runtime's default profile. - Unconfined - no AppArmor enforcement. - type: string - required: - - type - type: object - fsGroup: + Replicas is the number of MCP server pod replicas to run. + Defaults to 1 if not specified. + Set to 0 to scale down the deployment. + format: int32 + minimum: 0 + type: integer + security: description: |- - A special supplemental group that applies to all containers in a pod. - Some volume types allow the Kubelet to change the ownership of that volume - to be owned by the pod: + Security defines security-related configuration. + If not specified, default security settings will be applied. + properties: + podSecurityContext: + description: PodSecurityContext specifies the security context + for the MCP server pod. + properties: + appArmorProfile: + description: |- + appArmorProfile is the AppArmor options to use by the containers in this pod. + Note that this field cannot be set when spec.os.name is windows. + properties: + localhostProfile: + description: |- + localhostProfile indicates a profile loaded on the node that should be used. + The profile must be preconfigured on the node to work. + Must match the loaded name of the profile. + Must be set if and only if type is "Localhost". + type: string + type: + description: |- + type indicates which kind of AppArmor profile will be applied. + Valid options are: + Localhost - a profile pre-loaded on the node. + RuntimeDefault - the container runtime's default profile. + Unconfined - no AppArmor enforcement. + type: string + required: + - type + type: object + fsGroup: + description: |- + A special supplemental group that applies to all containers in a pod. + Some volume types allow the Kubelet to change the ownership of that volume + to be owned by the pod: - 1. The owning GID will be the FSGroup - 2. The setgid bit is set (new files created in the volume will be owned by FSGroup) - 3. The permission bits are OR'd with rw-rw---- + 1. The owning GID will be the FSGroup + 2. The setgid bit is set (new files created in the volume will be owned by FSGroup) + 3. The permission bits are OR'd with rw-rw---- - If unset, the Kubelet will not modify the ownership and permissions of any volume. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - fsGroupChangePolicy: - description: |- - fsGroupChangePolicy defines behavior of changing ownership and permission of the volume - before being exposed inside Pod. This field will only apply to - volume types which support fsGroup based ownership(and permissions). - It will have no effect on ephemeral volume types such as: secret, configmaps - and emptydir. - Valid values are "OnRootMismatch" and "Always". If not specified, "Always" is used. - Note that this field cannot be set when spec.os.name is windows. - type: string - runAsGroup: - description: |- - The GID to run the entrypoint of the container process. - Uses runtime default if unset. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence - for that container. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - runAsNonRoot: - description: |- - Indicates that the container must run as a non-root user. - If true, the Kubelet will validate the image at runtime to ensure that it - does not run as UID 0 (root) and fail to start the container if it does. - If unset or false, no such validation will be performed. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: boolean - runAsUser: - description: |- - The UID to run the entrypoint of the container process. - Defaults to user specified in image metadata if unspecified. - May also be set in SecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence - for that container. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - seLinuxChangePolicy: - description: |- - seLinuxChangePolicy defines how the container's SELinux label is applied to all volumes used by the Pod. - It has no effect on nodes that do not support SELinux or to volumes does not support SELinux. - Valid values are "MountOption" and "Recursive". + If unset, the Kubelet will not modify the ownership and permissions of any volume. + Note that this field cannot be set when spec.os.name is windows. + format: int64 + type: integer + fsGroupChangePolicy: + description: |- + fsGroupChangePolicy defines behavior of changing ownership and permission of the volume + before being exposed inside Pod. This field will only apply to + volume types which support fsGroup based ownership(and permissions). + It will have no effect on ephemeral volume types such as: secret, configmaps + and emptydir. + Valid values are "OnRootMismatch" and "Always". If not specified, "Always" is used. + Note that this field cannot be set when spec.os.name is windows. + type: string + runAsGroup: + description: |- + The GID to run the entrypoint of the container process. + Uses runtime default if unset. + May also be set in SecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence + for that container. + Note that this field cannot be set when spec.os.name is windows. + format: int64 + type: integer + runAsNonRoot: + description: |- + Indicates that the container must run as a non-root user. + If true, the Kubelet will validate the image at runtime to ensure that it + does not run as UID 0 (root) and fail to start the container if it does. + If unset or false, no such validation will be performed. + May also be set in SecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + type: boolean + runAsUser: + description: |- + The UID to run the entrypoint of the container process. + Defaults to user specified in image metadata if unspecified. + May also be set in SecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence + for that container. + Note that this field cannot be set when spec.os.name is windows. + format: int64 + type: integer + seLinuxChangePolicy: + description: |- + seLinuxChangePolicy defines how the container's SELinux label is applied to all volumes used by the Pod. + It has no effect on nodes that do not support SELinux or to volumes does not support SELinux. + Valid values are "MountOption" and "Recursive". - "Recursive" means relabeling of all files on all Pod volumes by the container runtime. - This may be slow for large volumes, but allows mixing privileged and unprivileged Pods sharing the same volume on the same node. + "Recursive" means relabeling of all files on all Pod volumes by the container runtime. + This may be slow for large volumes, but allows mixing privileged and unprivileged Pods sharing the same volume on the same node. - "MountOption" mounts all eligible Pod volumes with `-o context` mount option. - This requires all Pods that share the same volume to use the same SELinux label. - It is not possible to share the same volume among privileged and unprivileged Pods. - Eligible volumes are in-tree FibreChannel and iSCSI volumes, and all CSI volumes - whose CSI driver announces SELinux support by setting spec.seLinuxMount: true in their - CSIDriver instance. Other volumes are always re-labelled recursively. - "MountOption" value is allowed only when SELinuxMount feature gate is enabled. + "MountOption" mounts all eligible Pod volumes with `-o context` mount option. + This requires all Pods that share the same volume to use the same SELinux label. + It is not possible to share the same volume among privileged and unprivileged Pods. + Eligible volumes are in-tree FibreChannel and iSCSI volumes, and all CSI volumes + whose CSI driver announces SELinux support by setting spec.seLinuxMount: true in their + CSIDriver instance. Other volumes are always re-labelled recursively. + "MountOption" value is allowed only when SELinuxMount feature gate is enabled. - If not specified and SELinuxMount feature gate is enabled, "MountOption" is used. - If not specified and SELinuxMount feature gate is disabled, "MountOption" is used for ReadWriteOncePod volumes - and "Recursive" for all other volumes. + If not specified and SELinuxMount feature gate is enabled, "MountOption" is used. + If not specified and SELinuxMount feature gate is disabled, "MountOption" is used for ReadWriteOncePod volumes + and "Recursive" for all other volumes. - This field affects only Pods that have SELinux label set, either in PodSecurityContext or in SecurityContext of all containers. + This field affects only Pods that have SELinux label set, either in PodSecurityContext or in SecurityContext of all containers. - All Pods that use the same volume should use the same seLinuxChangePolicy, otherwise some pods can get stuck in ContainerCreating state. - Note that this field cannot be set when spec.os.name is windows. - type: string - seLinuxOptions: - description: |- - The SELinux context to be applied to all containers. - If unspecified, the container runtime will allocate a random SELinux context for each - container. May also be set in SecurityContext. If set in - both SecurityContext and PodSecurityContext, the value specified in SecurityContext - takes precedence for that container. - Note that this field cannot be set when spec.os.name is windows. - properties: - level: - description: Level is SELinux level label that applies to - the container. - type: string - role: - description: Role is a SELinux role label that applies to - the container. - type: string - type: - description: Type is a SELinux type label that applies to - the container. - type: string - user: - description: User is a SELinux user label that applies to - the container. - type: string - type: object - seccompProfile: - description: |- - The seccomp options to use by the containers in this pod. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile defined in a file on the node should be used. - The profile must be preconfigured on the node to work. - Must be a descending path, relative to the kubelet's configured seccomp profile location. - Must be set if type is "Localhost". Must NOT be set for any other type. - type: string - type: - description: |- - type indicates which kind of seccomp profile will be applied. - Valid options are: + All Pods that use the same volume should use the same seLinuxChangePolicy, otherwise some pods can get stuck in ContainerCreating state. + Note that this field cannot be set when spec.os.name is windows. + type: string + seLinuxOptions: + description: |- + The SELinux context to be applied to all containers. + If unspecified, the container runtime will allocate a random SELinux context for each + container. May also be set in SecurityContext. If set in + both SecurityContext and PodSecurityContext, the value specified in SecurityContext + takes precedence for that container. + Note that this field cannot be set when spec.os.name is windows. + properties: + level: + description: Level is SELinux level label that applies + to the container. + type: string + role: + description: Role is a SELinux role label that applies + to the container. + type: string + type: + description: Type is a SELinux type label that applies + to the container. + type: string + user: + description: User is a SELinux user label that applies + to the container. + type: string + type: object + seccompProfile: + description: |- + The seccomp options to use by the containers in this pod. + Note that this field cannot be set when spec.os.name is windows. + properties: + localhostProfile: + description: |- + localhostProfile indicates a profile defined in a file on the node should be used. + The profile must be preconfigured on the node to work. + Must be a descending path, relative to the kubelet's configured seccomp profile location. + Must be set if type is "Localhost". Must NOT be set for any other type. + type: string + type: + description: |- + type indicates which kind of seccomp profile will be applied. + Valid options are: - Localhost - a profile defined in a file on the node should be used. - RuntimeDefault - the container runtime default profile should be used. - Unconfined - no profile should be applied. - type: string - required: - - type - type: object - supplementalGroups: - description: |- - A list of groups applied to the first process run in each container, in - addition to the container's primary GID and fsGroup (if specified). If - the SupplementalGroupsPolicy feature is enabled, the - supplementalGroupsPolicy field determines whether these are in addition - to or instead of any group memberships defined in the container image. - If unspecified, no additional groups are added, though group memberships - defined in the container image may still be used, depending on the - supplementalGroupsPolicy field. - Note that this field cannot be set when spec.os.name is windows. - items: - format: int64 - type: integer - type: array - x-kubernetes-list-type: atomic - supplementalGroupsPolicy: - description: |- - Defines how supplemental groups of the first container processes are calculated. - Valid values are "Merge" and "Strict". If not specified, "Merge" is used. - (Alpha) Using the field requires the SupplementalGroupsPolicy feature gate to be enabled - and the container runtime must implement support for this feature. - Note that this field cannot be set when spec.os.name is windows. - type: string - sysctls: - description: |- - Sysctls hold a list of namespaced sysctls used for the pod. Pods with unsupported - sysctls (by the container runtime) might fail to launch. - Note that this field cannot be set when spec.os.name is windows. - items: - description: Sysctl defines a kernel parameter to be set - properties: - name: - description: Name of a property to set - type: string - value: - description: Value of a property to set - type: string - required: - - name - - value - type: object - type: array - x-kubernetes-list-type: atomic - windowsOptions: - description: |- - The Windows specific settings applied to all containers. - If unspecified, the options within a container's SecurityContext will be used. - If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is linux. - properties: - gmsaCredentialSpec: - description: |- - GMSACredentialSpec is where the GMSA admission webhook - (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the - GMSA credential spec named by the GMSACredentialSpecName field. - type: string - gmsaCredentialSpecName: - description: GMSACredentialSpecName is the name of the GMSA - credential spec to use. - type: string - hostProcess: - description: |- - HostProcess determines if a container should be run as a 'Host Process' container. - All of a Pod's containers must have the same effective HostProcess value - (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). - In addition, if HostProcess is true then HostNetwork must also be set to true. - type: boolean - runAsUserName: + Localhost - a profile defined in a file on the node should be used. + RuntimeDefault - the container runtime default profile should be used. + Unconfined - no profile should be applied. + type: string + required: + - type + type: object + supplementalGroups: + description: |- + A list of groups applied to the first process run in each container, in + addition to the container's primary GID and fsGroup (if specified). If + the SupplementalGroupsPolicy feature is enabled, the + supplementalGroupsPolicy field determines whether these are in addition + to or instead of any group memberships defined in the container image. + If unspecified, no additional groups are added, though group memberships + defined in the container image may still be used, depending on the + supplementalGroupsPolicy field. + Note that this field cannot be set when spec.os.name is windows. + items: + format: int64 + type: integer + type: array + x-kubernetes-list-type: atomic + supplementalGroupsPolicy: + description: |- + Defines how supplemental groups of the first container processes are calculated. + Valid values are "Merge" and "Strict". If not specified, "Merge" is used. + (Alpha) Using the field requires the SupplementalGroupsPolicy feature gate to be enabled + and the container runtime must implement support for this feature. + Note that this field cannot be set when spec.os.name is windows. + type: string + sysctls: + description: |- + Sysctls hold a list of namespaced sysctls used for the pod. Pods with unsupported + sysctls (by the container runtime) might fail to launch. + Note that this field cannot be set when spec.os.name is windows. + items: + description: Sysctl defines a kernel parameter to be + set + properties: + name: + description: Name of a property to set + type: string + value: + description: Value of a property to set + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + windowsOptions: + description: |- + The Windows specific settings applied to all containers. + If unspecified, the options within a container's SecurityContext will be used. + If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. + Note that this field cannot be set when spec.os.name is linux. + properties: + gmsaCredentialSpec: + description: |- + GMSACredentialSpec is where the GMSA admission webhook + (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the + GMSA credential spec named by the GMSACredentialSpecName field. + type: string + gmsaCredentialSpecName: + description: GMSACredentialSpecName is the name of + the GMSA credential spec to use. + type: string + hostProcess: + description: |- + HostProcess determines if a container should be run as a 'Host Process' container. + All of a Pod's containers must have the same effective HostProcess value + (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). + In addition, if HostProcess is true then HostNetwork must also be set to true. + type: boolean + runAsUserName: + description: |- + The UserName in Windows to run the entrypoint of the container process. + Defaults to the user specified in image metadata if unspecified. + May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + type: string + type: object + type: object + securityContext: + description: SecurityContext specifies the security context + for the MCP server container. + properties: + allowPrivilegeEscalation: + description: |- + AllowPrivilegeEscalation controls whether a process can gain more + privileges than its parent process. This bool directly controls if + the no_new_privs flag will be set on the container process. + AllowPrivilegeEscalation is true always when the container is: + 1) run as Privileged + 2) has CAP_SYS_ADMIN + Note that this field cannot be set when spec.os.name is windows. + type: boolean + appArmorProfile: + description: |- + appArmorProfile is the AppArmor options to use by this container. If set, this profile + overrides the pod's appArmorProfile. + Note that this field cannot be set when spec.os.name is windows. + properties: + localhostProfile: + description: |- + localhostProfile indicates a profile loaded on the node that should be used. + The profile must be preconfigured on the node to work. + Must match the loaded name of the profile. + Must be set if and only if type is "Localhost". + type: string + type: + description: |- + type indicates which kind of AppArmor profile will be applied. + Valid options are: + Localhost - a profile pre-loaded on the node. + RuntimeDefault - the container runtime's default profile. + Unconfined - no AppArmor enforcement. + type: string + required: + - type + type: object + capabilities: + description: |- + The capabilities to add/drop when running containers. + Defaults to the default set of capabilities granted by the container runtime. + Note that this field cannot be set when spec.os.name is windows. + properties: + add: + description: Added capabilities + items: + description: Capability represent POSIX capabilities + type + type: string + type: array + x-kubernetes-list-type: atomic + drop: + description: Removed capabilities + items: + description: Capability represent POSIX capabilities + type + type: string + type: array + x-kubernetes-list-type: atomic + type: object + privileged: + description: |- + Run container in privileged mode. + Processes in privileged containers are essentially equivalent to root on the host. + Defaults to false. + Note that this field cannot be set when spec.os.name is windows. + type: boolean + procMount: + description: |- + procMount denotes the type of proc mount to use for the containers. + The default value is Default which uses the container runtime defaults for + readonly paths and masked paths. + This requires the ProcMountType feature flag to be enabled. + Note that this field cannot be set when spec.os.name is windows. + type: string + readOnlyRootFilesystem: + description: |- + Whether this container has a read-only root filesystem. + Default is false. + Note that this field cannot be set when spec.os.name is windows. + type: boolean + runAsGroup: + description: |- + The GID to run the entrypoint of the container process. + Uses runtime default if unset. + May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + Note that this field cannot be set when spec.os.name is windows. + format: int64 + type: integer + runAsNonRoot: + description: |- + Indicates that the container must run as a non-root user. + If true, the Kubelet will validate the image at runtime to ensure that it + does not run as UID 0 (root) and fail to start the container if it does. + If unset or false, no such validation will be performed. + May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + type: boolean + runAsUser: + description: |- + The UID to run the entrypoint of the container process. + Defaults to user specified in image metadata if unspecified. + May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + Note that this field cannot be set when spec.os.name is windows. + format: int64 + type: integer + seLinuxOptions: + description: |- + The SELinux context to be applied to the container. + If unspecified, the container runtime will allocate a random SELinux context for each + container. May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + Note that this field cannot be set when spec.os.name is windows. + properties: + level: + description: Level is SELinux level label that applies + to the container. + type: string + role: + description: Role is a SELinux role label that applies + to the container. + type: string + type: + description: Type is a SELinux type label that applies + to the container. + type: string + user: + description: User is a SELinux user label that applies + to the container. + type: string + type: object + seccompProfile: + description: |- + The seccomp options to use by this container. If seccomp options are + provided at both the pod & container level, the container options + override the pod options. + Note that this field cannot be set when spec.os.name is windows. + properties: + localhostProfile: + description: |- + localhostProfile indicates a profile defined in a file on the node should be used. + The profile must be preconfigured on the node to work. + Must be a descending path, relative to the kubelet's configured seccomp profile location. + Must be set if type is "Localhost". Must NOT be set for any other type. + type: string + type: + description: |- + type indicates which kind of seccomp profile will be applied. + Valid options are: + + Localhost - a profile defined in a file on the node should be used. + RuntimeDefault - the container runtime default profile should be used. + Unconfined - no profile should be applied. + type: string + required: + - type + type: object + windowsOptions: + description: |- + The Windows specific settings applied to all containers. + If unspecified, the options from the PodSecurityContext will be used. + If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. + Note that this field cannot be set when spec.os.name is linux. + properties: + gmsaCredentialSpec: + description: |- + GMSACredentialSpec is where the GMSA admission webhook + (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the + GMSA credential spec named by the GMSACredentialSpecName field. + type: string + gmsaCredentialSpecName: + description: GMSACredentialSpecName is the name of + the GMSA credential spec to use. + type: string + hostProcess: + description: |- + HostProcess determines if a container should be run as a 'Host Process' container. + All of a Pod's containers must have the same effective HostProcess value + (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). + In addition, if HostProcess is true then HostNetwork must also be set to true. + type: boolean + runAsUserName: + description: |- + The UserName in Windows to run the entrypoint of the container process. + Defaults to the user specified in image metadata if unspecified. + May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + type: string + type: object + type: object + serviceAccountName: description: |- - The UserName in Windows to run the entrypoint of the container process. - Defaults to the user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. + 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. + Must be a string that follows the DNS1123 subdomain format. + Must be at most 253 characters in length, and must consist only of lower case alphanumeric characters, '-' + and '.', and must start and end with an alphanumeric character. + Example: For kubernetes-mcp-server with read-only access, create a ServiceAccount + and bind it to the 'view' ClusterRole. + maxLength: 253 type: string + x-kubernetes-validations: + - message: 'serviceAccountName must be a valid DNS subdomain + name: a lowercase RFC 1123 subdomain must consist of lower + case alphanumeric characters, ''-'' or ''.'', and must + start and end with an alphanumeric character.' + rule: self == '' || !format.dns1123Subdomain().validate(self).hasValue() type: object type: object - port: - description: |- - Port is the port number on which the MCP server listens for connections. - Must be between 1 and 65535. - Should match the port the MCP server container exposes. - format: int32 - 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. - Only used when SecretRef is set. - type: string - secretMountPath: - description: |- - SecretMountPath specifies the path where the Secret should be mounted. - Only used when SecretRef is set. Defaults to /etc/mcp-secrets if not specified. - type: string - secretRef: - description: |- - SecretRef references a Secret containing credentials - The Secret will be mounted as a read-only volume. - Use SecretMountPath to specify where to mount it. - Use SecretVolumeName to specify the volume name. - Use SecretKey to specify the key of the Secret to mount. - properties: - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - type: object - x-kubernetes-map-type: atomic - secretVolumeName: + source: description: |- - SecretVolumeName specifies the name of the volume for the Secret mount. - Only used when SecretRef is set. Defaults to mcp-secrets if not specified. - type: string - securityContext: - description: SecurityContext specifies the security context for the - MCP server container. + Source is a required field that defines where the MCP server should be sourced from. + Currently supports container images, with potential for additional source types in the future. + This configuration determines how the MCP server will be deployed and run. properties: - allowPrivilegeEscalation: - description: |- - AllowPrivilegeEscalation controls whether a process can gain more - privileges than its parent process. This bool directly controls if - the no_new_privs flag will be set on the container process. - AllowPrivilegeEscalation is true always when the container is: - 1) run as Privileged - 2) has CAP_SYS_ADMIN - Note that this field cannot be set when spec.os.name is windows. - type: boolean - appArmorProfile: - description: |- - appArmorProfile is the AppArmor options to use by this container. If set, this profile - overrides the pod's appArmorProfile. - Note that this field cannot be set when spec.os.name is windows. + containerImage: + description: ContainerImage specifies container image details + when Type is ContainerImage. properties: - localhostProfile: - description: |- - localhostProfile indicates a profile loaded on the node that should be used. - The profile must be preconfigured on the node to work. - Must match the loaded name of the profile. - Must be set if and only if type is "Localhost". - type: string - type: + ref: description: |- - type indicates which kind of AppArmor profile will be applied. - Valid options are: - Localhost - a profile pre-loaded on the node. - RuntimeDefault - the container runtime's default profile. - Unconfined - no AppArmor enforcement. + Ref is the container image containing the MCP server implementation. + Must be a valid OCI image reference. + Examples: + - ghcr.io/modelcontextprotocol/servers/filesystem:latest + - ghcr.io/modelcontextprotocol/servers/github:v1.0.0 + - custom-registry.io/my-mcp-server:1.2.3 + - custom-registry.io/my-mcp-server@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef + maxLength: 1000 + minLength: 1 type: string + x-kubernetes-validations: + - message: must start with a valid domain. valid domains must + be alphanumeric characters (lowercase and uppercase) separated + by the "." character. + rule: self.matches('^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])((\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))+)?(:[0-9]+)?\\b') + - message: a valid name is required. valid names must contain + lowercase alphanumeric characters separated only by the + ".", "_", "__", "-" characters. + rule: self.find('(\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?((\\/[a-z0-9]+((([._]|__|[-]*)[a-z0-9]+)+)?)+)?)') + != "" + - message: must end with a digest or a tag + rule: self.find('(@.*:)') != "" || self.find(':.*$') != + "" + - message: tag is invalid. the tag must not be more than 127 + characters + rule: 'self.find(''(@.*:)'') == "" ? (self.find('':.*$'') + != "" ? self.find('':.*$'').substring(1).size() <= 127 + : true) : true' + - message: tag is invalid. valid tags must begin with a word + character (alphanumeric + "_") followed by word characters + or ".", and "-" characters + rule: 'self.find(''(@.*:)'') == "" ? (self.find('':.*$'') + != "" ? self.find('':.*$'').matches('':[\\w][\\w.-]*$'') + : true) : true' + - message: digest algorithm is not valid. valid algorithms + must start with an uppercase or lowercase alpha character + followed by alphanumeric characters and may contain the + "-", "_", "+", and "." characters. + rule: 'self.find(''(@.*:)'') != "" ? self.find(''(@.*:)'').matches(''(@[A-Za-z][A-Za-z0-9]*([-_+.][A-Za-z][A-Za-z0-9]*)*[:])'') + : true' + - message: digest is not valid. the encoded string must be + at least 32 characters + rule: 'self.find(''(@.*:)'') != "" ? self.find('':.*$'').substring(1).size() + >= 32 : true' + - message: digest is not valid. the encoded string must only + contain hex characters (A-F, a-f, 0-9) + rule: 'self.find(''(@.*:)'') != "" ? self.find('':.*$'').matches('':[0-9A-Fa-f]*$'') + : true' required: - - type + - ref type: object - capabilities: - description: |- - The capabilities to add/drop when running containers. - Defaults to the default set of capabilities granted by the container runtime. - Note that this field cannot be set when spec.os.name is windows. - properties: - add: - description: Added capabilities - items: - description: Capability represent POSIX capabilities type - type: string - type: array - x-kubernetes-list-type: atomic - drop: - description: Removed capabilities - items: - description: Capability represent POSIX capabilities type - type: string - type: array - x-kubernetes-list-type: atomic - type: object - privileged: - description: |- - Run container in privileged mode. - Processes in privileged containers are essentially equivalent to root on the host. - Defaults to false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - procMount: + type: description: |- - procMount denotes the type of proc mount to use for the containers. - The default value is Default which uses the container runtime defaults for - readonly paths and masked paths. - This requires the ProcMountType feature flag to be enabled. - Note that this field cannot be set when spec.os.name is windows. + Type is a required field that configures how the MCP server should be sourced. + Allowed values are: ContainerImage. + When set to ContainerImage, the MCP server will be sourced directly from an OCI + container image following the configuration specified in containerImage. + enum: + - ContainerImage type: string - readOnlyRootFilesystem: - description: |- - Whether this container has a read-only root filesystem. - Default is false. - Note that this field cannot be set when spec.os.name is windows. - type: boolean - runAsGroup: - description: |- - The GID to run the entrypoint of the container process. - Uses runtime default if unset. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - runAsNonRoot: - description: |- - Indicates that the container must run as a non-root user. - If true, the Kubelet will validate the image at runtime to ensure that it - does not run as UID 0 (root) and fail to start the container if it does. - If unset or false, no such validation will be performed. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: boolean - runAsUser: - description: |- - The UID to run the entrypoint of the container process. - Defaults to user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - format: int64 - type: integer - seLinuxOptions: - description: |- - The SELinux context to be applied to the container. - If unspecified, the container runtime will allocate a random SELinux context for each - container. May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is windows. - properties: - level: - description: Level is SELinux level label that applies to - the container. - type: string - role: - description: Role is a SELinux role label that applies to - the container. - type: string - type: - description: Type is a SELinux type label that applies to - the container. - type: string - user: - description: User is a SELinux user label that applies to - the container. - type: string - type: object - seccompProfile: - description: |- - The seccomp options to use by this container. If seccomp options are - provided at both the pod & container level, the container options - override the pod options. - Note that this field cannot be set when spec.os.name is windows. - properties: - localhostProfile: - description: |- - localhostProfile indicates a profile defined in a file on the node should be used. - The profile must be preconfigured on the node to work. - Must be a descending path, relative to the kubelet's configured seccomp profile location. - Must be set if type is "Localhost". Must NOT be set for any other type. - type: string - type: - description: |- - type indicates which kind of seccomp profile will be applied. - Valid options are: - - Localhost - a profile defined in a file on the node should be used. - RuntimeDefault - the container runtime default profile should be used. - Unconfined - no profile should be applied. - type: string - required: - - type - type: object - windowsOptions: - description: |- - The Windows specific settings applied to all containers. - If unspecified, the options from the PodSecurityContext will be used. - If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. - Note that this field cannot be set when spec.os.name is linux. - properties: - gmsaCredentialSpec: - description: |- - GMSACredentialSpec is where the GMSA admission webhook - (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the - GMSA credential spec named by the GMSACredentialSpecName field. - type: string - gmsaCredentialSpecName: - description: GMSACredentialSpecName is the name of the GMSA - credential spec to use. - type: string - hostProcess: - description: |- - HostProcess determines if a container should be run as a 'Host Process' container. - All of a Pod's containers must have the same effective HostProcess value - (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). - In addition, if HostProcess is true then HostNetwork must also be set to true. - type: boolean - runAsUserName: - description: |- - The UserName in Windows to run the entrypoint of the container process. - Defaults to the user specified in image metadata if unspecified. - May also be set in PodSecurityContext. If set in both SecurityContext and - PodSecurityContext, the value specified in SecurityContext takes precedence. - type: string - type: object + required: + - type type: object - serviceAccountName: - description: |- - 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. - Example: For kubernetes-mcp-server with read-only access, create a ServiceAccount - and bind it to the 'view' ClusterRole. - type: string + x-kubernetes-validations: + - message: containerImage must be set when type is ContainerImage + and must not be set otherwise + rule: 'self.type == ''ContainerImage'' ? has(self.containerImage) + : !has(self.containerImage)' required: - - image - - port + - config + - source type: object status: description: status defines the observed state of MCPServer @@ -829,10 +1095,10 @@ spec: Conditions represent the current state of the MCPServer resource. Each condition has a unique type and reflects the status of a specific aspect of the resource. - Standard condition types include: - - "Ready": the resource is fully functional and available - - "Progressing": the resource is being created or updated - - "Degraded": the resource failed to reach or maintain its desired state + Standard condition types include "Ready", "Progressing", and "Degraded". + The "Ready" condition indicates the resource is fully functional and available. + The "Progressing" condition indicates the resource is being created or updated. + The "Degraded" condition indicates the resource failed to reach or maintain its desired state. The status of each condition is one of True, False, or Unknown. items: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index f8baa7da..cfb0eb41 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -1,6 +1,3 @@ -# Generated from kubebuilder template: -# https://github.com/kubernetes-sigs/kubebuilder/blob/v4.11.1/pkg/plugins/common/kustomize/v2/scaffolds/internal/templates/config/rbac/role.go - --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole @@ -10,6 +7,7 @@ rules: - apiGroups: - "" resources: + - configmaps - secrets verbs: - get diff --git a/config/samples/mcp_v1alpha1_mcpserver.yaml b/config/samples/mcp_v1alpha1_mcpserver.yaml index 932f214c..3c85d472 100644 --- a/config/samples/mcp_v1alpha1_mcpserver.yaml +++ b/config/samples/mcp_v1alpha1_mcpserver.yaml @@ -9,6 +9,9 @@ metadata: app.kubernetes.io/managed-by: kustomize name: mcpserver-sample spec: -spec: - image: quay.io/containers/kubernetes_mcp_server:latest - port: 8080 + source: + type: ContainerImage + containerImage: + ref: quay.io/containers/kubernetes_mcp_server:latest + config: + port: 8080 diff --git a/examples/everything-mcp-server/mcpserver.yaml b/examples/everything-mcp-server/mcpserver.yaml index 968ac87d..8fe286e8 100644 --- a/examples/everything-mcp-server/mcpserver.yaml +++ b/examples/everything-mcp-server/mcpserver.yaml @@ -4,5 +4,10 @@ metadata: name: everything-mcp-server namespace: default spec: - image: quay.io/matzew/mcp-everything:latest - port: 3001 + source: + type: ContainerImage + containerImage: + ref: quay.io/matzew/mcp-everything:latest + config: + port: 3001 + path: /mcp diff --git a/examples/kubernetes-mcp-server/README.md b/examples/kubernetes-mcp-server/README.md index 6bb09646..972de8d3 100644 --- a/examples/kubernetes-mcp-server/README.md +++ b/examples/kubernetes-mcp-server/README.md @@ -36,12 +36,11 @@ kubectl apply -f mcpserver-with-config.yaml This example shows how to: 1. Create a ConfigMap with `config.toml` -2. Reference the ConfigMap in the MCPServer spec -3. Optionally specify a custom mount path via `configMapMountPath` (defaults to `/etc/mcp-config`) -4. Optionally specify a custom volume name via `configMapVolumeName` (defaults to `mcp-config`) -5. Pass the config path via args +2. Mount the ConfigMap via `spec.config.storage` array +3. Specify the mount path and ConfigMap details +4. Pass the config path via `spec.config.arguments` -The ConfigMap is mounted as a read-only volume at the specified path. +The ConfigMap is mounted as a read-only volume (readOnly defaults to true). ## Deployment with RBAC (Recommended) @@ -102,7 +101,9 @@ Then create a ClusterRoleBinding and update the MCPServer to use your ServiceAcc ```yaml spec: - serviceAccountName: my-custom-sa + runtime: + security: + serviceAccountName: my-custom-sa ``` ## Testing diff --git a/examples/kubernetes-mcp-server/mcpserver-with-config.yaml b/examples/kubernetes-mcp-server/mcpserver-with-config.yaml index ca4e8398..1c83360b 100644 --- a/examples/kubernetes-mcp-server/mcpserver-with-config.yaml +++ b/examples/kubernetes-mcp-server/mcpserver-with-config.yaml @@ -20,12 +20,18 @@ metadata: name: kubernetes-mcp-server namespace: default spec: - image: quay.io/containers/kubernetes_mcp_server:latest - port: 8080 - configMapRef: - name: kubernetes-mcp-server-config - configMapMountPath: /etc/mcp-config # Optional: defaults to /etc/mcp-config - configMapVolumeName: mcp-config # Optional: defaults to mcp-config - args: - - --config - - /etc/mcp-config/config.toml + source: + type: ContainerImage + containerImage: + ref: quay.io/containers/kubernetes_mcp_server:latest + config: + port: 8080 + arguments: + - --config + - /etc/mcp-config/config.toml + storage: + - path: /etc/mcp-config + source: + type: ConfigMap + configMap: + name: kubernetes-mcp-server-config diff --git a/examples/kubernetes-mcp-server/mcpserver-with-rbac.yaml b/examples/kubernetes-mcp-server/mcpserver-with-rbac.yaml index 3477b3c1..52efcfed 100644 --- a/examples/kubernetes-mcp-server/mcpserver-with-rbac.yaml +++ b/examples/kubernetes-mcp-server/mcpserver-with-rbac.yaml @@ -42,11 +42,21 @@ metadata: name: kubernetes-mcp-server namespace: default spec: - image: quay.io/containers/kubernetes_mcp_server:latest - port: 8080 - serviceAccountName: mcp-viewer # Use the ServiceAccount with RBAC permissions - configMapRef: - name: kubernetes-mcp-server-config - args: - - --config - - /etc/mcp-config/config.toml + source: + type: ContainerImage + containerImage: + ref: quay.io/containers/kubernetes_mcp_server:latest + config: + port: 8080 + arguments: + - --config + - /etc/mcp-config/config.toml + storage: + - path: /etc/mcp-config + source: + type: ConfigMap + configMap: + name: kubernetes-mcp-server-config + runtime: + security: + serviceAccountName: mcp-viewer # Use the ServiceAccount with RBAC permissions diff --git a/examples/kubernetes-mcp-server/mcpserver.yaml b/examples/kubernetes-mcp-server/mcpserver.yaml index 0311b732..fe3f66d2 100644 --- a/examples/kubernetes-mcp-server/mcpserver.yaml +++ b/examples/kubernetes-mcp-server/mcpserver.yaml @@ -4,5 +4,9 @@ metadata: name: kubernetes-mcp-server namespace: default spec: - image: quay.io/containers/kubernetes_mcp_server:latest - port: 8080 + source: + type: ContainerImage + containerImage: + ref: quay.io/containers/kubernetes_mcp_server:latest + config: + port: 8080 diff --git a/internal/controller/mcpserver_controller.go b/internal/controller/mcpserver_controller.go index b6b56d01..cf5f3062 100644 --- a/internal/controller/mcpserver_controller.go +++ b/internal/controller/mcpserver_controller.go @@ -58,6 +58,7 @@ type MCPServerReconciler struct { // +kubebuilder:rbac:groups=mcp.x-k8s.io,resources=mcpservers/finalizers,verbs=update // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch // Reconcile is part of the main kubernetes reconciliation loop which aims to @@ -106,15 +107,15 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // Update status based on Deployment status mcpServer.Status.DeploymentName = existingDeployment.Name mcpServer.Status.ServiceName = mcpServer.Name - if mcpServer.Spec.Port > 0 { - path := mcpServer.Spec.Path + if mcpServer.Spec.Config.Port > 0 { + path := mcpServer.Spec.Config.Path if path == "" { path = "/mcp" } mcpServer.Status.Address = &mcpv1alpha1.MCPServerAddress{ // TODO: enhance this later to be TLS aware URL: fmt.Sprintf("http://%s.%s.svc.cluster.local:%d%s", - mcpServer.Name, mcpServer.Namespace, mcpServer.Spec.Port, path), + mcpServer.Name, mcpServer.Namespace, mcpServer.Spec.Config.Port, path), } } @@ -274,9 +275,22 @@ func (r *MCPServerReconciler) reconcileDeployment( // createDeployment creates a Deployment for the MCPServer func (r *MCPServerReconciler) createDeployment(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) (*appsv1.Deployment, error) { + // Validate source type and extract image reference + var imageRef string + switch mcpServer.Spec.Source.Type { + case mcpv1alpha1.SourceTypeContainerImage: + if mcpServer.Spec.Source.ContainerImage == nil { + return nil, fmt.Errorf("containerImage must be set when source type is ContainerImage") + } + imageRef = mcpServer.Spec.Source.ContainerImage.Ref + default: + return nil, fmt.Errorf("unsupported source type: %s", mcpServer.Spec.Source.Type) + } + + // Replicas defaults to 1 when not specified (nil) replicas := int32(1) - if mcpServer.Spec.Replicas != nil { - replicas = *mcpServer.Spec.Replicas + if mcpServer.Spec.Runtime.Replicas != nil { + replicas = *mcpServer.Spec.Runtime.Replicas } labels := map[string]string{ "app": "mcp-server", @@ -285,105 +299,118 @@ func (r *MCPServerReconciler) createDeployment(ctx context.Context, mcpServer *m container := corev1.Container{ Name: "mcp-server", - Image: mcpServer.Spec.Image, + Image: imageRef, Ports: []corev1.ContainerPort{ { Name: "mcp", - ContainerPort: mcpServer.Spec.Port, + ContainerPort: mcpServer.Spec.Config.Port, Protocol: corev1.ProtocolTCP, }, }, } // Add args if specified - if len(mcpServer.Spec.Args) > 0 { - container.Args = mcpServer.Spec.Args + if len(mcpServer.Spec.Config.Arguments) > 0 { + container.Args = mcpServer.Spec.Config.Arguments } // Add env vars if specified - if len(mcpServer.Spec.Env) > 0 { - container.Env = mcpServer.Spec.Env + if len(mcpServer.Spec.Config.Env) > 0 { + container.Env = mcpServer.Spec.Config.Env } - if len(mcpServer.Spec.EnvFrom) > 0 { - container.EnvFrom = mcpServer.Spec.EnvFrom + if len(mcpServer.Spec.Config.EnvFrom) > 0 { + container.EnvFrom = mcpServer.Spec.Config.EnvFrom } // Apply security context: use user-specified if provided, otherwise apply restricted defaults - if mcpServer.Spec.SecurityContext != nil { - container.SecurityContext = mcpServer.Spec.SecurityContext + if mcpServer.Spec.Runtime.Security.SecurityContext != nil { + container.SecurityContext = mcpServer.Spec.Runtime.Security.SecurityContext } else { container.SecurityContext = defaultContainerSecurityContext() } - // Add volume mount if ConfigMapRef is specified - var volumes []corev1.Volume - var volumeMounts []corev1.VolumeMount + // Process storage mounts from the new Storage API + volumes := make([]corev1.Volume, 0, len(mcpServer.Spec.Config.Storage)) + volumeMounts := make([]corev1.VolumeMount, 0, len(mcpServer.Spec.Config.Storage)) - // Add volume mount if SecretRef is specified - if mcpServer.Spec.SecretRef != nil { + for i, storage := range mcpServer.Spec.Config.Storage { + // Generate a unique volume name for each storage mount + volumeName := fmt.Sprintf("vol-%d", i) - existingSecret := &corev1.Secret{} - if err := r.Get(ctx, client.ObjectKey{Name: mcpServer.Spec.SecretRef.Name, Namespace: mcpServer.Namespace}, existingSecret); err != nil { - return nil, err - } - - volumeName := mcpServer.Spec.SecretVolumeName - if volumeName == "" { - volumeName = "mcp-secrets" - } - mountPath := mcpServer.Spec.SecretMountPath - if mountPath == "" { - mountPath = "/etc/mcp-secrets" - } + // Create volume mount volumeMount := corev1.VolumeMount{ Name: volumeName, - MountPath: mountPath, - ReadOnly: true, + MountPath: storage.Path, } - if secretKey := mcpServer.Spec.SecretKey; secretKey != "" { - if mcpServer.Spec.SecretMountPath == "" { - return nil, fmt.Errorf("secretMountPath must be specified when secretKey is set") - } - if _, ok := existingSecret.Data[secretKey]; !ok { - return nil, fmt.Errorf("secret key %s not found in secret %s/%s", secretKey, mcpServer.Namespace, mcpServer.Spec.SecretRef.Name) - } - volumeMount.SubPath = secretKey + + // Set permissions based on MountPermissions enum + // Default to ReadOnly if not specified (empty string means use default) + permissions := storage.Permissions + if permissions == "" { + permissions = mcpv1alpha1.MountPermissionsReadOnly + } + + switch permissions { + case mcpv1alpha1.MountPermissionsReadOnly: + volumeMount.ReadOnly = true + case mcpv1alpha1.MountPermissionsReadWrite: + volumeMount.ReadOnly = false + case mcpv1alpha1.MountPermissionsRecursiveReadOnly: + volumeMount.ReadOnly = true + volumeMount.RecursiveReadOnly = ptr.To(corev1.RecursiveReadOnlyEnabled) } volumeMounts = append(volumeMounts, volumeMount) - volumes = append(volumes, corev1.Volume{ - Name: volumeName, - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: mcpServer.Spec.SecretRef.Name, - }, - }, - }) - } - // Add volume mount if ConfigMapRef is specified - if mcpServer.Spec.ConfigMapRef != nil { - volumeName := mcpServer.Spec.ConfigMapVolumeName - if volumeName == "" { - volumeName = "mcp-config" + // Create volume based on type + volume := corev1.Volume{ + Name: volumeName, } - mountPath := mcpServer.Spec.ConfigMapMountPath - if mountPath == "" { - mountPath = "/etc/mcp-config" + + switch storage.Source.Type { + case mcpv1alpha1.StorageTypeConfigMap: + if storage.Source.ConfigMap == nil { + return nil, fmt.Errorf("configMap must be set when type is ConfigMap for storage mount at index %d", i) + } + // Validate ConfigMap name is not empty + if storage.Source.ConfigMap.Name == "" { + return nil, fmt.Errorf("configMap name must not be empty for storage mount at index %d", i) + } + // Verify ConfigMap exists only if not optional + if storage.Source.ConfigMap.Optional == nil || !*storage.Source.ConfigMap.Optional { + configMap := &corev1.ConfigMap{} + if err := r.Get(ctx, client.ObjectKey{ + Name: storage.Source.ConfigMap.Name, + Namespace: mcpServer.Namespace, + }, configMap); err != nil { + return nil, fmt.Errorf("failed to get ConfigMap %s for storage mount at index %d: %w", storage.Source.ConfigMap.Name, i, err) + } + } + volume.ConfigMap = storage.Source.ConfigMap + case mcpv1alpha1.StorageTypeSecret: + if storage.Source.Secret == nil { + return nil, fmt.Errorf("secret must be set when type is Secret for storage mount at index %d", i) + } + // Validate Secret name is not empty + if storage.Source.Secret.SecretName == "" { + return nil, fmt.Errorf("secret name must not be empty for storage mount at index %d", i) + } + // Verify Secret exists only if not optional + if storage.Source.Secret.Optional == nil || !*storage.Source.Secret.Optional { + secret := &corev1.Secret{} + if err := r.Get(ctx, client.ObjectKey{ + Name: storage.Source.Secret.SecretName, + Namespace: mcpServer.Namespace, + }, secret); err != nil { + return nil, fmt.Errorf("failed to get Secret %s for storage mount at index %d: %w", storage.Source.Secret.SecretName, i, err) + } + } + volume.Secret = storage.Source.Secret + default: + return nil, fmt.Errorf("unsupported storage type %s at index %d", storage.Source.Type, i) } - volumeMounts = append(volumeMounts, corev1.VolumeMount{ - Name: volumeName, - MountPath: mountPath, - ReadOnly: true, - }) - volumes = append(volumes, corev1.Volume{ - Name: volumeName, - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: *mcpServer.Spec.ConfigMapRef, - }, - }, - }) + + volumes = append(volumes, volume) } container.VolumeMounts = volumeMounts @@ -406,18 +433,19 @@ func (r *MCPServerReconciler) createDeployment(ctx context.Context, mcpServer *m Labels: labels, }, Spec: corev1.PodSpec{ - ServiceAccountName: serviceAccountName(mcpServer.Spec.ServiceAccountName), - Containers: []corev1.Container{container}, - Volumes: volumes, + Containers: []corev1.Container{container}, + Volumes: volumes, }, }, }, } - // Apply pod security context if specified - if mcpServer.Spec.PodSecurityContext != nil { - deployment.Spec.Template.Spec.SecurityContext = mcpServer.Spec.PodSecurityContext + // Add security settings if specified + // Only set ServiceAccountName if non-empty; otherwise leave unset for Kubernetes to default + if mcpServer.Spec.Runtime.Security.ServiceAccountName != "" { + deployment.Spec.Template.Spec.ServiceAccountName = mcpServer.Spec.Runtime.Security.ServiceAccountName } + deployment.Spec.Template.Spec.SecurityContext = mcpServer.Spec.Runtime.Security.PodSecurityContext return deployment, nil } @@ -474,7 +502,7 @@ func (r *MCPServerReconciler) createService(mcpServer *mcpv1alpha1.MCPServer) *c Ports: []corev1.ServicePort{ { Name: "mcp", - Port: mcpServer.Spec.Port, + Port: mcpServer.Spec.Config.Port, TargetPort: intstr.FromString("mcp"), Protocol: corev1.ProtocolTCP, }, @@ -498,14 +526,6 @@ func (r *MCPServerReconciler) updateStatusFailed(ctx context.Context, mcpServer _ = r.Status().Update(ctx, mcpServer) } -// serviceAccountName returns the given name, defaulting to "default" if empty. -func serviceAccountName(name string) string { - if name == "" { - return "default" - } - return name -} - // defaultContainerSecurityContext returns the "restricted" Pod Security Standard // security context applied to MCP server containers by default. func defaultContainerSecurityContext() *corev1.SecurityContext { diff --git a/internal/controller/mcpserver_controller_test.go b/internal/controller/mcpserver_controller_test.go index 147f5199..e33ed065 100644 --- a/internal/controller/mcpserver_controller_test.go +++ b/internal/controller/mcpserver_controller_test.go @@ -59,8 +59,15 @@ var _ = Describe("MCPServer Controller", func() { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + }, }, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) @@ -109,11 +116,18 @@ var _ = Describe("MCPServer Controller", func() { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, - Env: []corev1.EnvVar{ - {Name: "TOKEN", Value: "test-token"}, - {Name: "LOG_LEVEL", Value: "debug"}, + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + Env: []corev1.EnvVar{ + {Name: "TOKEN", Value: "test-token"}, + {Name: "LOG_LEVEL", Value: "debug"}, + }, }, }, } @@ -169,7 +183,7 @@ var _ = Describe("MCPServer Controller", func() { mcpServer := &mcpv1alpha1.MCPServer{} err = k8sClient.Get(ctx, typeNamespacedName, mcpServer) Expect(err).NotTo(HaveOccurred()) - mcpServer.Spec.Env = []corev1.EnvVar{ + mcpServer.Spec.Config.Env = []corev1.EnvVar{ {Name: "TOKEN", Value: "new-token"}, {Name: "NEW_VAR", Value: "new-value"}, } @@ -220,9 +234,16 @@ var _ = Describe("MCPServer Controller", func() { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, - Args: []string{"--verbose", "--port=8080"}, + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + Arguments: []string{"--verbose", "--port=8080"}, + }, }, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) @@ -249,7 +270,7 @@ var _ = Describe("MCPServer Controller", func() { By("Removing args from the MCPServer") mcpServer := &mcpv1alpha1.MCPServer{} Expect(k8sClient.Get(ctx, typeNamespacedName, mcpServer)).To(Succeed()) - mcpServer.Spec.Args = nil + mcpServer.Spec.Config.Arguments = nil Expect(k8sClient.Update(ctx, mcpServer)).To(Succeed()) By("Reconciling again to pick up the removal") @@ -292,9 +313,20 @@ var _ = Describe("MCPServer Controller", func() { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, - ServiceAccountName: "my-sa", + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + }, + Runtime: mcpv1alpha1.RuntimeConfig{ + Security: mcpv1alpha1.SecurityConfig{ + ServiceAccountName: "my-sa", + }, + }, }, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) @@ -321,7 +353,9 @@ var _ = Describe("MCPServer Controller", func() { By("Removing serviceAccountName from the MCPServer") mcpServer := &mcpv1alpha1.MCPServer{} Expect(k8sClient.Get(ctx, typeNamespacedName, mcpServer)).To(Succeed()) - mcpServer.Spec.ServiceAccountName = "" + // Remove the entire Runtime config to avoid MinProperties validation error + // since RuntimeConfig only had Security set, which only had ServiceAccountName + mcpServer.Spec.Runtime = mcpv1alpha1.RuntimeConfig{} Expect(k8sClient.Update(ctx, mcpServer)).To(Succeed()) By("Reconciling again to pick up the removal") @@ -335,7 +369,8 @@ var _ = Describe("MCPServer Controller", func() { Namespace: "default", }, deployment) Expect(err).NotTo(HaveOccurred()) - Expect(deployment.Spec.Template.Spec.ServiceAccountName).To(Equal("default")) + // When serviceAccountName is removed, we don't set it - let Kubernetes default it + Expect(deployment.Spec.Template.Spec.ServiceAccountName).To(BeEmpty()) }) }) @@ -366,11 +401,22 @@ var _ = Describe("MCPServer Controller", func() { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, - SecurityContext: &corev1.SecurityContext{ - RunAsUser: &runAsUser, - RunAsGroup: &runAsGroup, + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + }, + Runtime: mcpv1alpha1.RuntimeConfig{ + Security: mcpv1alpha1.SecurityConfig{ + SecurityContext: &corev1.SecurityContext{ + RunAsUser: &runAsUser, + RunAsGroup: &runAsGroup, + }, + }, }, }, } @@ -407,11 +453,22 @@ var _ = Describe("MCPServer Controller", func() { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, - PodSecurityContext: &corev1.PodSecurityContext{ - RunAsUser: &runAsUser, - FSGroup: &fsGroup, + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + }, + Runtime: mcpv1alpha1.RuntimeConfig{ + Security: mcpv1alpha1.SecurityConfig{ + PodSecurityContext: &corev1.PodSecurityContext{ + RunAsUser: &runAsUser, + FSGroup: &fsGroup, + }, + }, }, }, } @@ -449,14 +506,25 @@ var _ = Describe("MCPServer Controller", func() { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, - PodSecurityContext: &corev1.PodSecurityContext{ - RunAsUser: &runAsUser, - FSGroup: &fsGroup, + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, }, - SecurityContext: &corev1.SecurityContext{ - ReadOnlyRootFilesystem: &readOnly, + Runtime: mcpv1alpha1.RuntimeConfig{ + Security: mcpv1alpha1.SecurityConfig{ + PodSecurityContext: &corev1.PodSecurityContext{ + RunAsUser: &runAsUser, + FSGroup: &fsGroup, + }, + SecurityContext: &corev1.SecurityContext{ + ReadOnlyRootFilesystem: &readOnly, + }, + }, }, }, } @@ -495,8 +563,15 @@ var _ = Describe("MCPServer Controller", func() { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + }, }, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) @@ -565,9 +640,18 @@ var _ = Describe("MCPServer Controller", func() { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, - Replicas: ptr.To(int32(3)), + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + }, + Runtime: mcpv1alpha1.RuntimeConfig{ + Replicas: ptr.To(int32(3)), + }, }, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) @@ -597,8 +681,15 @@ var _ = Describe("MCPServer Controller", func() { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + }, }, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) @@ -621,6 +712,47 @@ var _ = Describe("MCPServer Controller", func() { Expect(*deployment.Spec.Replicas).To(Equal(int32(1))) }) + It("should allow 0 replicas for scale-to-zero", func() { + resource := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + }, + Runtime: mcpv1alpha1.RuntimeConfig{ + Replicas: ptr.To(int32(0)), + }, + }, + } + 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(0))) + }) + It("should update deployment when replicas changes", func() { resource := &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ @@ -628,9 +760,18 @@ var _ = Describe("MCPServer Controller", func() { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, - Replicas: ptr.To(int32(2)), + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + }, + Runtime: mcpv1alpha1.RuntimeConfig{ + Replicas: ptr.To(int32(2)), + }, }, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) @@ -657,7 +798,7 @@ var _ = Describe("MCPServer Controller", func() { By("Updating replicas to 5") mcpServer := &mcpv1alpha1.MCPServer{} Expect(k8sClient.Get(ctx, typeNamespacedName, mcpServer)).To(Succeed()) - mcpServer.Spec.Replicas = ptr.To(int32(5)) + mcpServer.Spec.Runtime.Replicas = ptr.To(int32(5)) Expect(k8sClient.Update(ctx, mcpServer)).To(Succeed()) By("Reconciling again to pick up the change") @@ -681,9 +822,18 @@ var _ = Describe("MCPServer Controller", func() { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, - Replicas: ptr.To(int32(3)), + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + }, + Runtime: mcpv1alpha1.RuntimeConfig{ + Replicas: ptr.To(int32(3)), + }, }, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) @@ -710,7 +860,9 @@ var _ = Describe("MCPServer Controller", func() { By("Removing replicas from the MCPServer") mcpServer := &mcpv1alpha1.MCPServer{} Expect(k8sClient.Get(ctx, typeNamespacedName, mcpServer)).To(Succeed()) - mcpServer.Spec.Replicas = nil + // Remove the entire Runtime config to avoid MinProperties validation error + // since RuntimeConfig only had Replicas set + mcpServer.Spec.Runtime = mcpv1alpha1.RuntimeConfig{} Expect(k8sClient.Update(ctx, mcpServer)).To(Succeed()) By("Reconciling again to pick up the removal") @@ -745,17 +897,24 @@ var _ = Describe("MCPServer Controller", func() { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, - EnvFrom: []corev1.EnvFromSource{ - { - SecretRef: &corev1.SecretEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-secret"}, - }, + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", }, - { - ConfigMapRef: &corev1.ConfigMapEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: "my-configmap"}, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + EnvFrom: []corev1.EnvFromSource{ + { + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-secret"}, + }, + }, + { + ConfigMapRef: &corev1.ConfigMapEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: "my-configmap"}, + }, }, }, }, @@ -805,7 +964,7 @@ var _ = Describe("MCPServer Controller", func() { mcpServer := &mcpv1alpha1.MCPServer{} err := k8sClient.Get(ctx, typeNamespacedName, mcpServer) Expect(err).NotTo(HaveOccurred()) - mcpServer.Spec.Env = []corev1.EnvVar{ + mcpServer.Spec.Config.Env = []corev1.EnvVar{ {Name: "EXTRA_VAR", Value: "extra-value"}, } Expect(k8sClient.Update(ctx, mcpServer)).To(Succeed()) @@ -836,9 +995,11 @@ var _ = Describe("MCPServer Controller", func() { }) }) - Context("When reconciling a resource with secretRef", func() { - const resourceName = "test-resource-secret" - const secretName = "my-secret" +}) + +var _ = Describe("MCPServer Controller - Address URL", func() { + Context("When reconciling a resource", func() { + const resourceName = "test-address" ctx := context.Background() @@ -847,93 +1008,30 @@ var _ = Describe("MCPServer Controller", func() { Namespace: "default", } - BeforeEach(func() { - By("creating the secret referenced by SecretRef") - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: "default", - }, - Data: map[string][]byte{ - "token": []byte("test-token-value"), - "ca.pem": []byte("test-ca-cert-data"), - }, - } - Expect(client.IgnoreAlreadyExists(k8sClient.Create(ctx, secret))).To(Succeed()) - }) - AfterEach(func() { resource := &mcpv1alpha1.MCPServer{} err := k8sClient.Get(ctx, typeNamespacedName, resource) - Expect(err).NotTo(HaveOccurred()) - Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) - - secret := &corev1.Secret{} - err = k8sClient.Get(ctx, client.ObjectKey{Name: secretName, Namespace: "default"}, secret) if err == nil { - Expect(k8sClient.Delete(ctx, secret)).To(Succeed()) + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) } }) - It("should mount the secret volume with default name and path", func() { + It("should set the address URL with default path after reconciliation", func() { resource := &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: resourceName, Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, - SecretRef: &corev1.LocalObjectReference{ - Name: "my-secret", + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, }, - }, - } - 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()) - - volumes := deployment.Spec.Template.Spec.Volumes - Expect(volumes).To(HaveLen(1)) - Expect(volumes[0].Name).To(Equal("mcp-secrets")) - Expect(volumes[0].Secret).NotTo(BeNil()) - Expect(volumes[0].Secret.SecretName).To(Equal("my-secret")) - - mounts := deployment.Spec.Template.Spec.Containers[0].VolumeMounts - Expect(mounts).To(HaveLen(1)) - Expect(mounts[0].Name).To(Equal("mcp-secrets")) - Expect(mounts[0].MountPath).To(Equal("/etc/mcp-secrets")) - Expect(mounts[0].ReadOnly).To(BeTrue()) - }) - - It("should use custom volume name and mount path when specified", func() { - resource := &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: resourceName, - Namespace: "default", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, - SecretRef: &corev1.LocalObjectReference{ - Name: "my-secret", + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, }, - SecretVolumeName: "custom-vol", - SecretMountPath: "/custom/path", }, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) @@ -947,38 +1045,28 @@ var _ = Describe("MCPServer Controller", func() { }) Expect(err).NotTo(HaveOccurred()) - deployment := &appsv1.Deployment{} - err = k8sClient.Get(ctx, client.ObjectKey{ - Name: resourceName, - Namespace: "default", - }, deployment) - Expect(err).NotTo(HaveOccurred()) - - volumes := deployment.Spec.Template.Spec.Volumes - Expect(volumes).To(HaveLen(1)) - Expect(volumes[0].Name).To(Equal("custom-vol")) - Expect(volumes[0].Secret.SecretName).To(Equal("my-secret")) - - mounts := deployment.Spec.Template.Spec.Containers[0].VolumeMounts - Expect(mounts).To(HaveLen(1)) - Expect(mounts[0].Name).To(Equal("custom-vol")) - Expect(mounts[0].MountPath).To(Equal("/custom/path")) + mcpServer := &mcpv1alpha1.MCPServer{} + Expect(k8sClient.Get(ctx, typeNamespacedName, mcpServer)).To(Succeed()) + Expect(mcpServer.Status.Address).NotTo(BeNil()) + Expect(mcpServer.Status.Address.URL).To(Equal("http://test-address.default.svc.cluster.local:8080/mcp")) }) - It("should set SubPath on the volume mount when secretKey is specified", func() { + It("should use the correct port in the address URL", func() { resource := &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: resourceName, Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, - SecretRef: &corev1.LocalObjectReference{ - Name: "my-secret", + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 3001, }, - SecretKey: "ca.pem", - SecretMountPath: "/app/certs/ca.pem", }, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) @@ -992,38 +1080,28 @@ var _ = Describe("MCPServer Controller", func() { }) Expect(err).NotTo(HaveOccurred()) - deployment := &appsv1.Deployment{} - err = k8sClient.Get(ctx, client.ObjectKey{ - Name: resourceName, - Namespace: "default", - }, deployment) - Expect(err).NotTo(HaveOccurred()) - - volumes := deployment.Spec.Template.Spec.Volumes - Expect(volumes).To(HaveLen(1)) - Expect(volumes[0].Name).To(Equal("mcp-secrets")) - Expect(volumes[0].Secret).NotTo(BeNil()) - Expect(volumes[0].Secret.SecretName).To(Equal("my-secret")) - - mounts := deployment.Spec.Template.Spec.Containers[0].VolumeMounts - Expect(mounts).To(HaveLen(1)) - Expect(mounts[0].Name).To(Equal("mcp-secrets")) - Expect(mounts[0].MountPath).To(Equal("/app/certs/ca.pem")) - Expect(mounts[0].SubPath).To(Equal("ca.pem")) - Expect(mounts[0].ReadOnly).To(BeTrue()) + mcpServer := &mcpv1alpha1.MCPServer{} + Expect(k8sClient.Get(ctx, typeNamespacedName, mcpServer)).To(Succeed()) + Expect(mcpServer.Status.Address).NotTo(BeNil()) + Expect(mcpServer.Status.Address.URL).To(Equal("http://test-address.default.svc.cluster.local:3001/mcp")) }) - It("should not set SubPath when secretKey is not specified", func() { + It("should use custom path in the address URL when specified", func() { resource := &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: resourceName, Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, - SecretRef: &corev1.LocalObjectReference{ - Name: "my-secret", + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + Path: "/sse", }, }, } @@ -1038,32 +1116,27 @@ var _ = Describe("MCPServer Controller", func() { }) Expect(err).NotTo(HaveOccurred()) - deployment := &appsv1.Deployment{} - err = k8sClient.Get(ctx, client.ObjectKey{ - Name: resourceName, - Namespace: "default", - }, deployment) - Expect(err).NotTo(HaveOccurred()) - - mounts := deployment.Spec.Template.Spec.Containers[0].VolumeMounts - Expect(mounts).To(HaveLen(1)) - Expect(mounts[0].SubPath).To(BeEmpty()) + mcpServer := &mcpv1alpha1.MCPServer{} + Expect(k8sClient.Get(ctx, typeNamespacedName, mcpServer)).To(Succeed()) + Expect(mcpServer.Status.Address).NotTo(BeNil()) + Expect(mcpServer.Status.Address.URL).To(Equal("http://test-address.default.svc.cluster.local:8080/sse")) }) - It("should mount both secret and configmap volumes together", func() { + It("should persist the address URL across reconciliations", func() { resource := &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: resourceName, Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, - SecretRef: &corev1.LocalObjectReference{ - Name: "my-secret", + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, }, - ConfigMapRef: &corev1.LocalObjectReference{ - Name: "my-config", + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, }, }, } @@ -1073,275 +1146,74 @@ var _ = Describe("MCPServer Controller", func() { 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) + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) Expect(err).NotTo(HaveOccurred()) - volumes := deployment.Spec.Template.Spec.Volumes - Expect(volumes).To(HaveLen(2)) - - mounts := deployment.Spec.Template.Spec.Containers[0].VolumeMounts - Expect(mounts).To(HaveLen(2)) - - Expect(volumes).To(ContainElement(SatisfyAll( - HaveField("Name", "mcp-secrets"), - HaveField("VolumeSource.Secret.SecretName", "my-secret"), - ))) - Expect(volumes).To(ContainElement(SatisfyAll( - HaveField("Name", "mcp-config"), - HaveField("VolumeSource.ConfigMap.Name", "my-config"), - ))) - - Expect(mounts).To(ContainElement(SatisfyAll( - HaveField("Name", "mcp-secrets"), - HaveField("MountPath", "/etc/mcp-secrets"), - HaveField("ReadOnly", true), - ))) - Expect(mounts).To(ContainElement(SatisfyAll( - HaveField("Name", "mcp-config"), - HaveField("MountPath", "/etc/mcp-config"), - HaveField("ReadOnly", true), - ))) + mcpServer := &mcpv1alpha1.MCPServer{} + Expect(k8sClient.Get(ctx, typeNamespacedName, mcpServer)).To(Succeed()) + Expect(mcpServer.Status.Address).NotTo(BeNil()) + Expect(mcpServer.Status.Address.URL).To(Equal("http://test-address.default.svc.cluster.local:8080/mcp")) }) }) +}) - Context("When reconciling a resource with a non-existent secret", func() { - const resourceName = "test-resource-missing-secret" +var _ = Describe("Phase Constants", func() { + It("should define expected phase values", func() { + Expect(PhasePending).To(Equal("Pending")) + Expect(PhaseRunning).To(Equal("Running")) + Expect(PhaseFailed).To(Equal("Failed")) + }) +}) - ctx := context.Background() +var _ = Describe("MCPServer Controller - reconcileDeployment", func() { + const resourceName = "test-reconcile-deployment" - typeNamespacedName := types.NamespacedName{ - Name: resourceName, - Namespace: "default", - } + ctx := context.Background() - AfterEach(func() { - resource := &mcpv1alpha1.MCPServer{} - err := k8sClient.Get(ctx, typeNamespacedName, resource) - if err == nil { - Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) - } - }) + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } - It("should return a not found error when the referenced secret does not exist", func() { - resource := &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: resourceName, - Namespace: "default", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, - SecretRef: &corev1.LocalObjectReference{ - Name: "nonexistent-secret", + BeforeEach(func() { + resource := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", }, }, - } - Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + Config: mcpv1alpha1.ServerConfig{ + 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).To(HaveOccurred()) - Expect(errors.IsNotFound(err)).To(BeTrue()) - }) + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) }) -}) -var _ = Describe("MCPServer Controller - Address URL", func() { - Context("When reconciling a resource", func() { - const resourceName = "test-address" - - 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 the address URL with default path after reconciliation", 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()) - - mcpServer := &mcpv1alpha1.MCPServer{} - Expect(k8sClient.Get(ctx, typeNamespacedName, mcpServer)).To(Succeed()) - Expect(mcpServer.Status.Address).NotTo(BeNil()) - Expect(mcpServer.Status.Address.URL).To(Equal("http://test-address.default.svc.cluster.local:8080/mcp")) - }) - - It("should use the correct port in the address URL", func() { - resource := &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: resourceName, - Namespace: "default", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 3001, - }, - } - 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()) - - mcpServer := &mcpv1alpha1.MCPServer{} - Expect(k8sClient.Get(ctx, typeNamespacedName, mcpServer)).To(Succeed()) - Expect(mcpServer.Status.Address).NotTo(BeNil()) - Expect(mcpServer.Status.Address.URL).To(Equal("http://test-address.default.svc.cluster.local:3001/mcp")) - }) - - It("should use custom path in the address URL when specified", func() { - resource := &mcpv1alpha1.MCPServer{ - ObjectMeta: metav1.ObjectMeta{ - Name: resourceName, - Namespace: "default", - }, - Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, - Path: "/sse", - }, - } - 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()) - - mcpServer := &mcpv1alpha1.MCPServer{} - Expect(k8sClient.Get(ctx, typeNamespacedName, mcpServer)).To(Succeed()) - Expect(mcpServer.Status.Address).NotTo(BeNil()) - Expect(mcpServer.Status.Address.URL).To(Equal("http://test-address.default.svc.cluster.local:8080/sse")) - }) - - It("should persist the address URL across reconciliations", 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()) - - _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, - }) - Expect(err).NotTo(HaveOccurred()) - - mcpServer := &mcpv1alpha1.MCPServer{} - Expect(k8sClient.Get(ctx, typeNamespacedName, mcpServer)).To(Succeed()) - Expect(mcpServer.Status.Address).NotTo(BeNil()) - Expect(mcpServer.Status.Address.URL).To(Equal("http://test-address.default.svc.cluster.local:8080/mcp")) - }) - }) -}) - -var _ = Describe("Phase Constants", func() { - It("should define expected phase values", func() { - Expect(PhasePending).To(Equal("Pending")) - Expect(PhaseRunning).To(Equal("Running")) - Expect(PhaseFailed).To(Equal("Failed")) - }) -}) - -var _ = Describe("MCPServer Controller - reconcileDeployment", func() { - const resourceName = "test-reconcile-deployment" - - ctx := context.Background() - - typeNamespacedName := types.NamespacedName{ - Name: resourceName, - Namespace: "default", - } - - BeforeEach(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()) - }) - - AfterEach(func() { - resource := &mcpv1alpha1.MCPServer{} - err := k8sClient.Get(ctx, typeNamespacedName, resource) - Expect(err).NotTo(HaveOccurred()) - Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) - }) - - It("should create a deployment when none exists", func() { - mcpServer := &mcpv1alpha1.MCPServer{} - Expect(k8sClient.Get(ctx, typeNamespacedName, mcpServer)).To(Succeed()) + It("should create a deployment when none exists", func() { + mcpServer := &mcpv1alpha1.MCPServer{} + Expect(k8sClient.Get(ctx, typeNamespacedName, mcpServer)).To(Succeed()) reconciler := &MCPServerReconciler{ Client: k8sClient, @@ -1389,8 +1261,15 @@ var _ = Describe("MCPServer Controller - reconcileService", func() { Namespace: "default", }, Spec: mcpv1alpha1.MCPServerSpec{ - Image: "test-image:latest", - Port: 8080, + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + }, }, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) @@ -1503,3 +1382,859 @@ var _ = Describe("determinePhase", func() { Expect(condition.Reason).To(Equal("DeploymentProgressing")) }) }) + +var _ = Describe("MCPServer Controller - Storage Mounts", func() { + ctx := context.Background() + + Context("When reconciling a resource with ConfigMap storage", func() { + const resourceName = "test-resource-configmap-storage" + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + + BeforeEach(func() { + // Create ConfigMap first + configMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-configmap", + Namespace: "default", + }, + Data: map[string]string{ + "config.yaml": "test: value", + }, + } + Expect(k8sClient.Create(ctx, configMap)).To(Succeed()) + + resource := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + Storage: []mcpv1alpha1.StorageMount{ + { + Path: "/etc/config", + Source: mcpv1alpha1.StorageSource{ + Type: mcpv1alpha1.StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-configmap", + }, + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + }) + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + configMap := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: "test-configmap", Namespace: "default"}, configMap) + if err == nil { + Expect(k8sClient.Delete(ctx, configMap)).To(Succeed()) + } + }) + + It("should create deployment with ConfigMap volume and mount", func() { + 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()) + + // Verify volume is created with auto-generated name + Expect(deployment.Spec.Template.Spec.Volumes).To(HaveLen(1)) + volume := deployment.Spec.Template.Spec.Volumes[0] + Expect(volume.Name).To(Equal("vol-0")) + Expect(volume.VolumeSource.ConfigMap).NotTo(BeNil()) + Expect(volume.VolumeSource.ConfigMap.Name).To(Equal("test-configmap")) + + // Verify volume mount is created + container := deployment.Spec.Template.Spec.Containers[0] + Expect(container.VolumeMounts).To(HaveLen(1)) + volumeMount := container.VolumeMounts[0] + Expect(volumeMount.Name).To(Equal("vol-0")) + Expect(volumeMount.MountPath).To(Equal("/etc/config")) + Expect(volumeMount.ReadOnly).To(BeTrue()) // Default is true + }) + }) + + Context("When reconciling a resource with Secret storage", func() { + const resourceName = "test-resource-secret-storage" + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + + BeforeEach(func() { + // Create Secret first + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "default", + }, + StringData: map[string]string{ + "token": "secret-value", + }, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + + resource := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + Storage: []mcpv1alpha1.StorageMount{ + { + Path: "/etc/secret", + Source: mcpv1alpha1.StorageSource{ + Type: mcpv1alpha1.StorageTypeSecret, + Secret: &corev1.SecretVolumeSource{ + SecretName: "test-secret", + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + }) + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + secret := &corev1.Secret{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: "test-secret", Namespace: "default"}, secret) + if err == nil { + Expect(k8sClient.Delete(ctx, secret)).To(Succeed()) + } + }) + + It("should create deployment with Secret volume and mount", func() { + 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()) + + // Verify volume is created with auto-generated name + Expect(deployment.Spec.Template.Spec.Volumes).To(HaveLen(1)) + volume := deployment.Spec.Template.Spec.Volumes[0] + Expect(volume.Name).To(Equal("vol-0")) + Expect(volume.VolumeSource.Secret).NotTo(BeNil()) + Expect(volume.VolumeSource.Secret.SecretName).To(Equal("test-secret")) + + // Verify volume mount is created + container := deployment.Spec.Template.Spec.Containers[0] + Expect(container.VolumeMounts).To(HaveLen(1)) + volumeMount := container.VolumeMounts[0] + Expect(volumeMount.Name).To(Equal("vol-0")) + Expect(volumeMount.MountPath).To(Equal("/etc/secret")) + Expect(volumeMount.ReadOnly).To(BeTrue()) // Default is true + }) + }) + + Context("When reconciling a resource with multiple storage mounts", func() { + const resourceName = "test-resource-multi-storage" + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + + BeforeEach(func() { + // Create ConfigMap + configMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multi-configmap", + Namespace: "default", + }, + Data: map[string]string{ + "config.yaml": "test: value", + }, + } + Expect(k8sClient.Create(ctx, configMap)).To(Succeed()) + + // Create Secret + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-multi-secret", + Namespace: "default", + }, + StringData: map[string]string{ + "token": "secret-value", + }, + } + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + + resource := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + Storage: []mcpv1alpha1.StorageMount{ + { + Path: "/etc/config", + Source: mcpv1alpha1.StorageSource{ + Type: mcpv1alpha1.StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-multi-configmap", + }, + }, + }, + }, + { + Path: "/etc/secret", + Source: mcpv1alpha1.StorageSource{ + Type: mcpv1alpha1.StorageTypeSecret, + Secret: &corev1.SecretVolumeSource{ + SecretName: "test-multi-secret", + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + }) + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + configMap := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: "test-multi-configmap", Namespace: "default"}, configMap) + if err == nil { + Expect(k8sClient.Delete(ctx, configMap)).To(Succeed()) + } + secret := &corev1.Secret{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: "test-multi-secret", Namespace: "default"}, secret) + if err == nil { + Expect(k8sClient.Delete(ctx, secret)).To(Succeed()) + } + }) + + It("should create deployment with multiple volumes and mounts with correct names", func() { + 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()) + + // Verify both volumes are created with auto-generated names + Expect(deployment.Spec.Template.Spec.Volumes).To(HaveLen(2)) + + volume0 := deployment.Spec.Template.Spec.Volumes[0] + Expect(volume0.Name).To(Equal("vol-0")) + Expect(volume0.VolumeSource.ConfigMap).NotTo(BeNil()) + Expect(volume0.VolumeSource.ConfigMap.Name).To(Equal("test-multi-configmap")) + + volume1 := deployment.Spec.Template.Spec.Volumes[1] + Expect(volume1.Name).To(Equal("vol-1")) + Expect(volume1.VolumeSource.Secret).NotTo(BeNil()) + Expect(volume1.VolumeSource.Secret.SecretName).To(Equal("test-multi-secret")) + + // Verify both volume mounts are created + container := deployment.Spec.Template.Spec.Containers[0] + Expect(container.VolumeMounts).To(HaveLen(2)) + + volumeMount0 := container.VolumeMounts[0] + Expect(volumeMount0.Name).To(Equal("vol-0")) + Expect(volumeMount0.MountPath).To(Equal("/etc/config")) + Expect(volumeMount0.ReadOnly).To(BeTrue()) + + volumeMount1 := container.VolumeMounts[1] + Expect(volumeMount1.Name).To(Equal("vol-1")) + Expect(volumeMount1.MountPath).To(Equal("/etc/secret")) + Expect(volumeMount1.ReadOnly).To(BeTrue()) + }) + }) + + Context("When reconciling a resource with readOnly set to false", func() { + const resourceName = "test-resource-readonly-false" + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + + BeforeEach(func() { + // Create ConfigMap + configMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-configmap-rw", + Namespace: "default", + }, + Data: map[string]string{ + "config.yaml": "test: value", + }, + } + Expect(k8sClient.Create(ctx, configMap)).To(Succeed()) + + resource := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + Storage: []mcpv1alpha1.StorageMount{ + { + Path: "/etc/config", + Permissions: mcpv1alpha1.MountPermissionsReadWrite, // Explicitly set to read-write + Source: mcpv1alpha1.StorageSource{ + Type: mcpv1alpha1.StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "test-configmap-rw", + }, + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + }) + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + configMap := &corev1.ConfigMap{} + err = k8sClient.Get(ctx, client.ObjectKey{Name: "test-configmap-rw", Namespace: "default"}, configMap) + if err == nil { + Expect(k8sClient.Delete(ctx, configMap)).To(Succeed()) + } + }) + + It("should create deployment with readOnly set to false", func() { + 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()) + + // Verify volume mount has ReadOnly set to false + container := deployment.Spec.Template.Spec.Containers[0] + Expect(container.VolumeMounts).To(HaveLen(1)) + volumeMount := container.VolumeMounts[0] + Expect(volumeMount.Name).To(Equal("vol-0")) + Expect(volumeMount.MountPath).To(Equal("/etc/config")) + Expect(volumeMount.ReadOnly).To(BeFalse()) // Explicitly false, not default + }) + }) + + Context("When ConfigMap reference doesn't exist", func() { + const resourceName = "test-resource-missing-configmap" + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + + BeforeEach(func() { + resource := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + Storage: []mcpv1alpha1.StorageMount{ + { + Path: "/etc/config", + Source: mcpv1alpha1.StorageSource{ + Type: mcpv1alpha1.StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "nonexistent-configmap", + }, + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + }) + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + }) + + It("should fail with 'ConfigMap not found' error", func() { + controllerReconciler := &MCPServerReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get ConfigMap")) + Expect(err.Error()).To(ContainSubstring("nonexistent-configmap")) + }) + }) + + Context("When Secret reference doesn't exist", func() { + const resourceName = "test-resource-missing-secret" + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + + BeforeEach(func() { + resource := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + Storage: []mcpv1alpha1.StorageMount{ + { + Path: "/etc/secret", + Source: mcpv1alpha1.StorageSource{ + Type: mcpv1alpha1.StorageTypeSecret, + Secret: &corev1.SecretVolumeSource{ + SecretName: "nonexistent-secret", + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + }) + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + }) + + It("should fail with 'Secret not found' error", func() { + controllerReconciler := &MCPServerReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get Secret")) + Expect(err.Error()).To(ContainSubstring("nonexistent-secret")) + }) + }) + + Context("When ConfigMap is optional and doesn't exist", func() { + const resourceName = "test-resource-optional-configmap" + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + + BeforeEach(func() { + // Don't create the ConfigMap - it should be optional + resource := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + Storage: []mcpv1alpha1.StorageMount{ + { + Path: "/etc/config", + Source: mcpv1alpha1.StorageSource{ + Type: mcpv1alpha1.StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "optional-configmap", + }, + Optional: ptr.To(true), + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + }) + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + }) + + It("should succeed reconciliation even when ConfigMap doesn't exist", func() { + controllerReconciler := &MCPServerReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + // Verify deployment was created + deployment := &appsv1.Deployment{} + err = k8sClient.Get(ctx, client.ObjectKey{ + Name: resourceName, + Namespace: "default", + }, deployment) + Expect(err).NotTo(HaveOccurred()) + + // Verify volume is created with optional ConfigMap reference + Expect(deployment.Spec.Template.Spec.Volumes).To(HaveLen(1)) + volume := deployment.Spec.Template.Spec.Volumes[0] + Expect(volume.VolumeSource.ConfigMap).NotTo(BeNil()) + Expect(volume.VolumeSource.ConfigMap.Name).To(Equal("optional-configmap")) + Expect(volume.VolumeSource.ConfigMap.Optional).NotTo(BeNil()) + Expect(*volume.VolumeSource.ConfigMap.Optional).To(BeTrue()) + }) + }) + + Context("When Secret is optional and doesn't exist", func() { + const resourceName = "test-resource-optional-secret" + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + + BeforeEach(func() { + // Don't create the Secret - it should be optional + resource := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + Storage: []mcpv1alpha1.StorageMount{ + { + Path: "/etc/secret", + Source: mcpv1alpha1.StorageSource{ + Type: mcpv1alpha1.StorageTypeSecret, + Secret: &corev1.SecretVolumeSource{ + SecretName: "optional-secret", + Optional: ptr.To(true), + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + }) + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + }) + + It("should succeed reconciliation even when Secret doesn't exist", func() { + controllerReconciler := &MCPServerReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + // Verify deployment was created + deployment := &appsv1.Deployment{} + err = k8sClient.Get(ctx, client.ObjectKey{ + Name: resourceName, + Namespace: "default", + }, deployment) + Expect(err).NotTo(HaveOccurred()) + + // Verify volume is created with optional Secret reference + Expect(deployment.Spec.Template.Spec.Volumes).To(HaveLen(1)) + volume := deployment.Spec.Template.Spec.Volumes[0] + Expect(volume.VolumeSource.Secret).NotTo(BeNil()) + Expect(volume.VolumeSource.Secret.SecretName).To(Equal("optional-secret")) + Expect(volume.VolumeSource.Secret.Optional).NotTo(BeNil()) + Expect(*volume.VolumeSource.Secret.Optional).To(BeTrue()) + }) + }) + + Context("When ConfigMap name is empty", func() { + const resourceName = "test-resource-empty-configmap-name" + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + + BeforeEach(func() { + resource := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + Storage: []mcpv1alpha1.StorageMount{ + { + Path: "/etc/config", + Source: mcpv1alpha1.StorageSource{ + Type: mcpv1alpha1.StorageTypeConfigMap, + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "", // Empty name + }, + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + }) + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + }) + + It("should fail with 'empty ConfigMap name' error", func() { + controllerReconciler := &MCPServerReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("configMap name must not be empty")) + }) + }) + + Context("When Secret name is empty", func() { + const resourceName = "test-resource-empty-secret-name" + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", + } + + BeforeEach(func() { + resource := &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Source: mcpv1alpha1.Source{ + Type: mcpv1alpha1.SourceTypeContainerImage, + ContainerImage: &mcpv1alpha1.ContainerImageSource{ + Ref: "docker.io/library/test-image:latest", + }, + }, + Config: mcpv1alpha1.ServerConfig{ + Port: 8080, + Storage: []mcpv1alpha1.StorageMount{ + { + Path: "/etc/secret", + Source: mcpv1alpha1.StorageSource{ + Type: mcpv1alpha1.StorageTypeSecret, + Secret: &corev1.SecretVolumeSource{ + SecretName: "", // Empty name + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + }) + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + }) + + It("should fail with 'empty Secret name' error", func() { + controllerReconciler := &MCPServerReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("secret name must not be empty")) + }) + }) +})