From 16a6d90b821c8f8d289b5bf5710de7afccd96350 Mon Sep 17 00:00:00 2001 From: Matthias Wessendorf Date: Mon, 23 Mar 2026 15:43:59 +0100 Subject: [PATCH] Add runAsNonRoot override for images that require root The new MCP lifecycle operator defaults to runAsNonRoot: true on Deployments, causing images like insights, satellite, and ansible to fail with CreateContainerConfigError. Add a RunAsRoot catalog field, a UI checkbox, and wire the securityContext override through Run(), QuickDeploy(), and buildYAMLPreview(). Signed-off-by: Matthias Wessendorf Co-Authored-By: Claude Opus 4.6 Signed-off-by: Matthias Wessendorf --- catalog/types.go | 2 + catalog/types_test.go | 9 +- deploy/catalog/ansible-mcp.yaml | 2 +- deploy/catalog/everything-mcp.yaml | 12 +- deploy/catalog/insights-mcp.yaml | 2 +- deploy/catalog/kubernetes-mcp.yaml | 23 ++- deploy/catalog/satellite-mcp.yaml | 1 + handlers/handlers.go | 244 ++++++++++++++++++++++------ handlers/handlers_test.go | 248 +++++++++++++++++++++-------- templates/configure.html | 18 ++- 10 files changed, 435 insertions(+), 126 deletions(-) diff --git a/catalog/types.go b/catalog/types.go index 930b057..a9d2f7e 100644 --- a/catalog/types.go +++ b/catalog/types.go @@ -69,6 +69,7 @@ type KubernetesExtensions struct { DefaultPort int32 `json:"defaultPort,omitempty"` NeedsServiceAccount bool `json:"needsServiceAccount,omitempty"` ServiceAccountHint string `json:"serviceAccountHint,omitempty"` + RunAsRoot bool `json:"runAsRoot,omitempty"` ConfigMaps []ConfigMap `json:"configMaps,omitempty"` SecretMounts []SecretMount `json:"secretMounts,omitempty"` CRTemplate map[string]any `json:"crTemplate,omitempty"` @@ -80,6 +81,7 @@ type ConfigMap struct { Description string `json:"description,omitempty"` DefaultContent string `json:"defaultContent,omitempty"` FileName string `json:"fileName,omitempty"` + MountPath string `json:"mountPath,omitempty"` IsRequired bool `json:"isRequired,omitempty"` } diff --git a/catalog/types_test.go b/catalog/types_test.go index f78942c..ab61133 100644 --- a/catalog/types_test.go +++ b/catalog/types_test.go @@ -75,7 +75,14 @@ func TestIsOneClick(t *testing.T) { Name: "test", Meta: &Meta{ K8s: &KubernetesExtensions{ - CRTemplate: map[string]any{"image": "test:latest"}, + CRTemplate: map[string]any{ + "source": map[string]any{ + "type": "ContainerImage", + "containerImage": map[string]any{ + "ref": "test:latest", + }, + }, + }, }, }, }, diff --git a/deploy/catalog/ansible-mcp.yaml b/deploy/catalog/ansible-mcp.yaml index bb16440..5050b9e 100644 --- a/deploy/catalog/ansible-mcp.yaml +++ b/deploy/catalog/ansible-mcp.yaml @@ -32,5 +32,5 @@ data: } ] }], - "_meta": { "io.openshift/k8s": { "defaultPort": 3000 } } + "_meta": { "io.openshift/k8s": { "defaultPort": 3000, "runAsRoot": true } } } diff --git a/deploy/catalog/everything-mcp.yaml b/deploy/catalog/everything-mcp.yaml index 405f0f3..edcd049 100644 --- a/deploy/catalog/everything-mcp.yaml +++ b/deploy/catalog/everything-mcp.yaml @@ -20,8 +20,16 @@ data: "_meta": { "io.openshift/k8s": { "defaultPort": 3001, "crTemplate": { - "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/deploy/catalog/insights-mcp.yaml b/deploy/catalog/insights-mcp.yaml index e70e17f..2da8967 100644 --- a/deploy/catalog/insights-mcp.yaml +++ b/deploy/catalog/insights-mcp.yaml @@ -37,5 +37,5 @@ data: } ] }], - "_meta": { "io.openshift/k8s": { "defaultPort": 8000 } } + "_meta": { "io.openshift/k8s": { "defaultPort": 8000, "runAsRoot": true } } } diff --git a/deploy/catalog/kubernetes-mcp.yaml b/deploy/catalog/kubernetes-mcp.yaml index fdf42e3..ab093fa 100644 --- a/deploy/catalog/kubernetes-mcp.yaml +++ b/deploy/catalog/kubernetes-mcp.yaml @@ -58,14 +58,27 @@ data: "label": "Server Configuration (config.toml)", "description": "TOML config file mounted at /etc/mcp-config/config.toml", "defaultContent": "# Kubernetes MCP Server Configuration\nlog_level = 5\nport = \"8080\"\nread_only = true\ntoolsets = [\"core\", \"config\"]\n", - "fileName": "config.toml" + "fileName": "config.toml", + "mountPath": "/etc/mcp-config" } ], "crTemplate": { - "image": "quay.io/containers/kubernetes_mcp_server:latest", - "port": 8080, - "args": ["--config", "/etc/mcp-config/config.toml"], - "serviceAccountName": "mcp-viewer" + "source": { + "type": "ContainerImage", + "containerImage": { + "ref": "quay.io/containers/kubernetes_mcp_server:latest" + } + }, + "config": { + "port": 8080, + "path": "/mcp", + "arguments": ["--config", "/etc/mcp-config/config.toml"] + }, + "runtime": { + "security": { + "serviceAccountName": "mcp-viewer" + } + } } } } } diff --git a/deploy/catalog/satellite-mcp.yaml b/deploy/catalog/satellite-mcp.yaml index 2517c16..eff9898 100644 --- a/deploy/catalog/satellite-mcp.yaml +++ b/deploy/catalog/satellite-mcp.yaml @@ -28,6 +28,7 @@ data: }], "_meta": { "io.openshift/k8s": { "defaultPort": 8080, + "runAsRoot": true, "secretMounts": [ { "secretKey": "ca.pem", diff --git a/handlers/handlers.go b/handlers/handlers.go index 44d0711..86949de 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -168,10 +168,20 @@ func (h *Handler) Run(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + configSection := map[string]any{ + "port": parsePort(port, defaultPort), + "path": "/mcp", + } spec := map[string]any{ - "image": image, - "port": parsePort(port, defaultPort), + "source": map[string]any{ + "type": "ContainerImage", + "containerImage": map[string]any{ + "ref": image, + }, + }, + "config": configSection, } + var storageEntries []any // Collect fixed args from packageArguments with a preset value var args []string @@ -238,9 +248,18 @@ func (h *Handler) Run(w http.ResponseWriter, r *http.Request) { name: fileSecretName, data: map[string]string{sm.SecretKey: fileContent}, }) - spec["secretRef"] = map[string]any{"name": fileSecretName} - spec["secretMountPath"] = sm.MountPath - spec["secretKey"] = sm.SecretKey + storageEntries = append(storageEntries, map[string]any{ + "path": sm.MountPath, + "source": map[string]any{ + "type": "Secret", + "secret": map[string]any{ + "secretName": fileSecretName, + "items": []any{ + map[string]any{"key": sm.SecretKey, "path": sm.SecretKey}, + }, + }, + }, + }) } } @@ -262,33 +281,67 @@ func (h *Handler) Run(w http.ResponseWriter, r *http.Request) { } if len(args) > 0 { - spec["args"] = args + configSection["arguments"] = args } if len(envVars) > 0 { - spec["env"] = envVars + configSection["env"] = envVars } sa := r.FormValue("service-account") + runAsRoot := r.FormValue("run-as-root") + runtimeSecurity := map[string]any{} if sa != "" { - spec["serviceAccountName"] = sa + runtimeSecurity["serviceAccountName"] = sa + } + if runAsRoot == "on" { + runtimeSecurity["securityContext"] = map[string]any{ + "runAsNonRoot": false, + } + } + if len(runtimeSecurity) > 0 { + spec["runtime"] = map[string]any{"security": runtimeSecurity} } // ConfigMap support: create from content or reference existing configMapRef := r.FormValue("configmap-ref") configMapContent := r.FormValue("configmap-content") if configMapRef != "" { - spec["configMapRef"] = map[string]any{"name": configMapRef} + mountPath := "/etc/mcp-config" + if entry.K8s() != nil && len(entry.K8s().ConfigMaps) > 0 && entry.K8s().ConfigMaps[0].MountPath != "" { + mountPath = entry.K8s().ConfigMaps[0].MountPath + } + storageEntries = append(storageEntries, map[string]any{ + "path": mountPath, + "source": map[string]any{ + "type": "ConfigMap", + "configMap": map[string]any{"name": configMapRef}, + }, + }) } else if configMapContent != "" && entry.K8s() != nil && len(entry.K8s().ConfigMaps) > 0 { cmName := instanceName + "-config" fileName := entry.K8s().ConfigMaps[0].FileName if fileName == "" { fileName = "config" } + mountPath := entry.K8s().ConfigMaps[0].MountPath + if mountPath == "" { + mountPath = "/etc/mcp-config" + } pendingConfigMaps = append(pendingConfigMaps, pendingConfigMap{ name: cmName, data: map[string]string{fileName: configMapContent}, }) - spec["configMapRef"] = map[string]any{"name": cmName} + storageEntries = append(storageEntries, map[string]any{ + "path": mountPath, + "source": map[string]any{ + "type": "ConfigMap", + "configMap": map[string]any{"name": cmName}, + }, + }) + } + + if len(storageEntries) > 0 { + configSection["storage"] = storageEntries } // Create the MCPServer CR first to get its UID for ownerReferences @@ -361,6 +414,23 @@ func (h *Handler) QuickDeploy(w http.ResponseWriter, r *http.Request) { instanceName := entry.Name ctx := r.Context() + // If the catalog entry requires root, inject securityContext override + if k8s := entry.K8s(); k8s != nil && k8s.RunAsRoot { + runtimeMap, _ := spec["runtime"].(map[string]any) + if runtimeMap == nil { + runtimeMap = map[string]any{} + spec["runtime"] = runtimeMap + } + securityMap, _ := runtimeMap["security"].(map[string]any) + if securityMap == nil { + securityMap = map[string]any{} + runtimeMap["security"] = securityMap + } + securityMap["securityContext"] = map[string]any{ + "runAsNonRoot": false, + } + } + // Pre-wire configMapRef names (resources created after the CR for ownerReferences) type pendingConfigMap struct { name string @@ -378,8 +448,26 @@ func (h *Handler) QuickDeploy(w http.ResponseWriter, r *http.Request) { if fileName == "" { fileName = "config" } + mountPath := cm.MountPath + if mountPath == "" { + mountPath = "/etc/mcp-config" + } pendingCMs = append(pendingCMs, pendingConfigMap{name: cmName, fileName: fileName, content: cm.DefaultContent}) - spec["configMapRef"] = map[string]any{"name": cmName} + // Navigate into spec.config.storage to append the ConfigMap entry + configMap, _ := spec["config"].(map[string]any) + if configMap == nil { + configMap = map[string]any{} + spec["config"] = configMap + } + storageSlice, _ := configMap["storage"].([]any) + storageSlice = append(storageSlice, map[string]any{ + "path": mountPath, + "source": map[string]any{ + "type": "ConfigMap", + "configMap": map[string]any{"name": cmName}, + }, + }) + configMap["storage"] = storageSlice } } @@ -444,8 +532,8 @@ func (h *Handler) Running(w http.ResponseWriter, r *http.Request) { if phase == "" { phase = "Pending" } - image, _, _ := unstructured.NestedString(item.Object, "spec", "image") - port, _, _ := unstructured.NestedInt64(item.Object, "spec", "port") + image, _, _ := unstructured.NestedString(item.Object, "spec", "source", "containerImage", "ref") + port, _, _ := unstructured.NestedInt64(item.Object, "spec", "config", "port") endpoint, _, _ := unstructured.NestedString(item.Object, "status", "address", "url") @@ -616,25 +704,17 @@ func buildYAMLPreview(r *http.Request, entry *catalog.ServerEntry, namespace str fmt.Fprintf(&b, " name: %s\n", instanceName) fmt.Fprintf(&b, " namespace: %s\n", ns) b.WriteString("spec:\n") - fmt.Fprintf(&b, " image: %s\n", image) - fmt.Fprintf(&b, " port: %s\n", port) - // Service account - sa := r.FormValue("service-account") - if sa != "" { - fmt.Fprintf(&b, " serviceAccountName: %s\n", sa) - } + // Source + b.WriteString(" source:\n") + b.WriteString(" type: ContainerImage\n") + b.WriteString(" containerImage:\n") + fmt.Fprintf(&b, " ref: %s\n", image) - // ConfigMap ref - configMapRef := r.FormValue("configmap-ref") - configMapContent := r.FormValue("configmap-content") - if configMapRef != "" { - b.WriteString(" configMapRef:\n") - fmt.Fprintf(&b, " name: %s\n", configMapRef) - } else if configMapContent != "" && entry.K8s() != nil && len(entry.K8s().ConfigMaps) > 0 { - b.WriteString(" configMapRef:\n") - fmt.Fprintf(&b, " name: %s-config\n", instanceName) - } + // Config + b.WriteString(" config:\n") + fmt.Fprintf(&b, " port: %s\n", port) + b.WriteString(" path: /mcp\n") // Args: fixed args from package + user-provided args var args []string @@ -659,23 +739,9 @@ func buildYAMLPreview(r *http.Request, entry *catalog.ServerEntry, namespace str } } if len(args) > 0 { - b.WriteString(" args:\n") + b.WriteString(" arguments:\n") for _, a := range args { - fmt.Fprintf(&b, " - %s\n", a) - } - } - - // Secret file mounts - if k8s := entry.K8s(); k8s != nil { - for _, sm := range k8s.SecretMounts { - content := r.FormValue("file-" + sm.SecretKey) - if content != "" { - secretName := instanceName + "-" + strings.TrimSuffix(sm.SecretKey, ".pem") + "-secret" - b.WriteString(" secretRef:\n") - fmt.Fprintf(&b, " name: %s\n", secretName) - fmt.Fprintf(&b, " secretMountPath: %s\n", sm.MountPath) - fmt.Fprintf(&b, " secretKey: %s\n", sm.SecretKey) - } + fmt.Fprintf(&b, " - %s\n", a) } } @@ -686,18 +752,92 @@ func buildYAMLPreview(r *http.Request, entry *catalog.ServerEntry, namespace str value := r.FormValue("env-" + ev.Name) if value != "" { if !hasEnvVars { - b.WriteString(" env:\n") + b.WriteString(" env:\n") hasEnvVars = true } credSecretName := instanceName + "-credentials" - fmt.Fprintf(&b, " - name: %s\n", ev.Name) - b.WriteString(" valueFrom:\n") - b.WriteString(" secretKeyRef:\n") - fmt.Fprintf(&b, " name: %s\n", credSecretName) - fmt.Fprintf(&b, " key: %s\n", sanitizeKey(ev.Name)) + fmt.Fprintf(&b, " - name: %s\n", ev.Name) + b.WriteString(" valueFrom:\n") + b.WriteString(" secretKeyRef:\n") + fmt.Fprintf(&b, " name: %s\n", credSecretName) + fmt.Fprintf(&b, " key: %s\n", sanitizeKey(ev.Name)) } } } + // Storage entries + var storageEntries []string + + // Secret file mounts + if k8s := entry.K8s(); k8s != nil { + for _, sm := range k8s.SecretMounts { + content := r.FormValue("file-" + sm.SecretKey) + if content != "" { + secretName := instanceName + "-" + strings.TrimSuffix(sm.SecretKey, ".pem") + "-secret" + var se strings.Builder + fmt.Fprintf(&se, " - path: %s\n", sm.MountPath) + se.WriteString(" source:\n") + se.WriteString(" type: Secret\n") + se.WriteString(" secret:\n") + fmt.Fprintf(&se, " secretName: %s\n", secretName) + se.WriteString(" items:\n") + fmt.Fprintf(&se, " - key: %s\n", sm.SecretKey) + fmt.Fprintf(&se, " path: %s\n", sm.SecretKey) + storageEntries = append(storageEntries, se.String()) + } + } + } + + // ConfigMap ref + configMapRef := r.FormValue("configmap-ref") + configMapContent := r.FormValue("configmap-content") + if configMapRef != "" { + mountPath := "/etc/mcp-config" + if entry.K8s() != nil && len(entry.K8s().ConfigMaps) > 0 && entry.K8s().ConfigMaps[0].MountPath != "" { + mountPath = entry.K8s().ConfigMaps[0].MountPath + } + var se strings.Builder + fmt.Fprintf(&se, " - path: %s\n", mountPath) + se.WriteString(" source:\n") + se.WriteString(" type: ConfigMap\n") + se.WriteString(" configMap:\n") + fmt.Fprintf(&se, " name: %s\n", configMapRef) + storageEntries = append(storageEntries, se.String()) + } else if configMapContent != "" && entry.K8s() != nil && len(entry.K8s().ConfigMaps) > 0 { + mountPath := entry.K8s().ConfigMaps[0].MountPath + if mountPath == "" { + mountPath = "/etc/mcp-config" + } + var se strings.Builder + fmt.Fprintf(&se, " - path: %s\n", mountPath) + se.WriteString(" source:\n") + se.WriteString(" type: ConfigMap\n") + se.WriteString(" configMap:\n") + fmt.Fprintf(&se, " name: %s-config\n", instanceName) + storageEntries = append(storageEntries, se.String()) + } + + if len(storageEntries) > 0 { + b.WriteString(" storage:\n") + for _, se := range storageEntries { + b.WriteString(se) + } + } + + // Runtime + sa := r.FormValue("service-account") + runAsRoot := r.FormValue("run-as-root") + if sa != "" || runAsRoot == "on" { + b.WriteString(" runtime:\n") + b.WriteString(" security:\n") + if sa != "" { + fmt.Fprintf(&b, " serviceAccountName: %s\n", sa) + } + if runAsRoot == "on" { + b.WriteString(" securityContext:\n") + b.WriteString(" runAsNonRoot: false\n") + } + } + return b.String() } diff --git a/handlers/handlers_test.go b/handlers/handlers_test.go index e045fba..8b7f887 100644 --- a/handlers/handlers_test.go +++ b/handlers/handlers_test.go @@ -97,8 +97,16 @@ func simpleEntry(name string) *catalog.ServerEntry { func oneClickEntry(name string) *catalog.ServerEntry { e := simpleEntry(name) e.Meta.K8s.CRTemplate = map[string]any{ - "image": "quay.io/test/" + name + ":latest", - "port": float64(3001), + "source": map[string]any{ + "type": "ContainerImage", + "containerImage": map[string]any{ + "ref": "quay.io/test/" + name + ":latest", + }, + }, + "config": map[string]any{ + "port": float64(3001), + "path": "/mcp", + }, } return e } @@ -188,8 +196,10 @@ func TestBuildYAMLPreview(t *testing.T) { "kind: MCPServer", "name: my-instance", "namespace: test-ns", - "image: quay.io/custom:v1", + "type: ContainerImage", + "ref: quay.io/custom:v1", "port: 9090", + "path: /mcp", "name: API_KEY", "key: api-key", } @@ -200,6 +210,43 @@ func TestBuildYAMLPreview(t *testing.T) { } } +func TestBuildYAMLPreviewRunAsRoot(t *testing.T) { + entry := &catalog.ServerEntry{ + Name: "root-server", + Packages: []catalog.Package{ + {Identifier: "quay.io/test/root:latest"}, + }, + Meta: &catalog.Meta{ + K8s: &catalog.KubernetesExtensions{ + DefaultPort: 8080, + }, + }, + } + + form := url.Values{} + form.Set("instance-name", "root-instance") + form.Set("namespace", "test-ns") + form.Set("image", "quay.io/test/root:latest") + form.Set("port", "8080") + form.Set("run-as-root", "on") + + r := httptest.NewRequest("GET", "/preview/root-server?"+form.Encode(), nil) + + yaml := buildYAMLPreview(r, entry, "default") + + checks := []string{ + "runtime:", + "security:", + "securityContext:", + "runAsNonRoot: false", + } + for _, check := range checks { + if !strings.Contains(yaml, check) { + t.Errorf("YAML preview missing %q\nGot:\n%s", check, yaml) + } + } +} + func TestBuildYAMLPreviewDefaults(t *testing.T) { entry := &catalog.ServerEntry{ Name: "test-server", @@ -220,7 +267,7 @@ func TestBuildYAMLPreviewDefaults(t *testing.T) { if !strings.Contains(yaml, "namespace: default") { t.Errorf("expected default namespace, got:\n%s", yaml) } - if !strings.Contains(yaml, "image: quay.io/test/server:latest") { + if !strings.Contains(yaml, "ref: quay.io/test/server:latest") { t.Errorf("expected default image from package, got:\n%s", yaml) } } @@ -337,7 +384,7 @@ func TestQuickDeploy(t *testing.T) { if err != nil { t.Fatalf("MCPServer not created: %v", err) } - image, _, _ := unstructured.NestedString(created.Object, "spec", "image") + image, _, _ := unstructured.NestedString(created.Object, "spec", "source", "containerImage", "ref") if image != "quay.io/test/quick-server:latest" { t.Errorf("MCPServer image = %q, want %q", image, "quay.io/test/quick-server:latest") } @@ -383,8 +430,15 @@ func TestRunning(t *testing.T) { "namespace": "default", }, "spec": map[string]any{ - "image": "quay.io/test/server:latest", - "port": int64(3001), + "source": map[string]any{ + "type": "ContainerImage", + "containerImage": map[string]any{ + "ref": "quay.io/test/server:latest", + }, + }, + "config": map[string]any{ + "port": int64(3001), + }, }, "status": map[string]any{ "phase": "Running", @@ -416,7 +470,12 @@ func TestRunning(t *testing.T) { "namespace": "default", }, "spec": map[string]any{ - "image": "quay.io/test/server:latest", + "source": map[string]any{ + "type": "ContainerImage", + "containerImage": map[string]any{ + "ref": "quay.io/test/server:latest", + }, + }, }, }, } @@ -447,7 +506,12 @@ func TestDelete(t *testing.T) { "namespace": "default", }, "spec": map[string]any{ - "image": "quay.io/test/server:latest", + "source": map[string]any{ + "type": "ContainerImage", + "containerImage": map[string]any{ + "ref": "quay.io/test/server:latest", + }, + }, }, }, } @@ -474,67 +538,127 @@ func TestDelete(t *testing.T) { } func TestRun(t *testing.T) { - entry := &catalog.ServerEntry{ - Name: "full-server", - Description: "Full test server", - Packages: []catalog.Package{ - { - Identifier: "quay.io/test/full:latest", - RegistryType: "oci", - Transport: catalog.Transport{Type: "sse"}, - EnvironmentVariables: []catalog.EnvironmentVariable{ - {Name: "API_KEY", IsSecret: true, IsRequired: true}, + t.Run("creates resource with env vars", func(t *testing.T) { + entry := &catalog.ServerEntry{ + Name: "full-server", + Description: "Full test server", + Packages: []catalog.Package{ + { + Identifier: "quay.io/test/full:latest", + RegistryType: "oci", + Transport: catalog.Transport{Type: "sse"}, + EnvironmentVariables: []catalog.EnvironmentVariable{ + {Name: "API_KEY", IsSecret: true, IsRequired: true}, + }, }, }, - }, - Meta: &catalog.Meta{ - K8s: &catalog.KubernetesExtensions{ - DefaultPort: 8080, + Meta: &catalog.Meta{ + K8s: &catalog.KubernetesExtensions{ + DefaultPort: 8080, + }, }, - }, - } + } - h := setupHandler(t, []*corev1.ConfigMap{ - makeConfigMap("full-server", "catalog-ns", entryJSON(entry), true), + h := setupHandler(t, []*corev1.ConfigMap{ + makeConfigMap("full-server", "catalog-ns", entryJSON(entry), true), + }) + + form := url.Values{} + form.Set("catalog-name", "full-server") + form.Set("instance-name", "my-instance") + form.Set("namespace", "default") + form.Set("image", "quay.io/test/full:latest") + form.Set("port", "8080") + form.Set("env-API_KEY", "my-secret-key") + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/run", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + h.Run(w, r) + + if w.Code != http.StatusSeeOther { + t.Fatalf("Run() status = %d, want %d\nbody: %s", w.Code, http.StatusSeeOther, w.Body.String()) + } + + // Verify the MCPServer CR was created + created, err := h.dynamicClient.Resource(mcpServerGVR).Namespace("default").Get( + context.Background(), "my-instance", metav1.GetOptions{}, + ) + if err != nil { + t.Fatalf("MCPServer not created: %v", err) + } + image, _, _ := unstructured.NestedString(created.Object, "spec", "source", "containerImage", "ref") + if image != "quay.io/test/full:latest" { + t.Errorf("MCPServer image = %q, want %q", image, "quay.io/test/full:latest") + } + + // Verify the Secret was created with env var + secret, err := h.clientset.CoreV1().Secrets("default").Get( + context.Background(), "my-instance-credentials", metav1.GetOptions{}, + ) + if err != nil { + t.Fatalf("Secret not created: %v", err) + } + if secret.StringData["api-key"] != "my-secret-key" { + t.Errorf("Secret key api-key = %q, want %q", secret.StringData["api-key"], "my-secret-key") + } }) - form := url.Values{} - form.Set("catalog-name", "full-server") - form.Set("instance-name", "my-instance") - form.Set("namespace", "default") - form.Set("image", "quay.io/test/full:latest") - form.Set("port", "8080") - form.Set("env-API_KEY", "my-secret-key") + t.Run("run-as-root sets securityContext", func(t *testing.T) { + entry := &catalog.ServerEntry{ + Name: "root-server", + Description: "Root test server", + Packages: []catalog.Package{ + { + Identifier: "quay.io/test/root:latest", + RegistryType: "oci", + Transport: catalog.Transport{Type: "sse"}, + }, + }, + Meta: &catalog.Meta{ + K8s: &catalog.KubernetesExtensions{ + DefaultPort: 8080, + }, + }, + } - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/run", strings.NewReader(form.Encode())) - r.Header.Set("Content-Type", "application/x-www-form-urlencoded") - h.Run(w, r) + h := setupHandler(t, []*corev1.ConfigMap{ + makeConfigMap("root-server", "catalog-ns", entryJSON(entry), true), + }) - if w.Code != http.StatusSeeOther { - t.Fatalf("Run() status = %d, want %d\nbody: %s", w.Code, http.StatusSeeOther, w.Body.String()) - } + form := url.Values{} + form.Set("catalog-name", "root-server") + form.Set("instance-name", "root-instance") + form.Set("namespace", "default") + form.Set("image", "quay.io/test/root:latest") + form.Set("port", "8080") + form.Set("run-as-root", "on") - // Verify the MCPServer CR was created - created, err := h.dynamicClient.Resource(mcpServerGVR).Namespace("default").Get( - context.Background(), "my-instance", metav1.GetOptions{}, - ) - if err != nil { - t.Fatalf("MCPServer not created: %v", err) - } - image, _, _ := unstructured.NestedString(created.Object, "spec", "image") - if image != "quay.io/test/full:latest" { - t.Errorf("MCPServer image = %q, want %q", image, "quay.io/test/full:latest") - } + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/run", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + h.Run(w, r) - // Verify the Secret was created with env var - secret, err := h.clientset.CoreV1().Secrets("default").Get( - context.Background(), "my-instance-credentials", metav1.GetOptions{}, - ) - if err != nil { - t.Fatalf("Secret not created: %v", err) - } - if secret.StringData["api-key"] != "my-secret-key" { - t.Errorf("Secret key api-key = %q, want %q", secret.StringData["api-key"], "my-secret-key") - } + if w.Code != http.StatusSeeOther { + t.Fatalf("Run() status = %d, want %d\nbody: %s", w.Code, http.StatusSeeOther, w.Body.String()) + } + + created, err := h.dynamicClient.Resource(mcpServerGVR).Namespace("default").Get( + context.Background(), "root-instance", metav1.GetOptions{}, + ) + if err != nil { + t.Fatalf("MCPServer not created: %v", err) + } + + runAsNonRoot, found, err := unstructured.NestedBool(created.Object, "spec", "runtime", "security", "securityContext", "runAsNonRoot") + if err != nil { + t.Fatalf("failed to read runAsNonRoot: %v", err) + } + if !found { + t.Fatal("spec.runtime.security.securityContext.runAsNonRoot not found") + } + if runAsNonRoot != false { + t.Errorf("runAsNonRoot = %v, want false", runAsNonRoot) + } + }) } diff --git a/templates/configure.html b/templates/configure.html index 9883ddd..77bbe9a 100644 --- a/templates/configure.html +++ b/templates/configure.html @@ -54,6 +54,15 @@

{{ .Server.DisplayTitle }}

{{ end }}{{ end }} +
Security
+
+ +

The operator defaults to runAsNonRoot. Check this if the image requires root.

+
+ {{ if .Server.K8s }}{{ if .Server.K8s.ConfigMaps }}
Configuration
{{ range .Server.K8s.ConfigMaps }} @@ -149,8 +158,13 @@

{{ .Server.DisplayTitle }}

name: {{ .Server.Name }} namespace: {{ .Namespace }} spec: - image: {{ if .Package }}{{ .Package.Identifier }}{{ end }} - port: {{ if .Server.K8s }}{{ .Server.K8s.DefaultPort }}{{ end }} + source: + type: ContainerImage + containerImage: + ref: {{ if .Package }}{{ .Package.Identifier }}{{ end }} + config: + port: {{ if .Server.K8s }}{{ .Server.K8s.DefaultPort }}{{ end }} + path: /mcp