Skip to content

Commit ae988e2

Browse files
committed
WIP feat(kubevirt): Add VM management toolset
Assisted-By: Claude <[email protected]> Signed-off-by: Lee Yarwood <[email protected]>
1 parent c3bc991 commit ae988e2

File tree

11 files changed

+1325
-1
lines changed

11 files changed

+1325
-1
lines changed

go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ require (
2727
k8s.io/kubectl v0.34.1
2828
k8s.io/metrics v0.34.1
2929
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397
30+
kubevirt.io/api v1.6.2
3031
sigs.k8s.io/controller-runtime v0.22.3
3132
sigs.k8s.io/controller-runtime/tools/setup-envtest v0.0.0-20250211091558-894df3a7e664
3233
sigs.k8s.io/yaml v1.6.0
@@ -103,6 +104,7 @@ require (
103104
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
104105
github.com/opencontainers/go-digest v1.0.0 // indirect
105106
github.com/opencontainers/image-spec v1.1.1 // indirect
107+
github.com/openshift/custom-resource-status v1.1.2 // indirect
106108
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
107109
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
108110
github.com/prometheus/client_golang v1.22.0 // indirect
@@ -136,6 +138,8 @@ require (
136138
k8s.io/apiserver v0.34.1 // indirect
137139
k8s.io/component-base v0.34.1 // indirect
138140
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
141+
kubevirt.io/containerized-data-importer-api v1.60.3-0.20241105012228-50fbed985de9 // indirect
142+
kubevirt.io/controller-lifecycle-operator-sdk/api v0.0.0-20220329064328-f3cc58c6ed90 // indirect
139143
oras.land/oras-go/v2 v2.6.0 // indirect
140144
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
141145
sigs.k8s.io/kustomize/api v0.20.1 // indirect

go.sum

Lines changed: 221 additions & 0 deletions
Large diffs are not rendered by default.

pkg/kubernetes-mcp-server/cmd/root_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ func TestToolsets(t *testing.T) {
137137
rootCmd := NewMCPServer(ioStreams)
138138
rootCmd.SetArgs([]string{"--help"})
139139
o, err := captureOutput(rootCmd.Execute) // --help doesn't use logger/klog, cobra prints directly to stdout
140-
if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm).") {
140+
if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm, kubevirt).") {
141141
t.Fatalf("Expected all available toolsets, got %s %v", o, err)
142142
}
143143
})

pkg/kubernetes/accesscontrol_clientset.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ func (a *AccessControlClientset) DiscoveryClient() discovery.DiscoveryInterface
3939
return a.discoveryClient
4040
}
4141

42+
func (a *AccessControlClientset) RESTConfig() *rest.Config {
43+
return a.cfg
44+
}
45+
4246
func (a *AccessControlClientset) Pods(namespace string) (corev1.PodInterface, error) {
4347
gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}
4448
if !isAllowed(a.staticConfig, gvk) {

pkg/mcp/modules.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ package mcp
33
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
44
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
55
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"
6+
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt"
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package containerdisks
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
// Resolve resolves OS names to container disk images from quay.io/containerdisks
9+
func Resolve(input string) string {
10+
// If input already looks like a container image, return as-is
11+
if strings.Contains(input, "/") || strings.Contains(input, ":") {
12+
return input
13+
}
14+
15+
// Common OS name mappings to containerdisk images
16+
// Images are from the official KubeVirt containerdisks repository
17+
// See: https://github.com/kubevirt/containerdisks
18+
osMap := map[string]string{
19+
"fedora": "quay.io/containerdisks/fedora:latest",
20+
"ubuntu": "quay.io/containerdisks/ubuntu:latest",
21+
"centos": "quay.io/containerdisks/centos-stream:latest",
22+
"centos-stream": "quay.io/containerdisks/centos-stream:latest",
23+
"debian": "quay.io/containerdisks/debian:latest",
24+
"opensuse": "quay.io/containerdisks/opensuse-tumbleweed:latest",
25+
"opensuse-tumbleweed": "quay.io/containerdisks/opensuse-tumbleweed:latest",
26+
"opensuse-leap": "quay.io/containerdisks/opensuse-leap:latest",
27+
}
28+
29+
// Normalize input to lowercase for lookup
30+
normalized := strings.ToLower(strings.TrimSpace(input))
31+
32+
// Look up the OS name
33+
if containerDisk, exists := osMap[normalized]; exists {
34+
return containerDisk
35+
}
36+
37+
// If no match found, assume it's already a valid container disk name
38+
// and try to construct a containerdisks URL
39+
return fmt.Sprintf("quay.io/containerdisks/%s:latest", normalized)
40+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package containerdisks
2+
3+
import "testing"
4+
5+
func TestResolve(t *testing.T) {
6+
t.Run("resolves known OS names to containerdisk images", func(t *testing.T) {
7+
tests := []struct {
8+
input string
9+
expected string
10+
}{
11+
{"fedora", "quay.io/containerdisks/fedora:latest"},
12+
{"ubuntu", "quay.io/containerdisks/ubuntu:latest"},
13+
{"centos", "quay.io/containerdisks/centos-stream:latest"},
14+
{"centos-stream", "quay.io/containerdisks/centos-stream:latest"},
15+
{"debian", "quay.io/containerdisks/debian:latest"},
16+
{"opensuse", "quay.io/containerdisks/opensuse-tumbleweed:latest"},
17+
{"opensuse-tumbleweed", "quay.io/containerdisks/opensuse-tumbleweed:latest"},
18+
{"opensuse-leap", "quay.io/containerdisks/opensuse-leap:latest"},
19+
}
20+
21+
for _, tt := range tests {
22+
t.Run(tt.input, func(t *testing.T) {
23+
result := Resolve(tt.input)
24+
if result != tt.expected {
25+
t.Errorf("Expected %s, got %s", tt.expected, result)
26+
}
27+
})
28+
}
29+
})
30+
31+
t.Run("handles case insensitive input", func(t *testing.T) {
32+
tests := []struct {
33+
input string
34+
expected string
35+
}{
36+
{"FEDORA", "quay.io/containerdisks/fedora:latest"},
37+
{"Ubuntu", "quay.io/containerdisks/ubuntu:latest"},
38+
{"CentOS", "quay.io/containerdisks/centos-stream:latest"},
39+
}
40+
41+
for _, tt := range tests {
42+
t.Run(tt.input, func(t *testing.T) {
43+
result := Resolve(tt.input)
44+
if result != tt.expected {
45+
t.Errorf("Expected %s, got %s", tt.expected, result)
46+
}
47+
})
48+
}
49+
})
50+
51+
t.Run("handles input with extra whitespace", func(t *testing.T) {
52+
tests := []struct {
53+
input string
54+
expected string
55+
}{
56+
{" ubuntu ", "quay.io/containerdisks/ubuntu:latest"},
57+
{" fedora ", "quay.io/containerdisks/fedora:latest"},
58+
{" debian ", "quay.io/containerdisks/debian:latest"},
59+
}
60+
61+
for _, tt := range tests {
62+
t.Run(tt.input, func(t *testing.T) {
63+
result := Resolve(tt.input)
64+
if result != tt.expected {
65+
t.Errorf("Expected %s, got %s", tt.expected, result)
66+
}
67+
})
68+
}
69+
})
70+
71+
t.Run("returns full container image URLs as-is", func(t *testing.T) {
72+
tests := []struct {
73+
input string
74+
}{
75+
{"quay.io/containerdisks/fedora:38"},
76+
{"my-registry/my-image:v1.0"},
77+
{"docker.io/library/ubuntu"},
78+
{"registry.example.com/path/to/image:tag"},
79+
}
80+
81+
for _, tt := range tests {
82+
t.Run(tt.input, func(t *testing.T) {
83+
result := Resolve(tt.input)
84+
if result != tt.input {
85+
t.Errorf("Expected %s, got %s", tt.input, result)
86+
}
87+
})
88+
}
89+
})
90+
91+
t.Run("returns images with tags as-is", func(t *testing.T) {
92+
tests := []struct {
93+
input string
94+
}{
95+
{"myimage:v1.0"},
96+
{"image:latest"},
97+
{"test:123"},
98+
}
99+
100+
for _, tt := range tests {
101+
t.Run(tt.input, func(t *testing.T) {
102+
result := Resolve(tt.input)
103+
if result != tt.input {
104+
t.Errorf("Expected %s, got %s", tt.input, result)
105+
}
106+
})
107+
}
108+
})
109+
110+
t.Run("constructs containerdisks URL for unknown OS names", func(t *testing.T) {
111+
tests := []struct {
112+
input string
113+
expected string
114+
}{
115+
{"myos", "quay.io/containerdisks/myos:latest"},
116+
{"customlinux", "quay.io/containerdisks/customlinux:latest"},
117+
}
118+
119+
for _, tt := range tests {
120+
t.Run(tt.input, func(t *testing.T) {
121+
result := Resolve(tt.input)
122+
if result != tt.expected {
123+
t.Errorf("Expected %s, got %s", tt.expected, result)
124+
}
125+
})
126+
}
127+
})
128+
129+
t.Run("handles unknown OS with case normalization", func(t *testing.T) {
130+
result := Resolve("MyCustomOS")
131+
expected := "quay.io/containerdisks/mycustomos:latest"
132+
if result != expected {
133+
t.Errorf("Expected %s, got %s", expected, result)
134+
}
135+
})
136+
}

pkg/toolsets/kubevirt/toolset.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package kubevirt
2+
3+
import (
4+
"slices"
5+
6+
"github.com/containers/kubernetes-mcp-server/pkg/api"
7+
internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
8+
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
9+
)
10+
11+
type Toolset struct{}
12+
13+
var _ api.Toolset = (*Toolset)(nil)
14+
15+
func (t *Toolset) GetName() string {
16+
return "kubevirt"
17+
}
18+
19+
func (t *Toolset) GetDescription() string {
20+
return "KubeVirt virtual machine management tools (VMs, instance types, etc.)"
21+
}
22+
23+
func (t *Toolset) GetTools(o internalk8s.Openshift) []api.ServerTool {
24+
return slices.Concat(
25+
initVMs(),
26+
)
27+
}
28+
29+
func init() {
30+
toolsets.Register(&Toolset{})
31+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package kubevirt
2+
3+
import (
4+
"context"
5+
"testing"
6+
)
7+
8+
// mockOpenshift is a mock implementation of the Openshift interface
9+
type mockOpenshift struct {
10+
isOpenShift bool
11+
}
12+
13+
func (m *mockOpenshift) IsOpenShift(_ context.Context) bool {
14+
return m.isOpenShift
15+
}
16+
17+
func TestToolsetGetName(t *testing.T) {
18+
toolset := &Toolset{}
19+
if toolset.GetName() != "kubevirt" {
20+
t.Fatalf("Expected toolset name 'kubevirt', got %s", toolset.GetName())
21+
}
22+
}
23+
24+
func TestToolsetGetDescription(t *testing.T) {
25+
toolset := &Toolset{}
26+
desc := toolset.GetDescription()
27+
if desc == "" {
28+
t.Fatal("Expected non-empty description")
29+
}
30+
if desc != "KubeVirt virtual machine management tools (VMs, instance types, etc.)" {
31+
t.Fatalf("Unexpected description: %s", desc)
32+
}
33+
}
34+
35+
func TestToolsetGetTools(t *testing.T) {
36+
toolset := &Toolset{}
37+
38+
t.Run("returns tools for non-OpenShift cluster", func(t *testing.T) {
39+
mock := &mockOpenshift{isOpenShift: false}
40+
tools := toolset.GetTools(mock)
41+
if len(tools) == 0 {
42+
t.Fatal("Expected at least one tool to be returned")
43+
}
44+
45+
// Verify we have the expected VM tools
46+
toolNames := make(map[string]bool)
47+
for _, tool := range tools {
48+
toolNames[tool.Tool.Name] = true
49+
}
50+
51+
expectedTools := []string{"vm_start", "vm_stop", "vm_restart", "vm_create"}
52+
for _, expectedTool := range expectedTools {
53+
if !toolNames[expectedTool] {
54+
t.Fatalf("Expected tool %s to be present", expectedTool)
55+
}
56+
}
57+
})
58+
59+
t.Run("returns tools for OpenShift cluster", func(t *testing.T) {
60+
mock := &mockOpenshift{isOpenShift: true}
61+
tools := toolset.GetTools(mock)
62+
if len(tools) == 0 {
63+
t.Fatal("Expected at least one tool to be returned")
64+
}
65+
66+
// The same tools should be available in OpenShift
67+
toolNames := make(map[string]bool)
68+
for _, tool := range tools {
69+
toolNames[tool.Tool.Name] = true
70+
}
71+
72+
expectedTools := []string{"vm_start", "vm_stop", "vm_restart", "vm_create"}
73+
for _, expectedTool := range expectedTools {
74+
if !toolNames[expectedTool] {
75+
t.Fatalf("Expected tool %s to be present", expectedTool)
76+
}
77+
}
78+
})
79+
80+
t.Run("all tools have valid metadata", func(t *testing.T) {
81+
mock := &mockOpenshift{isOpenShift: false}
82+
tools := toolset.GetTools(mock)
83+
for _, tool := range tools {
84+
if tool.Tool.Name == "" {
85+
t.Fatal("Tool name should not be empty")
86+
}
87+
if tool.Tool.Description == "" {
88+
t.Fatal("Tool description should not be empty")
89+
}
90+
if tool.Tool.InputSchema == nil {
91+
t.Fatalf("Tool %s should have input schema", tool.Tool.Name)
92+
}
93+
if tool.Handler == nil {
94+
t.Fatalf("Tool %s should have handler", tool.Tool.Name)
95+
}
96+
}
97+
})
98+
}

0 commit comments

Comments
 (0)