diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ef0b032 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "go.toolsEnvVars": { + "GO111MODULE": "on" + }, + "go.useLanguageServer": true, + "go.workspaceFolder": "${workspaceFolder}/kubechain", + "go.coverShowCounts": true, + "gopls": { + "formatting.gofumpt": true, + "ui.semanticTokens": true + } +} \ No newline at end of file diff --git a/kubechain/api/v1alpha1/contactchannel_types.go b/kubechain/api/v1alpha1/contactchannel_types.go index 1a4be8a..9d62c9f 100644 --- a/kubechain/api/v1alpha1/contactchannel_types.go +++ b/kubechain/api/v1alpha1/contactchannel_types.go @@ -36,6 +36,13 @@ type ContactChannelSpec struct { type ContactChannelStatus struct { // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster // Important: Run "make" to regenerate code after modifying this file + Ready bool `json:"ready,omitempty"` + + // +kubebuilder:validation:Enum=Ready;Error;Pending + Status string `json:"status,omitempty"` + + // StatusDetail provides additional details about the current status + StatusDetail string `json:"statusDetail,omitempty"` } // +kubebuilder:object:root=true diff --git a/kubechain/api/v1alpha1/mcpserver_types.go b/kubechain/api/v1alpha1/mcpserver_types.go index f2862da..3a542d2 100644 --- a/kubechain/api/v1alpha1/mcpserver_types.go +++ b/kubechain/api/v1alpha1/mcpserver_types.go @@ -32,6 +32,10 @@ type MCPServerSpec struct { // ResourceRequirements defines CPU/Memory resources requests/limits // +optional Resources ResourceRequirements `json:"resources,omitempty"` + + // ApprovalContactChannel is the contact channel for approval + // +optional + ApprovalContactChannel *LocalObjectReference `json:"approvalContactChannel,omitempty"` } // EnvVar represents an environment variable diff --git a/kubechain/api/v1alpha1/zz_generated.deepcopy.go b/kubechain/api/v1alpha1/zz_generated.deepcopy.go index fc18de1..dc9efa9 100644 --- a/kubechain/api/v1alpha1/zz_generated.deepcopy.go +++ b/kubechain/api/v1alpha1/zz_generated.deepcopy.go @@ -516,6 +516,11 @@ func (in *MCPServerSpec) DeepCopyInto(out *MCPServerSpec) { } } in.Resources.DeepCopyInto(&out.Resources) + if in.ApprovalContactChannel != nil { + in, out := &in.ApprovalContactChannel, &out.ApprovalContactChannel + *out = new(LocalObjectReference) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerSpec. diff --git a/kubechain/config/crd/bases/kubechain.humanlayer.dev_contactchannels.yaml b/kubechain/config/crd/bases/kubechain.humanlayer.dev_contactchannels.yaml index dec0987..9d4ea56 100644 --- a/kubechain/config/crd/bases/kubechain.humanlayer.dev_contactchannels.yaml +++ b/kubechain/config/crd/bases/kubechain.humanlayer.dev_contactchannels.yaml @@ -46,6 +46,22 @@ spec: type: object status: description: ContactChannelStatus defines the observed state of ContactChannel. + properties: + ready: + description: |- + INSERT ADDITIONAL STATUS FIELD - define observed state of cluster + Important: Run "make" to regenerate code after modifying this file + type: boolean + status: + enum: + - Ready + - Error + - Pending + type: string + statusDetail: + description: StatusDetail provides additional details about the current + status + type: string type: object type: object served: true diff --git a/kubechain/config/crd/bases/kubechain.humanlayer.dev_mcpservers.yaml b/kubechain/config/crd/bases/kubechain.humanlayer.dev_mcpservers.yaml index 3209a31..b587447 100644 --- a/kubechain/config/crd/bases/kubechain.humanlayer.dev_mcpservers.yaml +++ b/kubechain/config/crd/bases/kubechain.humanlayer.dev_mcpservers.yaml @@ -50,6 +50,16 @@ spec: spec: description: MCPServerSpec defines the desired state of MCPServer properties: + approvalContactChannel: + description: ApprovalContactChannel is the contact channel for approval + properties: + name: + description: Name of the referent + minLength: 1 + type: string + required: + - name + type: object args: description: Args are the arguments to pass to the command for stdio MCP servers diff --git a/kubechain/internal/controller/mcpserver/mcpserver_controller.go b/kubechain/internal/controller/mcpserver/mcpserver_controller.go index 1a741e2..df6fbc7 100644 --- a/kubechain/internal/controller/mcpserver/mcpserver_controller.go +++ b/kubechain/internal/controller/mcpserver/mcpserver_controller.go @@ -7,6 +7,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -17,7 +18,9 @@ import ( ) const ( - StatusError = "Error" + StatusError = "Error" + StatusPending = "Pending" + StatusReady = "Ready" ) // MCPServerManagerInterface defines the interface for MCP server management @@ -84,6 +87,34 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // Create a status update copy statusUpdate := mcpServer.DeepCopy() + if statusUpdate.Spec.ApprovalContactChannel != nil { + // validate the approval contact channel + approvalContactChannel := &kubechainv1alpha1.ContactChannel{} + err := r.Get(ctx, types.NamespacedName{Name: statusUpdate.Spec.ApprovalContactChannel.Name, Namespace: statusUpdate.Namespace}, approvalContactChannel) + if err != nil { + statusUpdate.Status.Connected = false + statusUpdate.Status.Status = StatusError + // todo handle other types of error, not just "not found" + statusUpdate.Status.StatusDetail = fmt.Sprintf("ContactChannel %q not found", statusUpdate.Spec.ApprovalContactChannel.Name) + r.recorder.Event(&mcpServer, corev1.EventTypeWarning, "ContactChannelNotFound", fmt.Sprintf("ContactChannel %q not found", statusUpdate.Spec.ApprovalContactChannel.Name)) + if err := r.updateStatus(ctx, req, statusUpdate); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, err + } + + if !approvalContactChannel.Status.Ready { + statusUpdate.Status.Connected = false + statusUpdate.Status.Status = StatusPending + statusUpdate.Status.StatusDetail = fmt.Sprintf("ContactChannel %q is not ready", statusUpdate.Spec.ApprovalContactChannel.Name) + r.recorder.Event(&mcpServer, corev1.EventTypeWarning, "ContactChannelNotReady", fmt.Sprintf("ContactChannel %q is not ready", statusUpdate.Spec.ApprovalContactChannel.Name)) + if err := r.updateStatus(ctx, req, statusUpdate); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: time.Second * 5}, nil + } + } + // Basic validation if err := r.validateMCPServer(&mcpServer); err != nil { statusUpdate.Status.Connected = false diff --git a/kubechain/internal/controller/mcpserver/mcpserver_controller_test.go b/kubechain/internal/controller/mcpserver/mcpserver_controller_test.go index d8aaef4..11c7623 100644 --- a/kubechain/internal/controller/mcpserver/mcpserver_controller_test.go +++ b/kubechain/internal/controller/mcpserver/mcpserver_controller_test.go @@ -13,6 +13,7 @@ import ( kubechainv1alpha1 "github.com/humanlayer/smallchain/kubechain/api/v1alpha1" "github.com/humanlayer/smallchain/kubechain/internal/mcpmanager" + "github.com/humanlayer/smallchain/kubechain/test/utils" ) // MockMCPServerManager is a mock implementation of the MCPServerManager for testing @@ -196,5 +197,116 @@ var _ = Describe("MCPServer Controller", func() { By("Cleaning up the invalid MCPServer") Expect(k8sClient.Delete(ctx, invalidMCPServer)).To(Succeed()) }) + + It("Should error if the approval contact channel is non-existent", func() { + ctx := context.Background() + + By("Creating a new MCPServer with non-existent approval contact channel reference") + mcpServer := &kubechainv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mcpserver-missing-channel", + Namespace: MCPServerNamespace, + }, + Spec: kubechainv1alpha1.MCPServerSpec{ + Transport: "stdio", + Command: "test-command", + ApprovalContactChannel: &kubechainv1alpha1.LocalObjectReference{ + Name: "non-existent-channel", + }, + }, + } + + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + + By("Creating a controller with a mock manager") + recorder := record.NewFakeRecorder(10) + reconciler := &MCPServerReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + recorder: recorder, + MCPManager: &MockMCPServerManager{}, + } + + By("Reconciling the MCPServer with non-existent contact channel") + _, err := reconciler.Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "mcpserver-missing-channel", Namespace: MCPServerNamespace}, + }) + Expect(err).To(HaveOccurred()) // Should fail because contact channel doesn't exist + + By("Checking that the status was updated correctly to reflect the error") + createdMCPServer := &kubechainv1alpha1.MCPServer{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: "mcpserver-missing-channel", Namespace: MCPServerNamespace}, createdMCPServer) + Expect(err).NotTo(HaveOccurred()) + Expect(createdMCPServer.Status.Connected).To(BeFalse()) + Expect(createdMCPServer.Status.Status).To(Equal("Error")) + Expect(createdMCPServer.Status.StatusDetail).To(ContainSubstring("ContactChannel \"non-existent-channel\" not found")) + By("Checking that the event was emitted") + utils.ExpectRecorder(recorder).ToEmitEventContaining("ContactChannelNotFound") + + By("Cleaning up the MCPServer") + Expect(k8sClient.Delete(ctx, mcpServer)).To(Succeed()) + }) + + It("Should stay in pending if the approval contact channel is not ready", func() { + ctx := context.Background() + + By("Creating a new MCPServer with approval contact channel reference") + mcpServer := &kubechainv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mcpserver-channel-ready", + Namespace: MCPServerNamespace, + }, + Spec: kubechainv1alpha1.MCPServerSpec{ + Transport: "stdio", + Command: "test-command", + ApprovalContactChannel: &kubechainv1alpha1.LocalObjectReference{ + Name: "test-channel", + }, + }, + } + + Expect(k8sClient.Create(ctx, mcpServer)).To(Succeed()) + + By("Creating the contact channel in not-ready state") + contactChannel := &kubechainv1alpha1.ContactChannel{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-channel", + Namespace: MCPServerNamespace, + }, + Status: kubechainv1alpha1.ContactChannelStatus{ + Ready: false, + Status: "Pending", + StatusDetail: "Initializing", + }, + } + Expect(k8sClient.Create(ctx, contactChannel)).To(Succeed()) + + By("Reconciling the MCPServer with not-ready contact channel") + recorder := record.NewFakeRecorder(10) + reconciler := &MCPServerReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + recorder: recorder, + MCPManager: &MockMCPServerManager{}, + } + + result, err := reconciler.Reconcile(ctx, ctrl.Request{ + NamespacedName: types.NamespacedName{Name: "mcpserver-channel-ready", Namespace: MCPServerNamespace}, + }) + Expect(err).NotTo(HaveOccurred()) // Should stay in pending because contact channel is not ready + Expect(result.RequeueAfter).To(Equal(time.Second * 5)) + + By("Checking that the status was updated to Pending") + createdMCPServer := &kubechainv1alpha1.MCPServer{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: "mcpserver-channel-ready", Namespace: MCPServerNamespace}, createdMCPServer) + Expect(err).NotTo(HaveOccurred()) + Expect(createdMCPServer.Status.Status).To(Equal("Pending")) + Expect(createdMCPServer.Status.StatusDetail).To(ContainSubstring("ContactChannel \"test-channel\" is not ready")) + utils.ExpectRecorder(recorder).ToEmitEventContaining("ContactChannelNotReady") + + By("Cleaning up the MCPServer and ContactChannel") + Expect(k8sClient.Delete(ctx, mcpServer)).To(Succeed()) + Expect(k8sClient.Delete(ctx, contactChannel)).To(Succeed()) + }) }) }) diff --git a/kubechain/internal/controller/mcpserver/suite_test.go b/kubechain/internal/controller/mcpserver/suite_test.go index cb96164..b67cec1 100644 --- a/kubechain/internal/controller/mcpserver/suite_test.go +++ b/kubechain/internal/controller/mcpserver/suite_test.go @@ -25,11 +25,13 @@ import ( // These tests use Ginkgo (BDD-style Go testing framework). Refer to // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. -var cfg *rest.Config -var k8sClient client.Client -var testEnv *envtest.Environment -var cancel context.CancelFunc -var ctx context.Context +var ( + cfg *rest.Config + k8sClient client.Client + testEnv *envtest.Environment + cancel context.CancelFunc + ctx context.Context +) func TestControllers(t *testing.T) { RegisterFailHandler(Fail) @@ -46,6 +48,7 @@ var _ = BeforeSuite(func() { testEnv = &envtest.Environment{ CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, ErrorIfCRDPathMissing: true, + BinaryAssetsDirectory: filepath.Join("..", "..", "..", "bin", "k8s", "1.32.0-darwin-arm64"), } var err error @@ -85,7 +88,6 @@ var _ = BeforeSuite(func() { err = k8sManager.Start(ctx) Expect(err).ToNot(HaveOccurred(), "Failed to run manager") }() - }) var _ = AfterSuite(func() {