diff --git a/pkg/mcp/common_test.go b/pkg/mcp/common_test.go index 0abe1418e..d08ddb5c5 100644 --- a/pkg/mcp/common_test.go +++ b/pkg/mcp/common_test.go @@ -80,6 +80,11 @@ func TestMain(m *testing.M) { CRD("instancetype.kubevirt.io", "v1beta1", "virtualmachineinstancetypes", "VirtualMachineInstancetype", "virtualmachineinstancetype", true), CRD("instancetype.kubevirt.io", "v1beta1", "virtualmachineclusterpreferences", "VirtualMachineClusterPreference", "virtualmachineclusterpreference", false), CRD("instancetype.kubevirt.io", "v1beta1", "virtualmachinepreferences", "VirtualMachinePreference", "virtualmachinepreference", true), + // OADP / Velero + CRD("velero.io", "v1", "backups", "Backup", "backup", true), + CRD("velero.io", "v1", "restores", "Restore", "restore", true), + CRD("velero.io", "v1", "backupstoragelocations", "BackupStorageLocation", "backupstoragelocation", true), + CRD("oadp.openshift.io", "v1alpha1", "dataprotectionapplications", "DataProtectionApplication", "dataprotectionapplication", true), }, } // Configure API server for faster CRD establishment and test performance diff --git a/pkg/mcp/oadp_test.go b/pkg/mcp/oadp_test.go new file mode 100644 index 000000000..75dcde9a7 --- /dev/null +++ b/pkg/mcp/oadp_test.go @@ -0,0 +1,185 @@ +package mcp + +import ( + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/suite" + "golang.org/x/sync/errgroup" + + oadpToolset "github.com/containers/kubernetes-mcp-server/pkg/toolsets/oadp" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" +) + +var oadpApis = []schema.GroupVersionResource{ + {Group: "velero.io", Version: "v1", Resource: "backups"}, + {Group: "velero.io", Version: "v1", Resource: "restores"}, + {Group: "velero.io", Version: "v1", Resource: "backupstoragelocations"}, + {Group: "oadp.openshift.io", Version: "v1alpha1", Resource: "dataprotectionapplications"}, +} + +type OADPSuite struct { + BaseMcpSuite +} + +func (s *OADPSuite) SetupSuite() { + ctx := s.T().Context() + tasks, _ := errgroup.WithContext(ctx) + for _, api := range oadpApis { + gvr := api + tasks.Go(func() error { return EnvTestEnableCRD(ctx, gvr.Group, gvr.Version, gvr.Resource) }) + } + s.Require().NoError(tasks.Wait()) + + _, err := kubernetes.NewForConfigOrDie(envTestRestConfig).CoreV1().Namespaces(). + Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "openshift-adp"}}, metav1.CreateOptions{}) + s.Require().NoError(err) +} + +func (s *OADPSuite) TearDownSuite() { + tasks, _ := errgroup.WithContext(s.T().Context()) + for _, api := range oadpApis { + gvr := api + tasks.Go(func() error { return EnvTestDisableCRD(s.T().Context(), gvr.Group, gvr.Version, gvr.Resource) }) + } + s.Require().NoError(tasks.Wait()) +} + +func (s *OADPSuite) SetupTest() { + s.BaseMcpSuite.SetupTest() + s.Cfg.Toolsets = append(s.Cfg.Toolsets, (&oadpToolset.Toolset{}).GetName()) + s.InitMcpClient() +} + +func (s *OADPSuite) TestToolsetRegistration() { + s.Run("toolset has no tools", func() { + tools, err := s.ListTools() + s.Require().NoError(err) + for _, tool := range tools.Tools { + s.Falsef(tool.Name == "oadp_backup" || tool.Name == "oadp_restore", + "OADP toolset should not expose dedicated tools, found: %s", tool.Name) + } + }) + + s.Run("toolset exposes oadp-troubleshoot prompt", func() { + prompts, err := s.ListPrompts() + s.Require().NoError(err) + var found bool + for _, prompt := range prompts.Prompts { + if prompt.Name == "oadp-troubleshoot" { + found = true + s.Equal("Generate a step-by-step troubleshooting guide for diagnosing OADP backup and restore issues", prompt.Description) + s.Len(prompt.Arguments, 3) + break + } + } + s.True(found, "expected oadp-troubleshoot prompt to be listed") + }) +} + +func (s *OADPSuite) TestTroubleshootPromptDefaultNamespace() { + result, err := s.GetPrompt("oadp-troubleshoot", map[string]string{}) + + s.Run("returns successfully", func() { + s.Require().NoError(err) + s.Require().NotNil(result) + }) + + s.Run("contains diagnostic sections", func() { + s.Require().NotNil(result) + s.Require().NotEmpty(result.Messages) + text := result.Messages[0].Content.(*mcp.TextContent).Text + s.Contains(text, "Namespace: openshift-adp") + s.Contains(text, "DataProtectionApplication") + s.Contains(text, "BackupStorageLocations") + s.Contains(text, "Recent Backups") + s.Contains(text, "Recent Restores") + s.Contains(text, "Velero Pods") + s.Contains(text, "Events") + }) + + s.Run("includes assistant analysis message", func() { + s.Require().NotNil(result) + s.Require().Len(result.Messages, 2) + s.Equal(mcp.Role("assistant"), result.Messages[1].Role) + }) +} + +func (s *OADPSuite) TestTroubleshootPromptCustomNamespace() { + result, err := s.GetPrompt("oadp-troubleshoot", map[string]string{ + "namespace": "custom-ns", + }) + + s.Run("uses custom namespace", func() { + s.Require().NoError(err) + s.Require().NotNil(result) + s.Require().NotEmpty(result.Messages) + text := result.Messages[0].Content.(*mcp.TextContent).Text + s.Contains(text, "Namespace: custom-ns") + }) +} + +func (s *OADPSuite) TestTroubleshootPromptWithBackup() { + s.Run("with existing backup", func() { + dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig) + backup := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "velero.io/v1", + "kind": "Backup", + "metadata": map[string]any{ + "name": "test-backup", + "namespace": "openshift-adp", + }, + "spec": map[string]any{ + "includedNamespaces": []any{"default"}, + }, + "status": map[string]any{ + "phase": "Completed", + }, + }, + } + _, err := dynamicClient.Resource(schema.GroupVersionResource{ + Group: "velero.io", Version: "v1", Resource: "backups", + }).Namespace("openshift-adp").Create(s.T().Context(), backup, metav1.CreateOptions{}) + s.Require().NoError(err) + + result, err := s.GetPrompt("oadp-troubleshoot", map[string]string{ + "backup": "test-backup", + }) + s.Require().NoError(err) + s.Require().NotNil(result) + text := result.Messages[0].Content.(*mcp.TextContent).Text + s.Contains(text, "Backup: test-backup") + }) + + s.Run("with non-existent backup", func() { + result, err := s.GetPrompt("oadp-troubleshoot", map[string]string{ + "backup": "non-existent", + }) + s.Require().NoError(err) + s.Require().NotNil(result) + text := result.Messages[0].Content.(*mcp.TextContent).Text + s.Contains(text, "Error fetching backup") + }) +} + +func (s *OADPSuite) TestTroubleshootPromptWithRestore() { + s.Run("with non-existent restore", func() { + result, err := s.GetPrompt("oadp-troubleshoot", map[string]string{ + "restore": "non-existent", + }) + s.Require().NoError(err) + s.Require().NotNil(result) + text := result.Messages[0].Content.(*mcp.TextContent).Text + s.Contains(text, "Error fetching restore") + }) +} + +func TestOADPSuite(t *testing.T) { + suite.Run(t, new(OADPSuite)) +} diff --git a/pkg/toolsets/oadp/toolset_test.go b/pkg/toolsets/oadp/toolset_test.go new file mode 100644 index 000000000..433b7106b --- /dev/null +++ b/pkg/toolsets/oadp/toolset_test.go @@ -0,0 +1,88 @@ +package oadp + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type ToolsetSuite struct { + suite.Suite +} + +func (s *ToolsetSuite) TestGetName() { + s.Run("returns oadp", func() { + t := &Toolset{} + s.Equal("oadp", t.GetName()) + }) +} + +func (s *ToolsetSuite) TestGetDescription() { + s.Run("returns non-empty description", func() { + t := &Toolset{} + s.NotEmpty(t.GetDescription()) + }) +} + +func (s *ToolsetSuite) TestGetTools() { + s.Run("returns nil", func() { + t := &Toolset{} + s.Nil(t.GetTools(nil)) + }) +} + +func (s *ToolsetSuite) TestGetPrompts() { + s.Run("returns one prompt", func() { + t := &Toolset{} + prompts := t.GetPrompts() + s.Len(prompts, 1) + }) + + s.Run("prompt is named oadp-troubleshoot", func() { + t := &Toolset{} + prompts := t.GetPrompts() + s.Require().Len(prompts, 1) + s.Equal("oadp-troubleshoot", prompts[0].Prompt.Name) + }) + + s.Run("prompt has three arguments", func() { + t := &Toolset{} + prompts := t.GetPrompts() + s.Require().Len(prompts, 1) + s.Len(prompts[0].Prompt.Arguments, 3) + }) + + s.Run("all prompt arguments are optional", func() { + t := &Toolset{} + prompts := t.GetPrompts() + s.Require().Len(prompts, 1) + for _, arg := range prompts[0].Prompt.Arguments { + s.False(arg.Required) + } + }) + + s.Run("prompt has a handler", func() { + t := &Toolset{} + prompts := t.GetPrompts() + s.Require().Len(prompts, 1) + s.NotNil(prompts[0].Handler) + }) +} + +func (s *ToolsetSuite) TestGetResources() { + s.Run("returns nil", func() { + t := &Toolset{} + s.Nil(t.GetResources()) + }) +} + +func (s *ToolsetSuite) TestGetResourceTemplates() { + s.Run("returns nil", func() { + t := &Toolset{} + s.Nil(t.GetResourceTemplates()) + }) +} + +func TestToolsetSuite(t *testing.T) { + suite.Run(t, new(ToolsetSuite)) +}