diff --git a/internal/tools/update-readme/main.go b/internal/tools/update-readme/main.go index cdf695fc..590cfc8d 100644 --- a/internal/tools/update-readme/main.go +++ b/internal/tools/update-readme/main.go @@ -15,6 +15,7 @@ import ( _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core" _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm" + _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt" ) type OpenShift struct{} diff --git a/pkg/api/toolsets.go b/pkg/api/toolsets.go index 9a990484..46dd9153 100644 --- a/pkg/api/toolsets.go +++ b/pkg/api/toolsets.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "fmt" internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" "github.com/containers/kubernetes-mcp-server/pkg/output" @@ -69,6 +70,58 @@ type ToolHandlerParams struct { ListOutput output.Output } +// GetRequiredString extracts a required string parameter from the tool call arguments. +// Returns an error if the parameter is missing or not a string. +func (p ToolHandlerParams) GetRequiredString(key string) (string, error) { + args := p.GetArguments() + val, ok := args[key] + if !ok { + return "", fmt.Errorf("%s parameter required", key) + } + str, ok := val.(string) + if !ok { + return "", fmt.Errorf("%s parameter must be a string", key) + } + return str, nil +} + +// GetOptionalString extracts an optional string parameter from the tool call arguments. +// Returns the provided default value if the parameter is missing or not a string. +// If no default value is provided, returns an empty string. +func (p ToolHandlerParams) GetOptionalString(key string, defaultValue ...string) string { + args := p.GetArguments() + val, ok := args[key] + if !ok { + if len(defaultValue) > 0 { + return defaultValue[0] + } + return "" + } + str, ok := val.(string) + if !ok { + if len(defaultValue) > 0 { + return defaultValue[0] + } + return "" + } + return str +} + +// GetOptionalBool extracts an optional boolean parameter from the tool call arguments. +// Returns false if the parameter is missing or not a boolean. +func (p ToolHandlerParams) GetOptionalBool(key string) bool { + args := p.GetArguments() + val, ok := args[key] + if !ok { + return false + } + b, ok := val.(bool) + if !ok { + return false + } + return b +} + type ToolHandlerFunc func(params ToolHandlerParams) (*ToolCallResult, error) type Tool struct { diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go index 22521667..3f98736a 100644 --- a/pkg/kubernetes-mcp-server/cmd/root_test.go +++ b/pkg/kubernetes-mcp-server/cmd/root_test.go @@ -137,7 +137,7 @@ func TestToolsets(t *testing.T) { rootCmd := NewMCPServer(ioStreams) rootCmd.SetArgs([]string{"--help"}) o, err := captureOutput(rootCmd.Execute) // --help doesn't use logger/klog, cobra prints directly to stdout - if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm).") { + if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm, kubevirt).") { t.Fatalf("Expected all available toolsets, got %s %v", o, err) } }) diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index 3b5733e1..a154b74e 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -2,6 +2,7 @@ package kubernetes import ( "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" "github.com/containers/kubernetes-mcp-server/pkg/helm" "k8s.io/client-go/kubernetes/scheme" @@ -30,6 +31,14 @@ func (k *Kubernetes) AccessControlClientset() *AccessControlClientset { return k.manager.accessControlClientSet } +// RESTConfig returns the Kubernetes REST configuration +func (k *Kubernetes) RESTConfig() *rest.Config { + if k.manager == nil { + return nil + } + return k.manager.cfg +} + var Scheme = scheme.Scheme var ParameterCodec = runtime.NewParameterCodec(Scheme) diff --git a/pkg/mcp/modules.go b/pkg/mcp/modules.go index 3295d72b..5356060e 100644 --- a/pkg/mcp/modules.go +++ b/pkg/mcp/modules.go @@ -3,3 +3,4 @@ package mcp import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config" import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core" import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm" +import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt" diff --git a/pkg/toolsets/kubevirt/toolset.go b/pkg/toolsets/kubevirt/toolset.go new file mode 100644 index 00000000..41257960 --- /dev/null +++ b/pkg/toolsets/kubevirt/toolset.go @@ -0,0 +1,38 @@ +package kubevirt + +import ( + "slices" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets" + vm_create "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt/vm/create" + vm_start "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt/vm/start" + vm_stop "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt/vm/stop" + vm_troubleshoot "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt/vm/troubleshoot" +) + +type Toolset struct{} + +var _ api.Toolset = (*Toolset)(nil) + +func (t *Toolset) GetName() string { + return "kubevirt" +} + +func (t *Toolset) GetDescription() string { + return "KubeVirt virtual machine management tools" +} + +func (t *Toolset) GetTools(o internalk8s.Openshift) []api.ServerTool { + return slices.Concat( + vm_create.Tools(), + vm_start.Tools(), + vm_stop.Tools(), + vm_troubleshoot.Tools(), + ) +} + +func init() { + toolsets.Register(&Toolset{}) +} diff --git a/pkg/toolsets/kubevirt/vm/create/plan.tmpl b/pkg/toolsets/kubevirt/vm/create/plan.tmpl new file mode 100644 index 00000000..758b0ee0 --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/create/plan.tmpl @@ -0,0 +1,99 @@ +# VirtualMachine Creation Plan + +**IMPORTANT**: Always use `runStrategy` instead of the deprecated `running` field when creating VirtualMachines. + +Use the `resources_create_or_update` tool with the following YAML: + +```yaml +apiVersion: kubevirt.io/v1 +kind: VirtualMachine +metadata: + name: {{.Name}} + namespace: {{.Namespace}} +spec: + runStrategy: Halted +{{- if .Instancetype}} + instancetype: + name: {{.Instancetype}} + kind: VirtualMachineClusterInstancetype +{{- end}} +{{- if .Preference}} + preference: + name: {{.Preference}} + kind: VirtualMachineClusterPreference +{{- end}} +{{- if .UseDataSource}} + dataVolumeTemplates: + - metadata: + name: {{.Name}}-rootdisk + spec: + sourceRef: + kind: DataSource + name: {{.DataSourceName}} + namespace: {{.DataSourceNamespace}} + storage: + resources: + requests: + storage: 30Gi +{{- end}} + template: + spec: + domain: + devices: + disks: + - name: {{.Name}}-rootdisk +{{- if not .Instancetype}} + memory: + guest: 2Gi +{{- end}} + volumes: + - name: {{.Name}}-rootdisk +{{- if .UseDataSource}} + dataVolume: + name: {{.Name}}-rootdisk +{{- else}} + containerDisk: + image: {{.ContainerDisk}} +{{- end}} +``` + +## Run Strategy Options + +The VM is created with `runStrategy: Halted` (stopped state). You can modify the `runStrategy` field to control the VM's execution: + +- **`Halted`** - VM is stopped and will not run +- **`Always`** - VM should always be running (restarts automatically) +- **`RerunOnFailure`** - Restart the VM only if it fails +- **`Manual`** - Manual start/stop control via `virtctl start/stop` +- **`Once`** - Run the VM once, then stop when it terminates + +To start the VM after creation, change `runStrategy: Halted` to `runStrategy: Always` or use the Manual strategy and start it with virtctl. + +## Verification + +After creating the VirtualMachine, verify it was created successfully: + +Use the `resources_get` tool: +- **apiVersion**: `kubevirt.io/v1` +- **kind**: `VirtualMachine` +- **namespace**: `{{.Namespace}}` +- **name**: `{{.Name}}` + +Check the resource details for any warnings or errors in the status conditions. + +## Troubleshooting + +If the VirtualMachine fails to create or start: + +1. **Check the VM resource details and events**: + - Use `resources_get` tool with apiVersion `kubevirt.io/v1`, kind `VirtualMachine`, namespace `{{.Namespace}}`, name `{{.Name}}` + - Look for error messages in the status conditions + +2. **Verify instance type exists** (if specified): + - Use `resources_get` tool with apiVersion `instancetype.kubevirt.io/v1beta1`, kind `VirtualMachineClusterInstancetype`, name `{{.Instancetype}}` + +3. **Verify preference exists** (if specified): + - Use `resources_get` tool with apiVersion `instancetype.kubevirt.io/v1beta1`, kind `VirtualMachineClusterPreference`, name `{{.Preference}}` + +4. **Check KubeVirt installation**: + - Use `pods_list` tool with namespace `kubevirt` diff --git a/pkg/toolsets/kubevirt/vm/create/tool.go b/pkg/toolsets/kubevirt/vm/create/tool.go new file mode 100644 index 00000000..c509c8ff --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/create/tool.go @@ -0,0 +1,766 @@ +package create + +import ( + _ "embed" + "fmt" + "strings" + "text/template" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/output" + "github.com/google/jsonschema-go/jsonschema" + 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/utils/ptr" +) + +const ( + defaultInstancetypeLabel = "instancetype.kubevirt.io/default-instancetype" + defaultPreferenceLabel = "instancetype.kubevirt.io/default-preference" + defaultWorkload = "fedora" +) + +//go:embed vm.yaml.tmpl +var vmYamlTemplate string + +func Tools() []api.ServerTool { + return []api.ServerTool{ + { + Tool: api.Tool{ + Name: "vm_create", + Description: "Create a VirtualMachine in the cluster with the specified configuration, automatically resolving instance types, preferences, and container disk images. VM will be created in Halted state by default; use autostart parameter to start it immediately.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "The namespace for the virtual machine", + }, + "name": { + Type: "string", + Description: "The name of the virtual machine", + }, + "workload": { + Type: "string", + Description: "The workload for the VM. Accepts OS names (e.g., 'fedora' (default), 'ubuntu', 'centos', 'centos-stream', 'debian', 'rhel', 'opensuse', 'opensuse-tumbleweed', 'opensuse-leap') or full container disk image URLs", + Examples: []interface{}{"fedora", "ubuntu", "centos", "debian", "rhel", "quay.io/containerdisks/fedora:latest"}, + }, + "instancetype": { + Type: "string", + Description: "Optional instance type name for the VM (e.g., 'u1.small', 'u1.medium', 'u1.large')", + }, + "preference": { + Type: "string", + Description: "Optional preference name for the VM", + }, + "size": { + Type: "string", + Description: "Optional workload size hint for the VM (e.g., 'small', 'medium', 'large', 'xlarge'). Used to auto-select an appropriate instance type if not explicitly specified.", + Examples: []interface{}{"small", "medium", "large"}, + }, + "performance": { + Type: "string", + Description: "Optional performance family hint for the VM instance type (e.g., 'u1' for general-purpose, 'o1' for overcommitted, 'c1' for compute-optimized, 'm1' for memory-optimized). Defaults to 'u1' (general-purpose) if not specified.", + Examples: []interface{}{"general-purpose", "overcommitted", "compute-optimized", "memory-optimized"}, + }, + "autostart": { + Type: "boolean", + Description: "Optional flag to automatically start the VM after creation (sets runStrategy to Always instead of Halted). Defaults to false.", + }, + }, + Required: []string{"namespace", "name"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Virtual Machine: Create", + ReadOnlyHint: ptr.To(false), + DestructiveHint: ptr.To(true), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(false), + }, + }, + Handler: create, + }, + } +} + +type vmParams struct { + Namespace string + Name string + ContainerDisk string + Instancetype string + Preference string + UseDataSource bool + DataSourceName string + DataSourceNamespace string + RunStrategy string +} + +type DataSourceInfo struct { + Name string + Namespace string + Source string + DefaultInstancetype string + DefaultPreference string +} + +type PreferenceInfo struct { + Name string +} + +type InstancetypeInfo struct { + Name string + Labels map[string]string +} + +func create(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Parse and validate input parameters + createParams, err := parseCreateParameters(params) + if err != nil { + return api.NewToolCallResult("", err), nil + } + + // Search for available DataSources + dataSources, _ := searchDataSources(params, createParams.Workload) + + // Match DataSource based on workload input + matchedDataSource := matchDataSource(dataSources, createParams.Workload) + + // Resolve preference from DataSource defaults or cluster resources + preference := resolvePreference(params, createParams.Preference, matchedDataSource, createParams.Workload, createParams.Namespace) + + // Resolve instancetype from DataSource defaults or size/performance hints + instancetype := resolveInstancetype(params, createParams, matchedDataSource) + + // Build template parameters from resolved resources + templateParams := buildTemplateParams(createParams, matchedDataSource, instancetype, preference) + + // Render the VM YAML + vmYaml, err := renderVMYaml(templateParams) + if err != nil { + return api.NewToolCallResult("", err), nil + } + + // Create the VM in the cluster + resources, err := params.ResourcesCreateOrUpdate(params, vmYaml) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to create VirtualMachine: %w", err)), nil + } + + // Format the output + marshalledYaml, err := output.MarshalYaml(resources) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to marshal created VirtualMachine: %w", err)), nil + } + + return api.NewToolCallResult("# VirtualMachine created successfully\n"+marshalledYaml, nil), nil +} + +// createParameters holds parsed input parameters for VM creation +type createParameters struct { + Namespace string + Name string + Workload string + Instancetype string + Preference string + Size string + Performance string + Autostart bool +} + +// parseCreateParameters parses and validates input parameters +func parseCreateParameters(params api.ToolHandlerParams) (*createParameters, error) { + namespace, err := params.GetRequiredString("namespace") + if err != nil { + return nil, err + } + + name, err := params.GetRequiredString("name") + if err != nil { + return nil, err + } + + return &createParameters{ + Namespace: namespace, + Name: name, + Workload: params.GetOptionalString("workload", defaultWorkload), + Instancetype: params.GetOptionalString("instancetype"), + Preference: params.GetOptionalString("preference"), + Size: params.GetOptionalString("size"), + Performance: normalizePerformance(params.GetOptionalString("performance")), + Autostart: params.GetOptionalBool("autostart"), + }, nil +} + +// matchDataSource finds a DataSource that matches the workload input +func matchDataSource(dataSources []DataSourceInfo, workload string) *DataSourceInfo { + normalizedInput := strings.ToLower(strings.TrimSpace(workload)) + + // First try exact match + for i := range dataSources { + ds := &dataSources[i] + if strings.EqualFold(ds.Name, normalizedInput) || strings.EqualFold(ds.Name, workload) { + return ds + } + } + + // If no exact match, try partial matching (e.g., "rhel" matches "rhel9") + // Only match against real DataSources with namespaces, not built-in containerdisks + for i := range dataSources { + ds := &dataSources[i] + // Only do partial matching for real DataSources (those with namespaces) + if ds.Namespace != "" && strings.Contains(strings.ToLower(ds.Name), normalizedInput) { + return ds + } + } + + return nil +} + +// resolvePreference determines the preference to use from DataSource defaults or cluster resources +func resolvePreference(params api.ToolHandlerParams, explicitPreference string, matchedDataSource *DataSourceInfo, workload string, namespace string) string { + // Use explicitly specified preference if provided + if explicitPreference != "" { + return explicitPreference + } + + // Use DataSource default preference if available + if matchedDataSource != nil && matchedDataSource.DefaultPreference != "" { + return matchedDataSource.DefaultPreference + } + + // Try to match preference name against the workload input + preferences := searchPreferences(params, namespace) + normalizedInput := strings.ToLower(strings.TrimSpace(workload)) + + for i := range preferences { + pref := &preferences[i] + // Common patterns: "fedora", "rhel.9", "ubuntu", etc. + if strings.Contains(strings.ToLower(pref.Name), normalizedInput) { + return pref.Name + } + } + + return "" +} + +// resolveInstancetype determines the instancetype to use from DataSource defaults or size/performance hints +func resolveInstancetype(params api.ToolHandlerParams, createParams *createParameters, matchedDataSource *DataSourceInfo) string { + // Use explicitly specified instancetype if provided + if createParams.Instancetype != "" { + return createParams.Instancetype + } + + // Use DataSource default instancetype if available (when size not specified) + if createParams.Size == "" && matchedDataSource != nil && matchedDataSource.DefaultInstancetype != "" { + return matchedDataSource.DefaultInstancetype + } + + // Match instancetype based on size and performance hints + if createParams.Size != "" { + return matchInstancetypeBySize(params, createParams.Size, createParams.Performance, createParams.Namespace) + } + + return "" +} + +// matchInstancetypeBySize finds an instancetype that matches the size and performance hints +func matchInstancetypeBySize(params api.ToolHandlerParams, size, performance, namespace string) string { + instancetypes := searchInstancetypes(params, namespace) + normalizedSize := strings.ToLower(strings.TrimSpace(size)) + normalizedPerformance := strings.ToLower(strings.TrimSpace(performance)) + + // Filter instance types by size + candidatesBySize := filterInstancetypesBySize(instancetypes, normalizedSize) + if len(candidatesBySize) == 0 { + return "" + } + + // Try to match by performance family prefix (e.g., "u1.small") + for i := range candidatesBySize { + it := &candidatesBySize[i] + if strings.HasPrefix(strings.ToLower(it.Name), normalizedPerformance+".") { + return it.Name + } + } + + // Try to match by performance family label + for i := range candidatesBySize { + it := &candidatesBySize[i] + if it.Labels != nil { + if class, ok := it.Labels["instancetype.kubevirt.io/class"]; ok { + if strings.EqualFold(class, normalizedPerformance) { + return it.Name + } + } + } + } + + // Fall back to first candidate that matches size + return candidatesBySize[0].Name +} + +// filterInstancetypesBySize filters instancetypes that contain the size hint in their name +func filterInstancetypesBySize(instancetypes []InstancetypeInfo, normalizedSize string) []InstancetypeInfo { + var candidates []InstancetypeInfo + for i := range instancetypes { + it := &instancetypes[i] + if strings.Contains(strings.ToLower(it.Name), normalizedSize) { + candidates = append(candidates, *it) + } + } + return candidates +} + +// buildTemplateParams constructs the template parameters for VM creation +func buildTemplateParams(createParams *createParameters, matchedDataSource *DataSourceInfo, instancetype, preference string) vmParams { + // Determine runStrategy based on autostart parameter + runStrategy := "Halted" + if createParams.Autostart { + runStrategy = "Always" + } + + params := vmParams{ + Namespace: createParams.Namespace, + Name: createParams.Name, + Instancetype: instancetype, + Preference: preference, + RunStrategy: runStrategy, + } + + if matchedDataSource != nil && matchedDataSource.Namespace != "" { + // Use the matched DataSource (real cluster DataSource with namespace) + params.UseDataSource = true + params.DataSourceName = matchedDataSource.Name + params.DataSourceNamespace = matchedDataSource.Namespace + } else if matchedDataSource != nil { + // Matched a built-in containerdisk (no namespace) + params.ContainerDisk = matchedDataSource.Source + } else { + // No match, resolve container disk image from workload name + params.ContainerDisk = resolveContainerDisk(createParams.Workload) + } + + return params +} + +// renderVMYaml renders the VM YAML from template +func renderVMYaml(templateParams vmParams) (string, error) { + tmpl, err := template.New("vm").Parse(vmYamlTemplate) + if err != nil { + return "", fmt.Errorf("failed to parse template: %w", err) + } + + var result strings.Builder + if err := tmpl.Execute(&result, templateParams); err != nil { + return "", fmt.Errorf("failed to render template: %w", err) + } + + return result.String(), nil +} + +// Helper functions + +func normalizePerformance(performance string) string { + // Normalize to lowercase and trim spaces + normalized := strings.ToLower(strings.TrimSpace(performance)) + + // Map natural language terms to instance type prefixes + performanceMap := map[string]string{ + "general-purpose": "u1", + "generalpurpose": "u1", + "general": "u1", + "overcommitted": "o1", + "compute": "c1", + "compute-optimized": "c1", + "computeoptimized": "c1", + "memory-optimized": "m1", + "memoryoptimized": "m1", + "memory": "m1", + "u1": "u1", + "o1": "o1", + "c1": "c1", + "m1": "m1", + } + + // Look up the mapping + if prefix, exists := performanceMap[normalized]; exists { + return prefix + } + + // Default to "u1" (general-purpose) if not recognized or empty + return "u1" +} + +// resolveContainerDisk resolves OS names to container disk images from quay.io/containerdisks +func resolveContainerDisk(input string) string { + // If input already looks like a container image, return as-is + if strings.Contains(input, "/") || strings.Contains(input, ":") { + return input + } + + // Common OS name mappings to containerdisk images + osMap := map[string]string{ + "fedora": "quay.io/containerdisks/fedora:latest", + "ubuntu": "quay.io/containerdisks/ubuntu:24.04", + "centos": "quay.io/containerdisks/centos-stream:9-latest", + "centos-stream": "quay.io/containerdisks/centos-stream:9-latest", + "debian": "quay.io/containerdisks/debian:latest", + "opensuse": "quay.io/containerdisks/opensuse-tumbleweed:1.0.0", + "opensuse-tumbleweed": "quay.io/containerdisks/opensuse-tumbleweed:1.0.0", + "opensuse-leap": "quay.io/containerdisks/opensuse-leap:15.6", + // NOTE: The following RHEL images could not be verified due to authentication requirements. + "rhel8": "registry.redhat.io/rhel8/rhel-guest-image:latest", + "rhel9": "registry.redhat.io/rhel9/rhel-guest-image:latest", + "rhel10": "registry.redhat.io/rhel10/rhel-guest-image:latest", + } + + // Normalize input to lowercase for lookup + normalized := strings.ToLower(strings.TrimSpace(input)) + + // Look up the OS name + if containerDisk, exists := osMap[normalized]; exists { + return containerDisk + } + + // If no match found, return the input as-is (assume it's a valid container image URL) + return input +} + +// getDefaultContainerDisks returns a list of common containerdisk images +func getDefaultContainerDisks() []DataSourceInfo { + return []DataSourceInfo{ + { + Name: "fedora", + Source: "quay.io/containerdisks/fedora:latest", + }, + { + Name: "ubuntu", + Source: "quay.io/containerdisks/ubuntu:24.04", + }, + { + Name: "centos-stream", + Source: "quay.io/containerdisks/centos-stream:9-latest", + }, + { + Name: "debian", + Source: "quay.io/containerdisks/debian:latest", + }, + { + Name: "rhel8", + Source: "registry.redhat.io/rhel8/rhel-guest-image:latest", + }, + { + Name: "rhel9", + Source: "registry.redhat.io/rhel9/rhel-guest-image:latest", + }, + { + Name: "rhel10", + Source: "registry.redhat.io/rhel10/rhel-guest-image:latest", + }, + } +} + +// searchDataSources searches for DataSource resources in the cluster +func searchDataSources(params api.ToolHandlerParams, query string) ([]DataSourceInfo, error) { + // Get dynamic client for querying DataSources + restConfig := params.RESTConfig() + if restConfig == nil { + return nil, fmt.Errorf("REST config is nil") + } + + dynamicClient, err := dynamic.NewForConfig(restConfig) + if err != nil { + // Return just the built-in containerdisk images + return getDefaultContainerDisks(), nil + } + + // DataSource GVR for CDI + dataSourceGVR := schema.GroupVersionResource{ + Group: "cdi.kubevirt.io", + Version: "v1beta1", + Resource: "datasources", + } + + // Collect DataSources from well-known namespaces and all namespaces + results := collectDataSources(params, dynamicClient, dataSourceGVR) + + // Add common containerdisk images + results = append(results, getDefaultContainerDisks()...) + + // Return helpful message if no sources found + if len(results) == 0 { + return []DataSourceInfo{ + { + Name: "No sources available", + Namespace: "", + Source: "No DataSources or containerdisks found", + }, + }, nil + } + + return results, nil +} + +// collectDataSources collects DataSources from well-known namespaces and all namespaces +func collectDataSources(params api.ToolHandlerParams, dynamicClient dynamic.Interface, gvr schema.GroupVersionResource) []DataSourceInfo { + var results []DataSourceInfo + + // Try to list DataSources from well-known namespaces first + wellKnownNamespaces := []string{ + "openshift-virtualization-os-images", + "kubevirt-os-images", + } + + for _, ns := range wellKnownNamespaces { + dsInfos, err := listDataSourcesFromNamespace(params, dynamicClient, gvr, ns) + if err == nil { + results = append(results, dsInfos...) + } + } + + // List DataSources from all namespaces + list, err := dynamicClient.Resource(gvr).List(params.Context, metav1.ListOptions{}) + if err != nil { + // If we found DataSources from well-known namespaces but couldn't list all, return what we have + if len(results) > 0 { + return results + } + // DataSources might not be available, return helpful message + return []DataSourceInfo{ + { + Name: "No DataSources found", + Namespace: "", + Source: "CDI may not be installed or DataSources are not available in this cluster", + }, + } + } + + // Deduplicate and add DataSources from all namespaces + results = deduplicateAndMergeDataSources(results, list.Items) + + return results +} + +// deduplicateAndMergeDataSources merges new DataSources with existing ones, avoiding duplicates +func deduplicateAndMergeDataSources(existing []DataSourceInfo, items []unstructured.Unstructured) []DataSourceInfo { + // Create a map to track already seen DataSources + seen := make(map[string]bool) + for _, ds := range existing { + key := ds.Namespace + "/" + ds.Name + seen[key] = true + } + + // Add new DataSources that haven't been seen + for _, item := range items { + name := item.GetName() + namespace := item.GetNamespace() + key := namespace + "/" + name + + // Skip if we've already added this DataSource + if seen[key] { + continue + } + + labels := item.GetLabels() + source := extractDataSourceInfo(&item) + + // Extract default instancetype and preference from labels + defaultInstancetype := "" + defaultPreference := "" + if labels != nil { + defaultInstancetype = labels[defaultInstancetypeLabel] + defaultPreference = labels[defaultPreferenceLabel] + } + + existing = append(existing, DataSourceInfo{ + Name: name, + Namespace: namespace, + Source: source, + DefaultInstancetype: defaultInstancetype, + DefaultPreference: defaultPreference, + }) + } + + return existing +} + +// listDataSourcesFromNamespace lists DataSources from a specific namespace +func listDataSourcesFromNamespace(params api.ToolHandlerParams, dynamicClient dynamic.Interface, gvr schema.GroupVersionResource, namespace string) ([]DataSourceInfo, error) { + var results []DataSourceInfo + list, err := dynamicClient.Resource(gvr).Namespace(namespace).List(params.Context, metav1.ListOptions{}) + if err != nil { + return nil, err + } + + for _, item := range list.Items { + name := item.GetName() + ns := item.GetNamespace() + labels := item.GetLabels() + + // Extract source information from the DataSource spec + source := extractDataSourceInfo(&item) + + // Extract default instancetype and preference from labels + defaultInstancetype := "" + defaultPreference := "" + if labels != nil { + defaultInstancetype = labels[defaultInstancetypeLabel] + defaultPreference = labels[defaultPreferenceLabel] + } + + results = append(results, DataSourceInfo{ + Name: name, + Namespace: ns, + Source: source, + DefaultInstancetype: defaultInstancetype, + DefaultPreference: defaultPreference, + }) + } + + return results, nil +} + +// searchPreferences searches for both cluster-wide and namespaced VirtualMachinePreference resources +func searchPreferences(params api.ToolHandlerParams, namespace string) []PreferenceInfo { + // Handle nil or invalid clients gracefully (e.g., in test environments) + if params.Kubernetes == nil { + return []PreferenceInfo{} + } + + restConfig := params.RESTConfig() + if restConfig == nil { + return []PreferenceInfo{} + } + + dynamicClient, err := dynamic.NewForConfig(restConfig) + if err != nil { + return []PreferenceInfo{} + } + + var results []PreferenceInfo + + // Search for cluster-wide VirtualMachineClusterPreferences + clusterPreferenceGVR := schema.GroupVersionResource{ + Group: "instancetype.kubevirt.io", + Version: "v1beta1", + Resource: "virtualmachineclusterpreferences", + } + + clusterList, err := dynamicClient.Resource(clusterPreferenceGVR).List(params.Context, metav1.ListOptions{}) + if err == nil { + for _, item := range clusterList.Items { + results = append(results, PreferenceInfo{ + Name: item.GetName(), + }) + } + } + + // Search for namespaced VirtualMachinePreferences + namespacedPreferenceGVR := schema.GroupVersionResource{ + Group: "instancetype.kubevirt.io", + Version: "v1beta1", + Resource: "virtualmachinepreferences", + } + + namespacedList, err := dynamicClient.Resource(namespacedPreferenceGVR).Namespace(namespace).List(params.Context, metav1.ListOptions{}) + if err == nil { + for _, item := range namespacedList.Items { + results = append(results, PreferenceInfo{ + Name: item.GetName(), + }) + } + } + + return results +} + +// searchInstancetypes searches for both cluster-wide and namespaced VirtualMachineInstancetype resources +func searchInstancetypes(params api.ToolHandlerParams, namespace string) []InstancetypeInfo { + // Handle nil or invalid clients gracefully (e.g., in test environments) + if params.Kubernetes == nil { + return []InstancetypeInfo{} + } + + restConfig := params.RESTConfig() + if restConfig == nil { + return []InstancetypeInfo{} + } + + dynamicClient, err := dynamic.NewForConfig(restConfig) + if err != nil { + return []InstancetypeInfo{} + } + + var results []InstancetypeInfo + + // Search for cluster-wide VirtualMachineClusterInstancetypes + clusterInstancetypeGVR := schema.GroupVersionResource{ + Group: "instancetype.kubevirt.io", + Version: "v1beta1", + Resource: "virtualmachineclusterinstancetypes", + } + + clusterList, err := dynamicClient.Resource(clusterInstancetypeGVR).List(params.Context, metav1.ListOptions{}) + if err == nil { + for _, item := range clusterList.Items { + results = append(results, InstancetypeInfo{ + Name: item.GetName(), + Labels: item.GetLabels(), + }) + } + } + + // Search for namespaced VirtualMachineInstancetypes + namespacedInstancetypeGVR := schema.GroupVersionResource{ + Group: "instancetype.kubevirt.io", + Version: "v1beta1", + Resource: "virtualmachineinstancetypes", + } + + namespacedList, err := dynamicClient.Resource(namespacedInstancetypeGVR).Namespace(namespace).List(params.Context, metav1.ListOptions{}) + if err == nil { + for _, item := range namespacedList.Items { + results = append(results, InstancetypeInfo{ + Name: item.GetName(), + Labels: item.GetLabels(), + }) + } + } + + return results +} + +// extractDataSourceInfo extracts source information from a DataSource object +func extractDataSourceInfo(obj *unstructured.Unstructured) string { + // Try to get the source from spec.source + spec, found, err := unstructured.NestedMap(obj.Object, "spec", "source") + if err != nil || !found { + return "unknown source" + } + + // Check for PVC source + if pvcInfo, found, _ := unstructured.NestedMap(spec, "pvc"); found { + if pvcName, found, _ := unstructured.NestedString(pvcInfo, "name"); found { + if pvcNamespace, found, _ := unstructured.NestedString(pvcInfo, "namespace"); found { + return fmt.Sprintf("PVC: %s/%s", pvcNamespace, pvcName) + } + return fmt.Sprintf("PVC: %s", pvcName) + } + } + + // Check for registry source + if registryInfo, found, _ := unstructured.NestedMap(spec, "registry"); found { + if url, found, _ := unstructured.NestedString(registryInfo, "url"); found { + return fmt.Sprintf("Registry: %s", url) + } + } + + // Check for http source + if url, found, _ := unstructured.NestedString(spec, "http", "url"); found { + return fmt.Sprintf("HTTP: %s", url) + } + + return "DataSource (type unknown)" +} diff --git a/pkg/toolsets/kubevirt/vm/create/tool_test.go b/pkg/toolsets/kubevirt/vm/create/tool_test.go new file mode 100644 index 00000000..0a19aa8f --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/create/tool_test.go @@ -0,0 +1,102 @@ +package create_test + +import ( + "context" + "testing" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt/vm/create" +) + +type mockToolCallRequest struct { + arguments map[string]interface{} +} + +func (m *mockToolCallRequest) GetArguments() map[string]any { + return m.arguments +} + +func TestCreateParameterValidation(t *testing.T) { + tests := []struct { + name string + args map[string]interface{} + wantErr bool + errMsg string + }{ + { + name: "missing namespace parameter", + args: map[string]interface{}{ + "name": "test-vm", + }, + wantErr: true, + errMsg: "namespace parameter required", + }, + { + name: "missing name parameter", + args: map[string]interface{}{ + "namespace": "test-ns", + }, + wantErr: true, + errMsg: "name parameter required", + }, + { + name: "invalid namespace type", + args: map[string]interface{}{ + "namespace": 123, + "name": "test-vm", + }, + wantErr: true, + errMsg: "namespace parameter must be a string", + }, + { + name: "invalid name type", + args: map[string]interface{}{ + "namespace": "test-ns", + "name": 456, + }, + wantErr: true, + errMsg: "name parameter must be a string", + }, + } + + // Get the tool through the public API + tools := create.Tools() + if len(tools) != 1 { + t.Fatalf("Expected 1 tool, got %d", len(tools)) + } + vmCreateTool := tools[0] + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params := api.ToolHandlerParams{ + Context: context.Background(), + Kubernetes: &internalk8s.Kubernetes{}, + ToolCallRequest: &mockToolCallRequest{arguments: tt.args}, + } + + // Call through the public Handler interface + result, err := vmCreateTool.Handler(params) + if err != nil { + t.Errorf("Handler() unexpected Go error: %v", err) + return + } + + if result == nil { + t.Error("Expected non-nil result") + return + } + + // For parameter validation errors, check the error message + if tt.wantErr && tt.errMsg != "" { + if result.Error == nil { + t.Error("Expected error in result.Error, got nil") + return + } + if result.Error.Error() != tt.errMsg { + t.Errorf("Expected error message %q, got %q", tt.errMsg, result.Error.Error()) + } + } + }) + } +} diff --git a/pkg/toolsets/kubevirt/vm/create/vm.yaml.tmpl b/pkg/toolsets/kubevirt/vm/create/vm.yaml.tmpl new file mode 100644 index 00000000..9982d4a9 --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/create/vm.yaml.tmpl @@ -0,0 +1,50 @@ +apiVersion: kubevirt.io/v1 +kind: VirtualMachine +metadata: + name: {{.Name}} + namespace: {{.Namespace}} +spec: + runStrategy: {{.RunStrategy}} +{{- if .Instancetype}} + instancetype: + name: {{.Instancetype}} + kind: VirtualMachineClusterInstancetype +{{- end}} +{{- if .Preference}} + preference: + name: {{.Preference}} + kind: VirtualMachineClusterPreference +{{- end}} +{{- if .UseDataSource}} + dataVolumeTemplates: + - metadata: + name: {{.Name}}-rootdisk + spec: + sourceRef: + kind: DataSource + name: {{.DataSourceName}} + namespace: {{.DataSourceNamespace}} + storage: + resources: + requests: + storage: 30Gi +{{- end}} + template: + spec: + domain: + devices: + disks: + - name: {{.Name}}-rootdisk +{{- if not .Instancetype}} + memory: + guest: 2Gi +{{- end}} + volumes: + - name: {{.Name}}-rootdisk +{{- if .UseDataSource}} + dataVolume: + name: {{.Name}}-rootdisk +{{- else}} + containerDisk: + image: {{.ContainerDisk}} +{{- end}} diff --git a/pkg/toolsets/kubevirt/vm/start/tool.go b/pkg/toolsets/kubevirt/vm/start/tool.go new file mode 100644 index 00000000..3295c914 --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/start/tool.go @@ -0,0 +1,110 @@ +package start + +import ( + "fmt" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/output" + "github.com/google/jsonschema-go/jsonschema" + 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/utils/ptr" +) + +func Tools() []api.ServerTool { + return []api.ServerTool{ + { + Tool: api.Tool{ + Name: "vm_start", + Description: "Start a halted or stopped VirtualMachine by changing its runStrategy to Always", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "The namespace of the virtual machine", + }, + "name": { + Type: "string", + Description: "The name of the virtual machine to start", + }, + }, + Required: []string{"namespace", "name"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Virtual Machine: Start", + ReadOnlyHint: ptr.To(false), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(false), + }, + }, + Handler: start, + }, + } +} + +func start(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Parse required parameters + namespace, err := params.GetRequiredString("namespace") + if err != nil { + return api.NewToolCallResult("", err), nil + } + + name, err := params.GetRequiredString("name") + if err != nil { + return api.NewToolCallResult("", err), nil + } + + // Get dynamic client + restConfig := params.RESTConfig() + if restConfig == nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get REST config")), nil + } + + dynamicClient, err := dynamic.NewForConfig(restConfig) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to create dynamic client: %w", err)), nil + } + + // Get the current VM + gvr := schema.GroupVersionResource{ + Group: "kubevirt.io", + Version: "v1", + Resource: "virtualmachines", + } + + vm, err := dynamicClient.Resource(gvr).Namespace(namespace).Get( + params.Context, + name, + metav1.GetOptions{}, + ) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get VirtualMachine: %w", err)), nil + } + + // Update runStrategy to Always + if err := unstructured.SetNestedField(vm.Object, "Always", "spec", "runStrategy"); err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to set runStrategy: %w", err)), nil + } + + // Update the VM + updatedVM, err := dynamicClient.Resource(gvr).Namespace(namespace).Update( + params.Context, + vm, + metav1.UpdateOptions{}, + ) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to update VirtualMachine: %w", err)), nil + } + + // Format the output + marshalledYaml, err := output.MarshalYaml(updatedVM) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to marshal VirtualMachine: %w", err)), nil + } + + return api.NewToolCallResult("# VirtualMachine started successfully\n"+marshalledYaml, nil), nil +} diff --git a/pkg/toolsets/kubevirt/vm/start/tool_test.go b/pkg/toolsets/kubevirt/vm/start/tool_test.go new file mode 100644 index 00000000..5acd1b88 --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/start/tool_test.go @@ -0,0 +1,110 @@ +package start_test + +import ( + "context" + "testing" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt/vm/start" +) + +type mockToolCallRequest struct { + arguments map[string]interface{} +} + +func (m *mockToolCallRequest) GetArguments() map[string]any { + return m.arguments +} + +func TestStartParameterValidation(t *testing.T) { + tests := []struct { + name string + args map[string]interface{} + wantErr bool + errMsg string + }{ + { + name: "missing namespace parameter", + args: map[string]interface{}{ + "name": "test-vm", + }, + wantErr: true, + errMsg: "namespace parameter required", + }, + { + name: "missing name parameter", + args: map[string]interface{}{ + "namespace": "test-ns", + }, + wantErr: true, + errMsg: "name parameter required", + }, + { + name: "invalid namespace type", + args: map[string]interface{}{ + "namespace": 123, + "name": "test-vm", + }, + wantErr: true, + errMsg: "namespace parameter must be a string", + }, + { + name: "invalid name type", + args: map[string]interface{}{ + "namespace": "test-ns", + "name": 456, + }, + wantErr: true, + errMsg: "name parameter must be a string", + }, + { + name: "valid parameters - cluster interaction expected", + args: map[string]interface{}{ + "namespace": "test-ns", + "name": "test-vm", + }, + wantErr: true, // Will fail due to missing cluster connection, but parameters are valid + }, + } + + // Get the tool through the public API + tools := start.Tools() + if len(tools) != 1 { + t.Fatalf("Expected 1 tool, got %d", len(tools)) + } + vmStartTool := tools[0] + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params := api.ToolHandlerParams{ + Context: context.Background(), + Kubernetes: &internalk8s.Kubernetes{}, + ToolCallRequest: &mockToolCallRequest{arguments: tt.args}, + } + + // Call through the public Handler interface + result, err := vmStartTool.Handler(params) + if err != nil { + t.Errorf("Handler() unexpected Go error: %v", err) + return + } + + if result == nil { + t.Error("Expected non-nil result") + return + } + + // For parameter validation errors, check the error message + if tt.wantErr && tt.errMsg != "" { + if result.Error == nil { + t.Error("Expected error in result.Error, got nil") + return + } + if result.Error.Error() != tt.errMsg { + t.Errorf("Expected error message %q, got %q", tt.errMsg, result.Error.Error()) + } + } + }) + } +} diff --git a/pkg/toolsets/kubevirt/vm/stop/tool.go b/pkg/toolsets/kubevirt/vm/stop/tool.go new file mode 100644 index 00000000..6267b269 --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/stop/tool.go @@ -0,0 +1,110 @@ +package stop + +import ( + "fmt" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/output" + "github.com/google/jsonschema-go/jsonschema" + 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/utils/ptr" +) + +func Tools() []api.ServerTool { + return []api.ServerTool{ + { + Tool: api.Tool{ + Name: "vm_stop", + Description: "Stop a running VirtualMachine by changing its runStrategy to Halted", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "The namespace of the virtual machine", + }, + "name": { + Type: "string", + Description: "The name of the virtual machine to stop", + }, + }, + Required: []string{"namespace", "name"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Virtual Machine: Stop", + ReadOnlyHint: ptr.To(false), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(false), + }, + }, + Handler: stop, + }, + } +} + +func stop(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Parse required parameters + namespace, err := params.GetRequiredString("namespace") + if err != nil { + return api.NewToolCallResult("", err), nil + } + + name, err := params.GetRequiredString("name") + if err != nil { + return api.NewToolCallResult("", err), nil + } + + // Get dynamic client + restConfig := params.RESTConfig() + if restConfig == nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get REST config")), nil + } + + dynamicClient, err := dynamic.NewForConfig(restConfig) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to create dynamic client: %w", err)), nil + } + + // Get the current VM + gvr := schema.GroupVersionResource{ + Group: "kubevirt.io", + Version: "v1", + Resource: "virtualmachines", + } + + vm, err := dynamicClient.Resource(gvr).Namespace(namespace).Get( + params.Context, + name, + metav1.GetOptions{}, + ) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get VirtualMachine: %w", err)), nil + } + + // Update runStrategy to Halted + if err := unstructured.SetNestedField(vm.Object, "Halted", "spec", "runStrategy"); err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to set runStrategy: %w", err)), nil + } + + // Update the VM + updatedVM, err := dynamicClient.Resource(gvr).Namespace(namespace).Update( + params.Context, + vm, + metav1.UpdateOptions{}, + ) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to update VirtualMachine: %w", err)), nil + } + + // Format the output + marshalledYaml, err := output.MarshalYaml(updatedVM) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to marshal VirtualMachine: %w", err)), nil + } + + return api.NewToolCallResult("# VirtualMachine stopped successfully\n"+marshalledYaml, nil), nil +} diff --git a/pkg/toolsets/kubevirt/vm/stop/tool_test.go b/pkg/toolsets/kubevirt/vm/stop/tool_test.go new file mode 100644 index 00000000..abbd67eb --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/stop/tool_test.go @@ -0,0 +1,110 @@ +package stop_test + +import ( + "context" + "testing" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt/vm/stop" +) + +type mockToolCallRequest struct { + arguments map[string]interface{} +} + +func (m *mockToolCallRequest) GetArguments() map[string]any { + return m.arguments +} + +func TestStopParameterValidation(t *testing.T) { + tests := []struct { + name string + args map[string]interface{} + wantErr bool + errMsg string + }{ + { + name: "missing namespace parameter", + args: map[string]interface{}{ + "name": "test-vm", + }, + wantErr: true, + errMsg: "namespace parameter required", + }, + { + name: "missing name parameter", + args: map[string]interface{}{ + "namespace": "test-ns", + }, + wantErr: true, + errMsg: "name parameter required", + }, + { + name: "invalid namespace type", + args: map[string]interface{}{ + "namespace": 123, + "name": "test-vm", + }, + wantErr: true, + errMsg: "namespace parameter must be a string", + }, + { + name: "invalid name type", + args: map[string]interface{}{ + "namespace": "test-ns", + "name": 456, + }, + wantErr: true, + errMsg: "name parameter must be a string", + }, + { + name: "valid parameters - cluster interaction expected", + args: map[string]interface{}{ + "namespace": "test-ns", + "name": "test-vm", + }, + wantErr: true, // Will fail due to missing cluster connection, but parameters are valid + }, + } + + // Get the tool through the public API + tools := stop.Tools() + if len(tools) != 1 { + t.Fatalf("Expected 1 tool, got %d", len(tools)) + } + vmStopTool := tools[0] + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params := api.ToolHandlerParams{ + Context: context.Background(), + Kubernetes: &internalk8s.Kubernetes{}, + ToolCallRequest: &mockToolCallRequest{arguments: tt.args}, + } + + // Call through the public Handler interface + result, err := vmStopTool.Handler(params) + if err != nil { + t.Errorf("Handler() unexpected Go error: %v", err) + return + } + + if result == nil { + t.Error("Expected non-nil result") + return + } + + // For parameter validation errors, check the error message + if tt.wantErr && tt.errMsg != "" { + if result.Error == nil { + t.Error("Expected error in result.Error, got nil") + return + } + if result.Error.Error() != tt.errMsg { + t.Errorf("Expected error message %q, got %q", tt.errMsg, result.Error.Error()) + } + } + }) + } +} diff --git a/pkg/toolsets/kubevirt/vm/troubleshoot/plan.tmpl b/pkg/toolsets/kubevirt/vm/troubleshoot/plan.tmpl new file mode 100644 index 00000000..abc9e22a --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/troubleshoot/plan.tmpl @@ -0,0 +1,188 @@ +# VirtualMachine Troubleshooting Guide + +## VM: {{.Name}} (namespace: {{.Namespace}}) + +Follow these steps to diagnose issues with the VirtualMachine: + +--- + +## Step 1: Check VirtualMachine Status + +Use the `resources_get` tool to inspect the VirtualMachine: +- **apiVersion**: `kubevirt.io/v1` +- **kind**: `VirtualMachine` +- **namespace**: `{{.Namespace}}` +- **name**: `{{.Name}}` + +**What to look for:** +- `status.printableStatus` - Should be "Running" for a healthy VM +- `status.ready` - Should be `true` +- `status.conditions` - Look for conditions with `status: "False"` or error messages +- `spec.runStrategy` - Check if it's "Always", "Manual", "Halted", or "RerunOnFailure" + +--- + +## Step 2: Check VirtualMachineInstance Status + +If the VM exists but isn't running, check if a VirtualMachineInstance was created: + +Use the `resources_get` tool: +- **apiVersion**: `kubevirt.io/v1` +- **kind**: `VirtualMachineInstance` +- **namespace**: `{{.Namespace}}` +- **name**: `{{.Name}}` + +**What to look for:** +- `status.phase` - Should be "Running" for a healthy VMI +- `status.conditions` - Check for "Ready" condition with `status: "True"` +- `status.guestOSInfo` - Confirms guest agent is running +- If VMI doesn't exist and VM runStrategy is "Always", this indicates a problem + +--- + +## Step 3: Check DataVolume Status (if applicable) + +If the VM uses DataVolumeTemplates, check their status: + +Use the `resources_list` tool: +- **apiVersion**: `cdi.kubevirt.io/v1beta1` +- **kind**: `DataVolume` +- **namespace**: `{{.Namespace}}` + +Look for DataVolumes with names starting with `{{.Name}}-` + +**What to look for:** +- `status.phase` - Should be "Succeeded" when ready +- `status.progress` - Shows import/clone progress (e.g., "100.0%") +- Common issues: + - Phase "Pending" - Waiting for resources + - Phase "ImportScheduled" or "ImportInProgress" - Still importing + - Phase "Failed" - Check `status.conditions` for error details + +### Check Underlying PersistentVolumeClaims + +DataVolumes create PVCs to provision storage. Check the PVC status: + +Use the `resources_list` tool: +- **apiVersion**: `v1` +- **kind**: `PersistentVolumeClaim` +- **namespace**: `{{.Namespace}}` + +Look for PVCs with names matching the DataVolume names (typically `{{.Name}}-*`) + +Or inspect a specific PVC with `resources_get`: +- **apiVersion**: `v1` +- **kind**: `PersistentVolumeClaim` +- **namespace**: `{{.Namespace}}` +- **name**: (name from DataVolume or VM volumes) + +**What to look for:** +- `status.phase` - Should be "Bound" when ready +- `spec.storageClassName` - Verify the storage class exists and is available +- `status.capacity.storage` - Confirms allocated storage size +- Common PVC issues: + - Phase "Pending" - Storage class not available, insufficient storage, or provisioner issues + - Missing PVC - DataVolume creation may have failed + - Incorrect size - Check if requested size matches available storage + +**Check Storage Class:** + +If PVC is stuck in "Pending", verify the storage class exists: + +Use the `resources_get` tool: +- **apiVersion**: `storage.k8s.io/v1` +- **kind**: `StorageClass` +- **name**: (from PVC `spec.storageClassName`) + +Ensure the storage class provisioner is healthy and has capacity. + +--- + +## Step 4: Check virt-launcher Pod + +The virt-launcher pod runs the actual VM. Find and inspect it: + +Use the `pods_list_in_namespace` tool: +- **namespace**: `{{.Namespace}}` +- **labelSelector**: `kubevirt.io=virt-launcher,vm.kubevirt.io/name={{.Name}}` + +**What to look for:** +- Pod should be in "Running" phase +- All containers should be ready (e.g., "2/2") +- Check pod events and conditions for errors + +If pod exists, get detailed status with `pods_get`: +- **namespace**: `{{.Namespace}}` +- **name**: `virt-launcher-{{.Name}}-xxxxx` (use actual pod name from list) + +Get pod logs with `pods_log`: +- **namespace**: `{{.Namespace}}` +- **name**: `virt-launcher-{{.Name}}-xxxxx` +- **container**: `compute` (main VM container) + +--- + +## Step 5: Check Events + +Events provide crucial diagnostic information: + +Use the `events_list` tool: +- **namespace**: `{{.Namespace}}` + +Filter output for events related to `{{.Name}}` - look for warnings or errors. + +--- + +## Step 6: Check Instance Type and Preference (if used) + +If the VM uses instance types or preferences, verify they exist: + +For instance types, use `resources_get`: +- **apiVersion**: `instancetype.kubevirt.io/v1beta1` +- **kind**: `VirtualMachineClusterInstancetype` +- **name**: (check VM spec for instancetype name) + +For preferences, use `resources_get`: +- **apiVersion**: `instancetype.kubevirt.io/v1beta1` +- **kind**: `VirtualMachineClusterPreference` +- **name**: (check VM spec for preference name) + +--- + +## Common Issues and Solutions + +### VM stuck in "Stopped" or "Halted" +- Check `spec.runStrategy` - if "Halted", the VM is intentionally stopped +- Change runStrategy to "Always" to start the VM + +### VMI doesn't exist +- Check VM conditions for admission errors +- Verify instance type and preference exist +- Check resource quotas in the namespace + +### DataVolume stuck in "ImportInProgress" +- Check CDI controller pods in `cdi` namespace +- Verify source image is accessible +- Check PVC storage class exists and has available capacity + +### virt-launcher pod in CrashLoopBackOff +- Check pod logs for container `compute` +- Common causes: + - Insufficient resources (CPU/memory) + - Invalid VM configuration + - Storage issues (PVC not available) + +### VM starts but guest doesn't boot +- Check virt-launcher logs for QEMU errors +- Verify boot disk is properly configured +- Check if guest agent is installed (for cloud images) +- Ensure correct architecture (amd64 vs arm64) + +--- + +## Additional Resources + +For more detailed diagnostics: +- Check KubeVirt components: `pods_list` in `kubevirt` namespace +- Check CDI components: `pods_list` in `cdi` namespace (if using DataVolumes) +- Review resource consumption: `pods_top` for the virt-launcher pod diff --git a/pkg/toolsets/kubevirt/vm/troubleshoot/tool.go b/pkg/toolsets/kubevirt/vm/troubleshoot/tool.go new file mode 100644 index 00000000..036bb49e --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/troubleshoot/tool.go @@ -0,0 +1,85 @@ +package troubleshoot + +import ( + _ "embed" + "fmt" + "strings" + "text/template" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/utils/ptr" +) + +//go:embed plan.tmpl +var planTemplate string + +func Tools() []api.ServerTool { + return []api.ServerTool{ + { + Tool: api.Tool{ + Name: "vm_troubleshoot", + Description: "Generate a comprehensive troubleshooting guide for a VirtualMachine, providing step-by-step instructions to diagnose common issues", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "The namespace of the virtual machine", + }, + "name": { + Type: "string", + Description: "The name of the virtual machine", + }, + }, + Required: []string{"namespace", "name"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Virtual Machine: Troubleshoot", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(false), + }, + }, + Handler: troubleshoot, + }, + } +} + +type troubleshootParams struct { + Namespace string + Name string +} + +func troubleshoot(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Parse required parameters + namespace, err := params.GetRequiredString("namespace") + if err != nil { + return api.NewToolCallResult("", err), nil + } + + name, err := params.GetRequiredString("name") + if err != nil { + return api.NewToolCallResult("", err), nil + } + + // Prepare template parameters + templateParams := troubleshootParams{ + Namespace: namespace, + Name: name, + } + + // Render template + tmpl, err := template.New("troubleshoot").Parse(planTemplate) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to parse template: %w", err)), nil + } + + var result strings.Builder + if err := tmpl.Execute(&result, templateParams); err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to render template: %w", err)), nil + } + + return api.NewToolCallResult(result.String(), nil), nil +} diff --git a/pkg/toolsets/kubevirt/vm/troubleshoot/tool_test.go b/pkg/toolsets/kubevirt/vm/troubleshoot/tool_test.go new file mode 100644 index 00000000..303e6ae4 --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/troubleshoot/tool_test.go @@ -0,0 +1,119 @@ +package troubleshoot_test + +import ( + "context" + "strings" + "testing" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt/vm/troubleshoot" +) + +type mockToolCallRequest struct { + arguments map[string]interface{} +} + +func (m *mockToolCallRequest) GetArguments() map[string]any { + return m.arguments +} + +func TestTroubleshoot(t *testing.T) { + tests := []struct { + name string + args map[string]interface{} + wantErr bool + checkFunc func(t *testing.T, result string) + }{ + { + name: "generates troubleshooting guide", + args: map[string]interface{}{ + "namespace": "test-ns", + "name": "test-vm", + }, + wantErr: false, + checkFunc: func(t *testing.T, result string) { + if !strings.Contains(result, "VirtualMachine Troubleshooting Guide") { + t.Errorf("Expected troubleshooting guide header") + } + if !strings.Contains(result, "test-vm") { + t.Errorf("Expected VM name in guide") + } + if !strings.Contains(result, "test-ns") { + t.Errorf("Expected namespace in guide") + } + if !strings.Contains(result, "Step 1: Check VirtualMachine Status") { + t.Errorf("Expected step 1 header") + } + if !strings.Contains(result, "resources_get") { + t.Errorf("Expected resources_get tool reference") + } + if !strings.Contains(result, "VirtualMachineInstance") { + t.Errorf("Expected VMI section") + } + if !strings.Contains(result, "virt-launcher") { + t.Errorf("Expected virt-launcher pod section") + } + }, + }, + { + name: "missing namespace", + args: map[string]interface{}{ + "name": "test-vm", + }, + wantErr: true, + }, + { + name: "missing name", + args: map[string]interface{}{ + "namespace": "test-ns", + }, + wantErr: true, + }, + } + + // Get the tool through the public API + tools := troubleshoot.Tools() + if len(tools) != 1 { + t.Fatalf("Expected 1 tool, got %d", len(tools)) + } + vmTroubleshootTool := tools[0] + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params := api.ToolHandlerParams{ + Context: context.Background(), + Kubernetes: &internalk8s.Kubernetes{}, + ToolCallRequest: &mockToolCallRequest{arguments: tt.args}, + } + + // Call through the public Handler interface + result, err := vmTroubleshootTool.Handler(params) + if err != nil { + t.Errorf("Handler() unexpected Go error: %v", err) + return + } + + if result == nil { + t.Error("Expected non-nil result") + return + } + + if tt.wantErr { + if result.Error == nil { + t.Error("Expected error in result.Error, got nil") + } + } else { + if result.Error != nil { + t.Errorf("Expected no error in result, got: %v", result.Error) + } + if result.Content == "" { + t.Error("Expected non-empty result content") + } + if tt.checkFunc != nil { + tt.checkFunc(t, result.Content) + } + } + }) + } +}