Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 83 additions & 120 deletions pkg/mcp/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,120 +222,6 @@ func (c *mcpContext) withKubeConfig(rc *rest.Config) *clientcmdapi.Config {
return fakeConfig
}

// withEnvTest sets up the environment for kubeconfig to be used with envTest
func (c *mcpContext) withEnvTest() {
c.withKubeConfig(envTestRestConfig)
}

// inOpenShift sets up the kubernetes environment to seem to be running OpenShift
func inOpenShift(c *mcpContext) {
c.withEnvTest()
crdTemplate := `
{
"apiVersion": "apiextensions.k8s.io/v1",
"kind": "CustomResourceDefinition",
"metadata": {"name": "%s"},
"spec": {
"group": "%s",
"versions": [{
"name": "v1","served": true,"storage": true,
"schema": {"openAPIV3Schema": {"type": "object","x-kubernetes-preserve-unknown-fields": true}}
}],
"scope": "%s",
"names": {"plural": "%s","singular": "%s","kind": "%s"}
}
}`
tasks, _ := errgroup.WithContext(c.ctx)
tasks.Go(func() error {
return c.crdApply(fmt.Sprintf(crdTemplate, "projects.project.openshift.io", "project.openshift.io",
"Cluster", "projects", "project", "Project"))
})
tasks.Go(func() error {
return c.crdApply(fmt.Sprintf(crdTemplate, "routes.route.openshift.io", "route.openshift.io",
"Namespaced", "routes", "route", "Route"))
})
if err := tasks.Wait(); err != nil {
panic(err)
}
}

// inOpenShiftClear clears the kubernetes environment so it no longer seems to be running OpenShift
func inOpenShiftClear(c *mcpContext) {
tasks, _ := errgroup.WithContext(c.ctx)
tasks.Go(func() error { return c.crdDelete("projects.project.openshift.io") })
tasks.Go(func() error { return c.crdDelete("routes.route.openshift.io") })
if err := tasks.Wait(); err != nil {
panic(err)
}
}

// newKubernetesClient creates a new Kubernetes client with the envTest kubeconfig
func (c *mcpContext) newKubernetesClient() *kubernetes.Clientset {
return kubernetes.NewForConfigOrDie(envTestRestConfig)
}

// newApiExtensionsClient creates a new ApiExtensions client with the envTest kubeconfig
func (c *mcpContext) newApiExtensionsClient() *apiextensionsv1.ApiextensionsV1Client {
return apiextensionsv1.NewForConfigOrDie(envTestRestConfig)
}

// crdApply creates a CRD from the provided resource string and waits for it to be established
func (c *mcpContext) crdApply(resource string) error {
apiExtensionsV1Client := c.newApiExtensionsClient()
var crd = &apiextensionsv1spec.CustomResourceDefinition{}
err := json.Unmarshal([]byte(resource), crd)
if err != nil {
return fmt.Errorf("failed to create CRD %v", err)
}
_, err = apiExtensionsV1Client.CustomResourceDefinitions().Create(c.ctx, crd, metav1.CreateOptions{})
if err != nil {
return fmt.Errorf("failed to create CRD %v", err)
}
c.crdWaitUntilReady(crd.Name)
return nil
}

// crdDelete deletes a CRD by name and waits for it to be removed
func (c *mcpContext) crdDelete(name string) error {
apiExtensionsV1Client := c.newApiExtensionsClient()
err := apiExtensionsV1Client.CustomResourceDefinitions().Delete(c.ctx, name, metav1.DeleteOptions{
GracePeriodSeconds: ptr.To(int64(0)),
})
iteration := 0
for iteration < 100 {
if _, derr := apiExtensionsV1Client.CustomResourceDefinitions().Get(c.ctx, name, metav1.GetOptions{}); derr != nil {
break
}
time.Sleep(5 * time.Millisecond)
iteration++
}
if err != nil {
return errors.Wrap(err, "failed to delete CRD")
}
return nil
}

// crdWaitUntilReady waits for a CRD to be established
func (c *mcpContext) crdWaitUntilReady(name string) {
watcher, err := c.newApiExtensionsClient().CustomResourceDefinitions().Watch(c.ctx, metav1.ListOptions{
FieldSelector: "metadata.name=" + name,
})
if err != nil {
panic(fmt.Errorf("failed to watch CRD %v", err))
}
_, err = toolswatch.UntilWithoutRetry(c.ctx, watcher, func(event watch.Event) (bool, error) {
for _, c := range event.Object.(*apiextensionsv1spec.CustomResourceDefinition).Status.Conditions {
if c.Type == apiextensionsv1spec.Established && c.Status == apiextensionsv1spec.ConditionTrue {
return true, nil
}
}
return false, nil
})
if err != nil {
panic(fmt.Errorf("failed to wait for CRD %v", err))
}
}

// callTool helper function to call a tool by name with arguments
func (c *mcpContext) callTool(name string, args map[string]interface{}) (*mcp.CallToolResult, error) {
callToolRequest := mcp.CallToolRequest{}
Expand Down Expand Up @@ -446,20 +332,97 @@ func (s *BaseMcpSuite) InitMcpClient(options ...transport.StreamableHTTPCOption)
s.McpClient = test.NewMcpClient(s.T(), s.mcpServer.ServeHTTP(nil), options...)
}

// CrdWaitUntilReady waits for a CRD to be established
func (s *BaseMcpSuite) CrdWaitUntilReady(name string) {
// EnvTestInOpenShift sets up the kubernetes environment to seem to be running OpenShift
func EnvTestInOpenShift(ctx context.Context) error {
crdTemplate := `
{
"apiVersion": "apiextensions.k8s.io/v1",
"kind": "CustomResourceDefinition",
"metadata": {"name": "%s"},
"spec": {
"group": "%s",
"versions": [{
"name": "v1","served": true,"storage": true,
"schema": {"openAPIV3Schema": {"type": "object","x-kubernetes-preserve-unknown-fields": true}}
}],
"scope": "%s",
"names": {"plural": "%s","singular": "%s","kind": "%s"}
}
}`
tasks, _ := errgroup.WithContext(ctx)
tasks.Go(func() error {
return EnvTestCrdApply(ctx, fmt.Sprintf(crdTemplate, "projects.project.openshift.io", "project.openshift.io",
"Cluster", "projects", "project", "Project"))
})
tasks.Go(func() error {
return EnvTestCrdApply(ctx, fmt.Sprintf(crdTemplate, "routes.route.openshift.io", "route.openshift.io",
"Namespaced", "routes", "route", "Route"))
})
return tasks.Wait()
}

// EnvTestInOpenShiftClear clears the kubernetes environment so it no longer seems to be running OpenShift
func EnvTestInOpenShiftClear(ctx context.Context) error {
tasks, _ := errgroup.WithContext(ctx)
tasks.Go(func() error { return EnvTestCrdDelete(ctx, "projects.project.openshift.io") })
tasks.Go(func() error { return EnvTestCrdDelete(ctx, "routes.route.openshift.io") })
return tasks.Wait()
}

// EnvTestCrdWaitUntilReady waits for a CRD to be established
func EnvTestCrdWaitUntilReady(ctx context.Context, name string) error {
apiExtensionClient := apiextensionsv1.NewForConfigOrDie(envTestRestConfig)
watcher, err := apiExtensionClient.CustomResourceDefinitions().Watch(s.T().Context(), metav1.ListOptions{
watcher, err := apiExtensionClient.CustomResourceDefinitions().Watch(ctx, metav1.ListOptions{
FieldSelector: "metadata.name=" + name,
})
s.Require().NoError(err, "failed to watch CRD")
_, err = toolswatch.UntilWithoutRetry(s.T().Context(), watcher, func(event watch.Event) (bool, error) {
if err != nil {
return fmt.Errorf("unable to watch CRDs: %w", err)
}
_, err = toolswatch.UntilWithoutRetry(ctx, watcher, func(event watch.Event) (bool, error) {
for _, c := range event.Object.(*apiextensionsv1spec.CustomResourceDefinition).Status.Conditions {
if c.Type == apiextensionsv1spec.Established && c.Status == apiextensionsv1spec.ConditionTrue {
return true, nil
}
}
return false, nil
})
s.Require().NoError(err, "failed to wait for CRD")
if err != nil {
return fmt.Errorf("failed to wait for CRD: %w", err)
}
return nil
}

// EnvTestCrdApply creates a CRD from the provided resource string and waits for it to be established
func EnvTestCrdApply(ctx context.Context, resource string) error {
apiExtensionsV1Client := apiextensionsv1.NewForConfigOrDie(envTestRestConfig)
var crd = &apiextensionsv1spec.CustomResourceDefinition{}
err := json.Unmarshal([]byte(resource), crd)
if err != nil {
return fmt.Errorf("failed to create CRD %v", err)
}
_, err = apiExtensionsV1Client.CustomResourceDefinitions().Create(ctx, crd, metav1.CreateOptions{})
if err != nil {
return fmt.Errorf("failed to create CRD %v", err)
}
return EnvTestCrdWaitUntilReady(ctx, crd.Name)
}

// crdDelete deletes a CRD by name and waits for it to be removed
func EnvTestCrdDelete(ctx context.Context, name string) error {
apiExtensionsV1Client := apiextensionsv1.NewForConfigOrDie(envTestRestConfig)
err := apiExtensionsV1Client.CustomResourceDefinitions().Delete(ctx, name, metav1.DeleteOptions{
GracePeriodSeconds: ptr.To(int64(0)),
})
iteration := 0
for iteration < 100 {
if _, derr := apiExtensionsV1Client.CustomResourceDefinitions().Get(ctx, name, metav1.GetOptions{}); derr != nil {
break
}
time.Sleep(5 * time.Millisecond)
iteration++
}
if err != nil {
return errors.Wrap(err, "failed to delete CRD")
}
return nil
}
80 changes: 38 additions & 42 deletions pkg/mcp/namespaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"sigs.k8s.io/yaml"

"github.com/containers/kubernetes-mcp-server/internal/test"
"github.com/containers/kubernetes-mcp-server/pkg/config"
)

type NamespacesSuite struct {
Expand Down Expand Up @@ -108,68 +105,67 @@ func (s *NamespacesSuite) TestNamespacesListAsTable() {
})
}

func TestNamespaces(t *testing.T) {
suite.Run(t, new(NamespacesSuite))
}
func (s *NamespacesSuite) TestProjectsListInOpenShift() {
s.Require().NoError(EnvTestInOpenShift(s.T().Context()), "Expected to configure test for OpenShift")
s.T().Cleanup(func() {
s.Require().NoError(EnvTestInOpenShiftClear(s.T().Context()), "Expected to clear OpenShift test configuration")
})
s.InitMcpClient()

func TestProjectsListInOpenShift(t *testing.T) {
testCaseWithContext(t, &mcpContext{before: inOpenShift, after: inOpenShiftClear}, func(c *mcpContext) {
s.Run("projects_list returns project list in OpenShift", func() {
dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig)
_, _ = dynamicClient.Resource(schema.GroupVersionResource{Group: "project.openshift.io", Version: "v1", Resource: "projects"}).
Create(c.ctx, &unstructured.Unstructured{Object: map[string]interface{}{
Create(s.T().Context(), &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "project.openshift.io/v1",
"kind": "Project",
"metadata": map[string]interface{}{
"name": "an-openshift-project",
},
}}, metav1.CreateOptions{})
toolResult, err := c.callTool("projects_list", map[string]interface{}{})
t.Run("projects_list returns project list", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
toolResult, err := s.CallTool("projects_list", map[string]interface{}{})
s.Run("no error", func() {
s.Nilf(err, "call tool failed %v", err)
s.Falsef(toolResult.IsError, "call tool failed")
})
var decoded []unstructured.Unstructured
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
t.Run("projects_list has yaml content", func(t *testing.T) {
if err != nil {
t.Fatalf("invalid tool result content %v", err)
}
s.Run("has yaml content", func() {
s.Nilf(err, "invalid tool result content %v", err)
})
t.Run("projects_list returns at least 1 items", func(t *testing.T) {
if len(decoded) < 1 {
t.Errorf("invalid project count, expected at least 1, got %v", len(decoded))
}
s.Run("returns at least 1 item", func() {
s.GreaterOrEqualf(len(decoded), 1, "invalid project count, expected at least 1, got %v", len(decoded))
idx := slices.IndexFunc(decoded, func(ns unstructured.Unstructured) bool {
return ns.GetName() == "an-openshift-project"
})
if idx == -1 {
t.Errorf("namespace %s not found in the list", "an-openshift-project")
}
s.NotEqualf(-1, idx, "namespace %s not found in the list", "an-openshift-project")
})
})
}

func TestProjectsListInOpenShiftDenied(t *testing.T) {
deniedResourcesServer := test.Must(config.ReadToml([]byte(`
func (s *NamespacesSuite) TestProjectsListInOpenShiftDenied() {
s.Require().NoError(toml.Unmarshal([]byte(`
denied_resources = [ { group = "project.openshift.io", version = "v1" } ]
`)))
testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer, before: inOpenShift, after: inOpenShiftClear}, func(c *mcpContext) {
c.withEnvTest()
projectsList, _ := c.callTool("projects_list", map[string]interface{}{})
t.Run("projects_list has error", func(t *testing.T) {
if !projectsList.IsError {
t.Fatalf("call tool should fail")
}
`), s.Cfg), "Expected to parse denied resources config")
s.Require().NoError(EnvTestInOpenShift(s.T().Context()), "Expected to configure test for OpenShift")
s.T().Cleanup(func() {
s.Require().NoError(EnvTestInOpenShiftClear(s.T().Context()), "Expected to clear OpenShift test configuration")
})
s.InitMcpClient()

s.Run("projects_list (denied)", func() {
projectsList, err := s.CallTool("projects_list", map[string]interface{}{})
s.Run("has error", func() {
s.Truef(projectsList.IsError, "call tool should fail")
s.Nilf(err, "call tool should not return error object")
})
t.Run("projects_list describes denial", func(t *testing.T) {
s.Run("describes denial", func() {
expectedMessage := "failed to list projects: resource not allowed: project.openshift.io/v1, Kind=Project"
if projectsList.Content[0].(mcp.TextContent).Text != expectedMessage {
t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, projectsList.Content[0].(mcp.TextContent).Text)
}
s.Equalf(expectedMessage, projectsList.Content[0].(mcp.TextContent).Text,
"expected descriptive error '%s', got %v", expectedMessage, projectsList.Content[0].(mcp.TextContent).Text)
})
})
}

func TestNamespaces(t *testing.T) {
suite.Run(t, new(NamespacesSuite))
}
Loading
Loading