diff --git a/cmd/main.go b/cmd/main.go index 5e241c45..7859e03b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -188,6 +188,13 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "MCPServer") os.Exit(1) } + if err := (&controller.MCPServerGatewayReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "MCPServerGateway") + os.Exit(1) + } // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/crd/test/gateway/httproute-crd.yaml b/config/crd/test/gateway/httproute-crd.yaml new file mode 100644 index 00000000..31e44a02 --- /dev/null +++ b/config/crd/test/gateway/httproute-crd.yaml @@ -0,0 +1,24 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + api-approved.kubernetes.io: https://github.com/kubernetes-sigs/gateway-api/pull/4530 + gateway.networking.k8s.io/bundle-version: v1.4.1 + gateway.networking.k8s.io/channel: standard + name: httproutes.gateway.networking.k8s.io +spec: + group: gateway.networking.k8s.io + names: + kind: HTTPRoute + listKind: HTTPRouteList + plural: httproutes + singular: httproute + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + x-kubernetes-preserve-unknown-fields: true diff --git a/config/crd/test/gateway/mcpserverregistration-crd.yaml b/config/crd/test/gateway/mcpserverregistration-crd.yaml new file mode 100644 index 00000000..10dbd07e --- /dev/null +++ b/config/crd/test/gateway/mcpserverregistration-crd.yaml @@ -0,0 +1,20 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: mcpserverregistrations.mcp.kagenti.com +spec: + group: mcp.kagenti.com + names: + kind: MCPServerRegistration + listKind: MCPServerRegistrationList + plural: mcpserverregistrations + singular: mcpserverregistration + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + x-kubernetes-preserve-unknown-fields: true diff --git a/config/rbac/gateway_role.yaml b/config/rbac/gateway_role.yaml new file mode 100644 index 00000000..19b48872 --- /dev/null +++ b/config/rbac/gateway_role.yaml @@ -0,0 +1,30 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: mcp-lifecycle-operator + app.kubernetes.io/managed-by: kustomize + name: manager-gateway-role +rules: +- apiGroups: + - gateway.networking.k8s.io + resources: + - httproutes + verbs: + - get + - list + - watch + - create + - update + - delete +- apiGroups: + - mcp.kagenti.com + resources: + - mcpserverregistrations + verbs: + - get + - list + - watch + - create + - update + - delete diff --git a/config/rbac/gateway_role_binding.yaml b/config/rbac/gateway_role_binding.yaml new file mode 100644 index 00000000..72ab5679 --- /dev/null +++ b/config/rbac/gateway_role_binding.yaml @@ -0,0 +1,15 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/name: mcp-lifecycle-operator + app.kubernetes.io/managed-by: kustomize + name: manager-gateway-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: manager-gateway-role +subjects: +- kind: ServiceAccount + name: controller-manager + namespace: system diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 0a6a02cd..1a3ba717 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -28,4 +28,6 @@ resources: - mcpserver_admin_role.yaml - mcpserver_editor_role.yaml - mcpserver_viewer_role.yaml +- gateway_role.yaml +- gateway_role_binding.yaml diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index f8baa7da..6cee4d19 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 diff --git a/internal/controller/mcpserver_gateway_controller.go b/internal/controller/mcpserver_gateway_controller.go new file mode 100644 index 00000000..844d3fce --- /dev/null +++ b/internal/controller/mcpserver_gateway_controller.go @@ -0,0 +1,355 @@ +package controller + +import ( + "context" + "fmt" + "strings" + + mcpv1alpha1 "github.com/kubernetes-sigs/mcp-lifecycle-operator/api/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + AnnotationGatewayRef = "mcp.x-k8s.io/gateway-ref" + AnnotationGatewayHostname = "mcp.x-k8s.io/gateway-hostname" + AnnotationGatewayToolPrefix = "mcp.x-k8s.io/gateway-tool-prefix" + HTTPRouteGroup = "gateway.networking.k8s.io" + HTTPRouteVersion = "v1" + HTTPRouteKind = "HTTPRoute" + MCPServerRegistrationGroup = "mcp.kagenti.com" + MCPServerRegistrationVersion = "v1alpha1" + MCPServerRegistrationKind = "MCPServerRegistration" + StatusConditionGatewayReady = "GatewayReady" +) + +type MCPServerGatewayReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +func (r *MCPServerGatewayReconciler) SetupWithManager(mgr ctrl.Manager) error { + builder := ctrl.NewControllerManagedBy(mgr). + For(&mcpv1alpha1.MCPServer{}). + Named("mcpserver-gateway") + + mapper := mgr.GetRESTMapper() + + if _, err := mapper.RESTMapping(schema.GroupKind{Group: HTTPRouteGroup, Kind: HTTPRouteKind}, HTTPRouteVersion); err == nil { + httpRoute := &unstructured.Unstructured{} + httpRoute.SetGroupVersionKind(schema.GroupVersionKind{ + Group: HTTPRouteGroup, Version: HTTPRouteVersion, Kind: HTTPRouteKind, + }) + builder.Owns(httpRoute) + } + + if _, err := mapper.RESTMapping(schema.GroupKind{Group: MCPServerRegistrationGroup, Kind: MCPServerRegistrationKind}, MCPServerRegistrationVersion); err == nil { + mcpReg := &unstructured.Unstructured{} + mcpReg.SetGroupVersionKind(schema.GroupVersionKind{ + Group: MCPServerRegistrationGroup, Version: MCPServerRegistrationVersion, Kind: MCPServerRegistrationKind, + }) + builder.Owns(mcpReg) + } + + return builder.Complete(r) +} + +func (r *MCPServerGatewayReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + mcpServer := &mcpv1alpha1.MCPServer{} + if err := r.Get(ctx, req.NamespacedName, mcpServer); err != nil { + if apierrors.IsNotFound(err) { + // Object deleted - owned resources get garbage-collected via owner refs, nothing to do + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + gatewayRef, hasGatewayRef := mcpServer.Annotations[AnnotationGatewayRef] + if !hasGatewayRef { + if err := r.deleteGatewayResources(ctx, mcpServer); err != nil { + return ctrl.Result{}, err + } + + if err := r.removeGatewayCondition(ctx, mcpServer); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + + gwNamespace, gwName, err := parseGatewayRef(gatewayRef) + if err != nil { + logger.Error(err, "invalid gateway-ref annotation", "value", gatewayRef) + return ctrl.Result{}, r.setGatewayCondition(ctx, mcpServer, metav1.ConditionFalse, "InvalidGatewayRef", err.Error()) + } + + if err := r.reconcileHTTPRoute(ctx, mcpServer, gwNamespace, gwName); err != nil { + if meta.IsNoMatchError(err) { + return ctrl.Result{}, r.setGatewayCondition( + ctx, + mcpServer, + metav1.ConditionFalse, + "CRDNotInstalled", + "Gateway API CRDs are not installed in the cluster", + ) + } + return ctrl.Result{}, err + } + + if err := r.reconcileMCPServerRegistration(ctx, mcpServer); err != nil { + if meta.IsNoMatchError(err) { + return ctrl.Result{}, r.setGatewayCondition( + ctx, + mcpServer, + metav1.ConditionFalse, + "CRDNotInstalled", + "MCPServerRegistration CRD is not installed in the cluster", + ) + } + return ctrl.Result{}, err + } + + return ctrl.Result{}, r.setGatewayCondition( + ctx, + mcpServer, + metav1.ConditionTrue, + "GatewayResourcesReady", + "HTTPRoute and MCPServerRegistration are configured", + ) +} + +func (r *MCPServerGatewayReconciler) reconcileHTTPRoute( + ctx context.Context, + mcpServer *mcpv1alpha1.MCPServer, + gwNamespace, gwName string, +) error { + hostname := gatewayHostname(mcpServer) + path := gatewayPath(mcpServer) + route := httpRouteForMCPServer(mcpServer) + + _, err := controllerutil.CreateOrUpdate(ctx, r.Client, route, func() error { + if err := controllerutil.SetControllerReference(mcpServer, route, r.Scheme); err != nil { + return err + } + + route.Object["spec"] = map[string]any{ + "parentRefs": []any{ + map[string]any{ + "name": gwName, + "namespace": gwNamespace, + }, + }, + "hostnames": []any{hostname}, + "rules": []any{ + map[string]any{ + "matches": []any{ + map[string]any{ + "path": map[string]any{ + "type": "PathPrefix", + "value": path, + }, + }, + }, + "backendRefs": []any{ + map[string]any{ + "name": mcpServer.Name, + // Cast to int64: the API server returns unstructured JSON numbers as int64, + // so using int32 here causes DeepEqual mismatches on every reconcile, + // triggering spurious updates. + "port": int64(mcpServer.Spec.Port), + }, + }, + }, + }, + } + + return nil + }) + + return err +} + +func (r *MCPServerGatewayReconciler) reconcileMCPServerRegistration( + ctx context.Context, + mcpServer *mcpv1alpha1.MCPServer, +) error { + logger := log.FromContext(ctx) + toolPrefix := gatewayToolPrefix(mcpServer) + + // toolPrefix is immutable on the MCPServerRegistration CRD. + // If it changed, we must delete and recreate rather than update. + existing := mcpServerRegistrationForMCPServer(mcpServer) + if err := r.Get(ctx, client.ObjectKeyFromObject(existing), existing); err == nil { + if spec, ok := existing.Object["spec"].(map[string]any); ok { + if existingPrefix, _ := spec["toolPrefix"].(string); existingPrefix != toolPrefix { + logger.Info("toolPrefix changed, deleting MCPServerRegistration for recreation", + "old", existingPrefix, "new", toolPrefix) + if err := r.Delete(ctx, existing); err != nil && !apierrors.IsNotFound(err) { + return err + } + } + } + } + + reg := mcpServerRegistrationForMCPServer(mcpServer) + _, err := controllerutil.CreateOrUpdate(ctx, r.Client, reg, func() error { + if err := controllerutil.SetControllerReference(mcpServer, reg, r.Scheme); err != nil { + return err + } + + reg.Object["spec"] = map[string]any{ + "toolPrefix": toolPrefix, + "targetRef": map[string]any{ + "group": HTTPRouteGroup, + "kind": HTTPRouteKind, + "name": mcpServer.Name, + "namespace": mcpServer.Namespace, + }, + } + + return nil + }) + + return err +} + +func (r *MCPServerGatewayReconciler) deleteGatewayResources( + ctx context.Context, + mcpServer *mcpv1alpha1.MCPServer, +) error { + logger := log.FromContext(ctx) + + route := httpRouteForMCPServer(mcpServer) + if err := r.Delete(ctx, route); err != nil { + if !apierrors.IsNotFound(err) && !meta.IsNoMatchError(err) { + // if the crd doesn't exist at all or the resource is not foundm, we are happy + return err + } + } else { + logger.Info("Deleted HTTPRoute", "name", mcpServer.Name) + } + + reg := mcpServerRegistrationForMCPServer(mcpServer) + if err := r.Delete(ctx, reg); err != nil { + if !apierrors.IsNotFound(err) && !meta.IsNoMatchError(err) { + return err + } + } else { + logger.Info("Deleted MCPServerRegistration", "name", mcpServer.Name) + } + + return nil +} + +func (r *MCPServerGatewayReconciler) setGatewayCondition( + ctx context.Context, + mcpServer *mcpv1alpha1.MCPServer, + status metav1.ConditionStatus, + reason, message string, +) error { + + // refetch the latest resourceVersion to avoid conflicts + latest := &mcpv1alpha1.MCPServer{} + if err := r.Get(ctx, client.ObjectKeyFromObject(mcpServer), latest); err != nil { + return err + } + + base := latest.DeepCopy() + + meta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{ + Type: StatusConditionGatewayReady, + Status: status, + Reason: reason, + Message: message, + ObservedGeneration: latest.Generation, + }) + + return r.Status().Patch(ctx, latest, client.MergeFrom(base)) +} + +func (r *MCPServerGatewayReconciler) removeGatewayCondition( + ctx context.Context, + mcpServer *mcpv1alpha1.MCPServer, +) error { + latest := &mcpv1alpha1.MCPServer{} + if err := r.Get(ctx, client.ObjectKeyFromObject(mcpServer), latest); err != nil { + return client.IgnoreNotFound(err) + } + + if meta.FindStatusCondition(latest.Status.Conditions, "GatewayReady") == nil { + return nil + } + + base := latest.DeepCopy() + meta.RemoveStatusCondition(&latest.Status.Conditions, "GatewayReady") + return r.Status().Patch(ctx, latest, client.MergeFrom(base)) +} + +func httpRouteForMCPServer(mcpServer *mcpv1alpha1.MCPServer) *unstructured.Unstructured { + route := &unstructured.Unstructured{} + route.SetGroupVersionKind(schema.GroupVersionKind{ + Group: HTTPRouteGroup, + Version: HTTPRouteVersion, + Kind: HTTPRouteKind, + }) + route.SetName(mcpServer.Name) + route.SetNamespace(mcpServer.Namespace) + + return route +} + +func mcpServerRegistrationForMCPServer(mcpServer *mcpv1alpha1.MCPServer) *unstructured.Unstructured { + reg := &unstructured.Unstructured{} + reg.SetGroupVersionKind(schema.GroupVersionKind{ + Group: MCPServerRegistrationGroup, + Version: MCPServerRegistrationVersion, + Kind: MCPServerRegistrationKind, + }) + reg.SetName(mcpServer.Name) + reg.SetNamespace(mcpServer.Namespace) + + return reg +} + +func parseGatewayRef(ref string) (namespace, name string, err error) { + parts := strings.SplitN(ref, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("gateway-ref must be in 'namespace/name' format, got %q", ref) + } + + return parts[0], parts[1], nil +} + +func gatewayHostname(mcpServer *mcpv1alpha1.MCPServer) string { + if h, ok := mcpServer.Annotations[AnnotationGatewayHostname]; ok && h != "" { + return h + } + + return mcpServer.Name + ".mcp.local" +} + +func gatewayToolPrefix(mcpServer *mcpv1alpha1.MCPServer) string { + if p, ok := mcpServer.Annotations[AnnotationGatewayToolPrefix]; ok && p != "" { + return p + } + + return strings.ReplaceAll(mcpServer.Name, "-", "_") + "_" +} + +func gatewayPath(mcpServer *mcpv1alpha1.MCPServer) string { + p := mcpServer.Spec.Path + if p == "" { + return "/mcp" + } + + return p +} diff --git a/internal/controller/mcpserver_gateway_controller_test.go b/internal/controller/mcpserver_gateway_controller_test.go new file mode 100644 index 00000000..b92d6769 --- /dev/null +++ b/internal/controller/mcpserver_gateway_controller_test.go @@ -0,0 +1,540 @@ +package controller + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile" + + mcpv1alpha1 "github.com/kubernetes-sigs/mcp-lifecycle-operator/api/v1alpha1" +) + +var _ = Describe("MCPServer Gateway Controller", func() { + newReconciler := func() *MCPServerGatewayReconciler { + return &MCPServerGatewayReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + } + + newMCPServer := func(name string, annotations map[string]string) *mcpv1alpha1.MCPServer { + return &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + Annotations: annotations, + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "test-image:latest", + Port: 8080, + }, + } + } + + doReconcile := func(name string) error { + _, err := newReconciler().Reconcile(ctx, ctrlreconcile.Request{ + NamespacedName: types.NamespacedName{Name: name, Namespace: "default"}, + }) + return err + } + + getHTTPRoute := func(name string) (*unstructured.Unstructured, error) { + route := &unstructured.Unstructured{} + route.SetGroupVersionKind(schema.GroupVersionKind{ + Group: HTTPRouteGroup, Version: HTTPRouteVersion, Kind: HTTPRouteKind, + }) + err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, route) + return route, err + } + + getMCPServerRegistration := func(name string) (*unstructured.Unstructured, error) { + reg := &unstructured.Unstructured{} + reg.SetGroupVersionKind(schema.GroupVersionKind{ + Group: MCPServerRegistrationGroup, Version: MCPServerRegistrationVersion, Kind: MCPServerRegistrationKind, + }) + err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, reg) + return reg, err + } + + getGatewayCondition := func(name string) *metav1.Condition { + mcpServer := &mcpv1alpha1.MCPServer{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, mcpServer); err != nil { + return nil + } + return meta.FindStatusCondition(mcpServer.Status.Conditions, StatusConditionGatewayReady) + } + + Context("When no gateway-ref annotation is present", func() { + const name = "gw-test-no-annotation" + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + }) + + It("should not create gateway resources", func() { + mcpServer := newMCPServer(name, nil) + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + + Expect(doReconcile(name)).To(Succeed()) + + _, err := getHTTPRoute(name) + Expect(errors.IsNotFound(err)).To(BeTrue()) + + _, err = getMCPServerRegistration(name) + Expect(errors.IsNotFound(err)).To(BeTrue()) + + Expect(getGatewayCondition(name)).To(BeNil()) + }) + }) + + Context("When gateway-ref annotation is valid", func() { + const name = "gw-test-valid" + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + }) + + It("should create HTTPRoute with correct parentRefs, hostnames, and backendRefs", func() { + mcpServer := newMCPServer(name, map[string]string{ + AnnotationGatewayRef: "gateway-system/mcp-gateway", + }) + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + + Expect(doReconcile(name)).To(Succeed()) + + route, err := getHTTPRoute(name) + Expect(err).NotTo(HaveOccurred()) + + spec := route.Object["spec"].(map[string]any) + + parentRefs := spec["parentRefs"].([]any) + Expect(parentRefs).To(HaveLen(1)) + ref := parentRefs[0].(map[string]any) + Expect(ref["name"]).To(Equal("mcp-gateway")) + Expect(ref["namespace"]).To(Equal("gateway-system")) + + hostnames := spec["hostnames"].([]any) + Expect(hostnames).To(ContainElement("gw-test-valid.mcp.local")) + + rules := spec["rules"].([]any) + Expect(rules).To(HaveLen(1)) + rule := rules[0].(map[string]any) + + matches := rule["matches"].([]any) + match := matches[0].(map[string]any) + pathMatch := match["path"].(map[string]any) + Expect(pathMatch["type"]).To(Equal("PathPrefix")) + Expect(pathMatch["value"]).To(Equal("/mcp")) + + backendRefs := rule["backendRefs"].([]any) + Expect(backendRefs).To(HaveLen(1)) + backend := backendRefs[0].(map[string]any) + Expect(backend["name"]).To(Equal(name)) + }) + + It("should create MCPServerRegistration with correct toolPrefix and targetRef", func() { + mcpServer := newMCPServer(name, map[string]string{ + AnnotationGatewayRef: "gateway-system/mcp-gateway", + }) + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + + Expect(doReconcile(name)).To(Succeed()) + + reg, err := getMCPServerRegistration(name) + Expect(err).NotTo(HaveOccurred()) + + regSpec := reg.Object["spec"].(map[string]any) + Expect(regSpec["toolPrefix"]).To(Equal("gw_test_valid_")) + + targetRef := regSpec["targetRef"].(map[string]any) + Expect(targetRef["name"]).To(Equal(name)) + Expect(targetRef["namespace"]).To(Equal("default")) + }) + + It("should set GatewayReady=True condition", func() { + mcpServer := newMCPServer(name, map[string]string{ + AnnotationGatewayRef: "gateway-system/mcp-gateway", + }) + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + + Expect(doReconcile(name)).To(Succeed()) + + cond := getGatewayCondition(name) + Expect(cond).NotTo(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + Expect(cond.Reason).To(Equal("GatewayResourcesReady")) + }) + }) + + Context("When using default values", func() { + const name = "my-cool-server" + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + }) + + It("should default hostname to .mcp.local", func() { + mcpServer := newMCPServer(name, map[string]string{ + AnnotationGatewayRef: "gateway-system/mcp-gateway", + }) + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + + Expect(doReconcile(name)).To(Succeed()) + + route, err := getHTTPRoute(name) + Expect(err).NotTo(HaveOccurred()) + + spec := route.Object["spec"].(map[string]any) + hostnames := spec["hostnames"].([]any) + Expect(hostnames).To(ContainElement("my-cool-server.mcp.local")) + }) + + It("should default tool prefix to _", func() { + mcpServer := newMCPServer(name, map[string]string{ + AnnotationGatewayRef: "gateway-system/mcp-gateway", + }) + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + + Expect(doReconcile(name)).To(Succeed()) + + reg, err := getMCPServerRegistration(name) + Expect(err).NotTo(HaveOccurred()) + + regSpec := reg.Object["spec"].(map[string]any) + Expect(regSpec["toolPrefix"]).To(Equal("my_cool_server_")) + }) + + It("should default path to /mcp", func() { + mcpServer := newMCPServer(name, map[string]string{ + AnnotationGatewayRef: "gateway-system/mcp-gateway", + }) + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + + Expect(doReconcile(name)).To(Succeed()) + + route, err := getHTTPRoute(name) + Expect(err).NotTo(HaveOccurred()) + + spec := route.Object["spec"].(map[string]any) + rules := spec["rules"].([]any) + rule := rules[0].(map[string]any) + matches := rule["matches"].([]any) + match := matches[0].(map[string]any) + pathMatch := match["path"].(map[string]any) + Expect(pathMatch["value"]).To(Equal("/mcp")) + }) + }) + + Context("When custom annotation values are set", func() { + const name = "gw-test-custom" + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + }) + + It("should use custom hostname from annotation", func() { + mcpServer := newMCPServer(name, map[string]string{ + AnnotationGatewayRef: "gateway-system/mcp-gateway", + AnnotationGatewayHostname: "custom.example.com", + }) + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + + Expect(doReconcile(name)).To(Succeed()) + + route, err := getHTTPRoute(name) + Expect(err).NotTo(HaveOccurred()) + + spec := route.Object["spec"].(map[string]any) + hostnames := spec["hostnames"].([]any) + Expect(hostnames).To(ContainElement("custom.example.com")) + }) + + It("should use custom tool prefix from annotation", func() { + mcpServer := newMCPServer(name, map[string]string{ + AnnotationGatewayRef: "gateway-system/mcp-gateway", + AnnotationGatewayToolPrefix: "kube_", + }) + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + + Expect(doReconcile(name)).To(Succeed()) + + reg, err := getMCPServerRegistration(name) + Expect(err).NotTo(HaveOccurred()) + + regSpec := reg.Object["spec"].(map[string]any) + Expect(regSpec["toolPrefix"]).To(Equal("kube_")) + }) + }) + + Context("When spec.path is set", func() { + const name = "gw-test-custom-path" + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + }) + + It("should use spec.path in HTTPRoute path match", func() { + mcpServer := newMCPServer(name, map[string]string{ + AnnotationGatewayRef: "gateway-system/mcp-gateway", + }) + mcpServer.Spec.Path = "/sse" + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + + Expect(doReconcile(name)).To(Succeed()) + + route, err := getHTTPRoute(name) + Expect(err).NotTo(HaveOccurred()) + + spec := route.Object["spec"].(map[string]any) + rules := spec["rules"].([]any) + rule := rules[0].(map[string]any) + matches := rule["matches"].([]any) + match := matches[0].(map[string]any) + pathMatch := match["path"].(map[string]any) + Expect(pathMatch["value"]).To(Equal("/sse")) + }) + }) + + Context("When gateway-ref is malformed", func() { + const name = "gw-test-bad-ref" + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + }) + + It("should set GatewayReady=False with InvalidGatewayRef for missing slash", func() { + mcpServer := newMCPServer(name, map[string]string{ + AnnotationGatewayRef: "no-slash-here", + }) + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + + Expect(doReconcile(name)).To(Succeed()) + + cond := getGatewayCondition(name) + Expect(cond).NotTo(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + Expect(cond.Reason).To(Equal("InvalidGatewayRef")) + }) + + It("should set GatewayReady=False with InvalidGatewayRef for empty namespace", func() { + mcpServer := newMCPServer(name, map[string]string{ + AnnotationGatewayRef: "/mcp-gateway", + }) + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + + Expect(doReconcile(name)).To(Succeed()) + + cond := getGatewayCondition(name) + Expect(cond).NotTo(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + Expect(cond.Reason).To(Equal("InvalidGatewayRef")) + }) + + It("should set GatewayReady=False with InvalidGatewayRef for empty name", func() { + mcpServer := newMCPServer(name, map[string]string{ + AnnotationGatewayRef: "gateway-system/", + }) + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + + Expect(doReconcile(name)).To(Succeed()) + + cond := getGatewayCondition(name) + Expect(cond).NotTo(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + Expect(cond.Reason).To(Equal("InvalidGatewayRef")) + }) + }) + + Context("When annotation value changes", func() { + const name = "gw-test-update" + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + }) + + It("should update HTTPRoute when gateway-ref changes", func() { + mcpServer := newMCPServer(name, map[string]string{ + AnnotationGatewayRef: "ns-one/gateway-one", + }) + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + + By("Reconciling to create initial resources") + Expect(doReconcile(name)).To(Succeed()) + + route, err := getHTTPRoute(name) + Expect(err).NotTo(HaveOccurred()) + spec := route.Object["spec"].(map[string]any) + parentRefs := spec["parentRefs"].([]any) + ref := parentRefs[0].(map[string]any) + Expect(ref["namespace"]).To(Equal("ns-one")) + Expect(ref["name"]).To(Equal("gateway-one")) + + By("Updating the gateway-ref annotation") + updated := &mcpv1alpha1.MCPServer{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, updated)).To(Succeed()) + updated.Annotations[AnnotationGatewayRef] = "ns-two/gateway-two" + Expect(k8sClient.Update(ctx, updated)).To(Succeed()) + + By("Reconciling again") + Expect(doReconcile(name)).To(Succeed()) + + route, err = getHTTPRoute(name) + Expect(err).NotTo(HaveOccurred()) + spec = route.Object["spec"].(map[string]any) + parentRefs = spec["parentRefs"].([]any) + ref = parentRefs[0].(map[string]any) + Expect(ref["namespace"]).To(Equal("ns-two")) + Expect(ref["name"]).To(Equal("gateway-two")) + }) + + It("should update MCPServerRegistration when tool prefix annotation changes", func() { + mcpServer := newMCPServer(name, map[string]string{ + AnnotationGatewayRef: "gateway-system/mcp-gateway", + AnnotationGatewayToolPrefix: "old_prefix_", + }) + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + + By("Reconciling to create initial resources") + Expect(doReconcile(name)).To(Succeed()) + + reg, err := getMCPServerRegistration(name) + Expect(err).NotTo(HaveOccurred()) + regSpec := reg.Object["spec"].(map[string]any) + Expect(regSpec["toolPrefix"]).To(Equal("old_prefix_")) + + By("Updating the tool prefix annotation") + updated := &mcpv1alpha1.MCPServer{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, updated)).To(Succeed()) + updated.Annotations[AnnotationGatewayToolPrefix] = "new_prefix_" + Expect(k8sClient.Update(ctx, updated)).To(Succeed()) + + By("Reconciling again") + Expect(doReconcile(name)).To(Succeed()) + + reg, err = getMCPServerRegistration(name) + Expect(err).NotTo(HaveOccurred()) + regSpec = reg.Object["spec"].(map[string]any) + Expect(regSpec["toolPrefix"]).To(Equal("new_prefix_")) + }) + }) + + Context("When gateway-ref annotation is removed", func() { + const name = "gw-test-remove-annotation" + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + }) + + It("should delete HTTPRoute and MCPServerRegistration and remove condition", func() { + mcpServer := newMCPServer(name, map[string]string{ + AnnotationGatewayRef: "gateway-system/mcp-gateway", + }) + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + + By("Reconciling to create gateway resources") + Expect(doReconcile(name)).To(Succeed()) + + By("Verifying resources exist") + _, err := getHTTPRoute(name) + Expect(err).NotTo(HaveOccurred()) + _, err = getMCPServerRegistration(name) + Expect(err).NotTo(HaveOccurred()) + Expect(getGatewayCondition(name)).NotTo(BeNil()) + + By("Removing the gateway-ref annotation") + updated := &mcpv1alpha1.MCPServer{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, updated)).To(Succeed()) + delete(updated.Annotations, AnnotationGatewayRef) + Expect(k8sClient.Update(ctx, updated)).To(Succeed()) + + By("Reconciling again") + Expect(doReconcile(name)).To(Succeed()) + + By("Verifying resources are deleted") + _, err = getHTTPRoute(name) + Expect(errors.IsNotFound(err)).To(BeTrue()) + _, err = getMCPServerRegistration(name) + Expect(errors.IsNotFound(err)).To(BeTrue()) + + By("Verifying GatewayReady condition is removed") + Expect(getGatewayCondition(name)).To(BeNil()) + }) + }) + + Context("When gateway-ref annotation is removed leaving other annotations", func() { + const name = "gw-test-remove-partial" + + AfterEach(func() { + resource := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, resource) + if err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + }) + + It("should delete gateway resources even when other annotations remain", func() { + mcpServer := newMCPServer(name, map[string]string{ + AnnotationGatewayRef: "gateway-system/mcp-gateway", + AnnotationGatewayHostname: "custom.example.com", + }) + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + + By("Reconciling to create gateway resources") + Expect(doReconcile(name)).To(Succeed()) + _, err := getHTTPRoute(name) + Expect(err).NotTo(HaveOccurred()) + + By("Removing only gateway-ref, leaving hostname") + updated := &mcpv1alpha1.MCPServer{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: "default"}, updated)).To(Succeed()) + delete(updated.Annotations, AnnotationGatewayRef) + Expect(k8sClient.Update(ctx, updated)).To(Succeed()) + + By("Reconciling again") + Expect(doReconcile(name)).To(Succeed()) + + By("Verifying resources are deleted") + _, err = getHTTPRoute(name) + Expect(errors.IsNotFound(err)).To(BeTrue()) + _, err = getMCPServerRegistration(name) + Expect(errors.IsNotFound(err)).To(BeTrue()) + }) + }) +}) diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 9b47a1ef..877112e9 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -70,7 +70,10 @@ var _ = BeforeSuite(func() { By("bootstrapping test environment") testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "config", "crd", "bases"), + filepath.Join("..", "..", "config", "crd", "test", "gateway"), + }, ErrorIfCRDPathMissing: true, }