diff --git a/.gitignore b/.gitignore index 12b624e7..dbb0e3cb 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ python/build/ python/dist/ python/kubernetes_mcp_server.egg-info/ !python/kubernetes-mcp-server + +.gevals-step* +gevals-kubevirt-vm-operations-out.json 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/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..f8b21137 --- /dev/null +++ b/pkg/toolsets/kubevirt/toolset.go @@ -0,0 +1,34 @@ +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_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_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..e8e1f4ba --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/create/plan.tmpl @@ -0,0 +1,115 @@ +# VirtualMachine Creation Plan + +## ⚠️ IMPORTANT: Deprecated Field Warning + +**DO NOT use the `running` field** - it is deprecated in KubeVirt. Always use `runStrategy` instead. + +❌ **INCORRECT** (deprecated): +```yaml +spec: + running: true # DO NOT USE - deprecated field +``` + +✅ **CORRECT**: +```yaml +spec: + runStrategy: Always # Use runStrategy instead +``` + +## VirtualMachine YAML + +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..3cecdb35 --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/create/tool.go @@ -0,0 +1,781 @@ +package create + +import ( + _ "embed" + "fmt" + "strings" + "text/template" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "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" +) + +//go:embed plan.tmpl +var planTemplate string + +func Tools() []api.ServerTool { + return []api.ServerTool{ + { + Tool: api.Tool{ + Name: "vm_create", + Description: "Generate a comprehensive creation plan for a VirtualMachine, including pre-creation checks for instance types, preferences, and container disk images", + 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"}, + }, + }, + Required: []string{"namespace", "name"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Virtual Machine: Create", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + 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 +} + +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 creation plan template + result, err := renderTemplate(templateParams) + if err != nil { + return api.NewToolCallResult("", err), nil + } + + return api.NewToolCallResult(result, 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 +} + +// parseCreateParameters parses and validates input parameters +func parseCreateParameters(params api.ToolHandlerParams) (*createParameters, error) { + namespace, err := getRequiredString(params, "namespace") + if err != nil { + return nil, err + } + + name, err := getRequiredString(params, "name") + if err != nil { + return nil, err + } + + workload := getOptionalString(params, "workload") + if workload == "" { + workload = "fedora" // Default to fedora if not specified + } + + performance := normalizePerformance(getOptionalString(params, "performance")) + + return &createParameters{ + Namespace: namespace, + Name: name, + Workload: workload, + Instancetype: getOptionalString(params, "instancetype"), + Preference: getOptionalString(params, "preference"), + Size: getOptionalString(params, "size"), + Performance: performance, + }, 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 { + params := vmParams{ + Namespace: createParams.Namespace, + Name: createParams.Name, + Instancetype: instancetype, + Preference: preference, + } + + 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 +} + +// renderTemplate renders the VM creation plan template +func renderTemplate(templateParams vmParams) (string, error) { + tmpl, err := template.New("vm").Parse(planTemplate) + 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" +} + +func getRequiredString(params api.ToolHandlerParams, key string) (string, error) { + args := params.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 +} + +func getOptionalString(params api.ToolHandlerParams, key string) string { + args := params.GetArguments() + val, ok := args[key] + if !ok { + return "" + } + str, ok := val.(string) + if !ok { + return "" + } + return str +} + +// 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 + dynamicClient, err := getDynamicClient(params) + 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 +} + +// getDynamicClient creates a dynamic Kubernetes client from the provided parameters +func getDynamicClient(params api.ToolHandlerParams) (dynamic.Interface, error) { + // Handle nil or invalid clients gracefully (e.g., in test environments) + if params.Kubernetes == nil { + return nil, fmt.Errorf("kubernetes client is nil") + } + + restConfig := params.RESTConfig() + if restConfig == nil { + return nil, fmt.Errorf("REST config is nil") + } + + return dynamic.NewForConfig(restConfig) +} + +// 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..7d3a834e --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/create/tool_test.go @@ -0,0 +1,205 @@ +package create + +import ( + "context" + "strings" + "testing" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" +) + +type mockToolCallRequest struct { + arguments map[string]interface{} +} + +func (m *mockToolCallRequest) GetArguments() map[string]any { + return m.arguments +} + +func TestCreate(t *testing.T) { + tests := []struct { + name string + args map[string]interface{} + wantErr bool + checkFunc func(t *testing.T, result string) + }{ + { + name: "creates VM with basic settings", + args: map[string]interface{}{ + "namespace": "test-ns", + "name": "test-vm", + "workload": "fedora", + }, + wantErr: false, + checkFunc: func(t *testing.T, result string) { + if !strings.Contains(result, "VirtualMachine Creation Plan") { + t.Errorf("Expected 'VirtualMachine Creation Plan' header in result") + } + if !strings.Contains(result, "name: test-vm") { + t.Errorf("Expected VM name test-vm in YAML") + } + if !strings.Contains(result, "namespace: test-ns") { + t.Errorf("Expected namespace test-ns in YAML") + } + if !strings.Contains(result, "quay.io/containerdisks/fedora:latest") { + t.Errorf("Expected fedora container disk in result") + } + if !strings.Contains(result, "guest: 2Gi") { + t.Errorf("Expected guest: 2Gi in YAML manifest") + } + }, + }, + { + name: "creates VM with instancetype", + args: map[string]interface{}{ + "namespace": "test-ns", + "name": "test-vm", + "workload": "ubuntu", + "instancetype": "u1.medium", + }, + wantErr: false, + checkFunc: func(t *testing.T, result string) { + if !strings.Contains(result, "name: u1.medium") { + t.Errorf("Expected instance type in YAML manifest") + } + if !strings.Contains(result, "kind: VirtualMachineClusterInstancetype") { + t.Errorf("Expected VirtualMachineClusterInstancetype in YAML manifest") + } + // When instancetype is set, memory should not be in the YAML resources section + if strings.Contains(result, "resources:\n requests:\n memory:") { + t.Errorf("Should not have memory resources when instancetype is specified") + } + }, + }, + { + name: "creates VM with preference", + args: map[string]interface{}{ + "namespace": "test-ns", + "name": "test-vm", + "workload": "rhel", + "preference": "rhel.9", + }, + wantErr: false, + checkFunc: func(t *testing.T, result string) { + if !strings.Contains(result, "name: rhel.9") { + t.Errorf("Expected preference in YAML manifest") + } + if !strings.Contains(result, "kind: VirtualMachineClusterPreference") { + t.Errorf("Expected VirtualMachineClusterPreference in YAML manifest") + } + }, + }, + { + name: "creates VM with custom container disk", + args: map[string]interface{}{ + "namespace": "test-ns", + "name": "test-vm", + "workload": "quay.io/myrepo/myimage:v1.0", + }, + wantErr: false, + checkFunc: func(t *testing.T, result string) { + if !strings.Contains(result, "quay.io/myrepo/myimage:v1.0") { + t.Errorf("Expected custom container disk in YAML") + } + }, + }, + { + name: "missing namespace", + args: map[string]interface{}{ + "name": "test-vm", + "workload": "fedora", + }, + wantErr: true, + }, + { + name: "missing name", + args: map[string]interface{}{ + "namespace": "test-ns", + "workload": "fedora", + }, + wantErr: true, + }, + { + name: "missing workload defaults to fedora", + args: map[string]interface{}{ + "namespace": "test-ns", + "name": "test-vm", + }, + wantErr: false, + checkFunc: func(t *testing.T, result string) { + if !strings.Contains(result, "quay.io/containerdisks/fedora:latest") { + t.Errorf("Expected default fedora container disk in result") + } + }, + }, + } + + 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}, + } + + result, err := create(params) + if err != nil { + t.Errorf("create() 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) + } + } + }) + } +} + +func TestResolveContainerDisk(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"fedora", "fedora", "quay.io/containerdisks/fedora:latest"}, + {"ubuntu", "ubuntu", "quay.io/containerdisks/ubuntu:24.04"}, + {"rhel8", "rhel8", "registry.redhat.io/rhel8/rhel-guest-image:latest"}, + {"rhel9", "rhel9", "registry.redhat.io/rhel9/rhel-guest-image:latest"}, + {"rhel10", "rhel10", "registry.redhat.io/rhel10/rhel-guest-image:latest"}, + {"centos", "centos", "quay.io/containerdisks/centos-stream:9-latest"}, + {"centos-stream", "centos-stream", "quay.io/containerdisks/centos-stream:9-latest"}, + {"debian", "debian", "quay.io/containerdisks/debian:latest"}, + {"case insensitive", "FEDORA", "quay.io/containerdisks/fedora:latest"}, + {"with whitespace", " ubuntu ", "quay.io/containerdisks/ubuntu:24.04"}, + {"custom image", "quay.io/myrepo/myimage:v1", "quay.io/myrepo/myimage:v1"}, + {"with tag", "myimage:latest", "myimage:latest"}, + {"unknown OS", "customos", "customos"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := resolveContainerDisk(tt.input) + if result != tt.expected { + t.Errorf("resolveContainerDisk(%s) = %s, want %s", tt.input, result, tt.expected) + } + }) + } +} diff --git a/pkg/toolsets/kubevirt/vm/tests/README.md b/pkg/toolsets/kubevirt/vm/tests/README.md new file mode 100644 index 00000000..72f7556d --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/tests/README.md @@ -0,0 +1,214 @@ +# KubeVirt VM Toolset Tests + +This directory contains gevals-based tests for the KubeVirt VM toolset in the Kubernetes MCP Server. + +## Overview + +These tests validate the VM creation and troubleshooting tools (`vm_create` and `vm_troubleshoot`) by having AI agents complete real tasks using the MCP server. + +## Test Structure + +``` +tests/ +├── README.md # This file +├── mcp-config.yaml # MCP server configuration +├── claude-code/ # Claude Code agent configuration +│ ├── agent.yaml +│ └── eval.yaml +└── tasks/ # Test tasks + ├── create-vm-basic/ # Basic VM creation test + ├── create-vm-with-instancetype/ # VM with specific instancetype + ├── create-vm-with-size/ # VM with size parameter + ├── create-vm-ubuntu/ # Ubuntu VM creation + ├── create-vm-with-performance/ # VM with performance family + └── troubleshoot-vm/ # VM troubleshooting test +``` + +## Prerequisites + +1. **Kubernetes cluster** with KubeVirt installed + - The cluster must have KubeVirt CRDs installed + - For testing, you can use a Kind cluster with KubeVirt + +2. **Kubernetes MCP Server** running at `http://localhost:8888/mcp` + + ```bash + # Build and run the server + cd /path/to/kubernetes-mcp-server + make build + ./kubernetes-mcp-server --port 8888 + ``` + +3. **gevals binary** built from the gevals project + + ```bash + cd /path/to/gevals + go build -o gevals ./cmd/gevals + ``` + +4. **Claude Code** installed and in PATH + + ```bash + # Install Claude Code (if not already installed) + npm install -g @anthropicsdk/claude-code + ``` + +5. **kubectl** configured to access your cluster + +## Running the Tests + +### Run All Tests + +```bash +# From the gevals directory +./gevals eval /path/to/kubernetes-mcp-server/pkg/toolsets/kubevirt/vm/tests/claude-code/eval.yaml +``` + +### Run a Specific Test + +```bash +# Run just the basic VM creation test +./gevals eval /path/to/kubernetes-mcp-server/pkg/toolsets/kubevirt/vm/tests/tasks/create-vm-basic/create-vm-basic.yaml \ + --agent-file /path/to/kubernetes-mcp-server/pkg/toolsets/kubevirt/vm/tests/claude-code/agent.yaml \ + --mcp-config-file /path/to/kubernetes-mcp-server/pkg/toolsets/kubevirt/vm/tests/mcp-config.yaml +``` + +## Test Descriptions + +### create-vm-basic + +**Difficulty:** Easy +**Description:** Tests basic VM creation with default Fedora workload. +**Key Tool:** `vm_create` +**Expected Behavior:** Agent should use `vm_create` to generate a plan and then create the VM using `resources_create_or_update`. + +### create-vm-with-instancetype + +**Difficulty:** Medium +**Description:** Tests VM creation with a specific instancetype (u1.medium). +**Key Tool:** `vm_create` +**Expected Behavior:** Agent should pass the instancetype parameter to `vm_create` and create a VM with the correct instancetype reference. + +### create-vm-with-size + +**Difficulty:** Medium +**Description:** Tests VM creation using a size hint ('large'). +**Key Tool:** `vm_create` +**Expected Behavior:** Agent should use the size parameter which should map to an appropriate instancetype. + +### create-vm-ubuntu + +**Difficulty:** Easy +**Description:** Tests VM creation with Ubuntu workload. +**Key Tool:** `vm_create` +**Expected Behavior:** Agent should create a VM using the Ubuntu container disk image. + +### create-vm-with-performance + +**Difficulty:** Medium +**Description:** Tests VM creation with performance family ('compute-optimized') and size. +**Key Tool:** `vm_create` +**Expected Behavior:** Agent should combine performance and size to select an appropriate instancetype (e.g., c1.medium). + +### troubleshoot-vm + +**Difficulty:** Easy +**Description:** Tests VM troubleshooting guide generation. +**Key Tool:** `vm_troubleshoot` +**Expected Behavior:** Agent should use `vm_troubleshoot` to generate a troubleshooting guide for the VM. + +## Assertions + +The tests validate: + +- **Tool Usage:** Agents must call `vm_create`, `vm_troubleshoot`, or `resources_*` tools +- **Call Limits:** Between 1 and 30 tool calls (allows for exploration and creation) +- **Task Success:** Verification scripts confirm VMs are created correctly + +## Expected Results + +**✅ Pass** means: + +- The VM tools are well-designed and discoverable +- Tool descriptions are clear to AI agents +- Schemas are properly structured +- Implementation works correctly + +**❌ Fail** indicates: + +- Tool descriptions may need improvement +- Schema complexity issues +- Missing functionality +- Implementation bugs + +## Output + +Results are saved to `gevals-kubevirt-vm-operations-out.json` with: + +- Task pass/fail status +- Assertion results +- Tool call history +- Agent interactions + +## Customization + +### Using Different AI Agents + +You can create additional agent configurations (similar to the `claude-code/` directory) for testing with different AI models: + +```yaml +# Example: openai-agent/agent.yaml +kind: Agent +metadata: + name: "openai-agent" +commands: + argTemplateMcpServer: "{{ .File }}" + runPrompt: |- + agent-wrapper.sh {{ .McpServerFileArgs }} "{{ .Prompt }}" +``` + +### Adding New Tests + +To add a new test task: + +1. Create a new directory under `tasks/` +2. Add task YAML file with prompt +3. Add setup, verify, and cleanup scripts +4. The test will be automatically discovered by the glob pattern in `eval.yaml` + +## Troubleshooting + +### Tests Fail to Connect to MCP Server + +Ensure the Kubernetes MCP Server is running: + +```bash +curl http://localhost:8888/mcp/health +``` + +### VirtualMachine Not Created + +Check if KubeVirt is installed: + +```bash +kubectl get crds | grep kubevirt +kubectl get pods -n kubevirt +``` + +### Permission Issues + +Ensure your kubeconfig has permissions to: + +- Create namespaces +- Create VirtualMachine resources +- List instancetypes and preferences + +## Contributing + +When adding new tests: + +- Keep tasks focused on a single capability +- Make verification scripts robust +- Document expected behavior +- Set appropriate difficulty levels +- Ensure cleanup scripts remove all resources diff --git a/pkg/toolsets/kubevirt/vm/tests/claude-code/agent.yaml b/pkg/toolsets/kubevirt/vm/tests/claude-code/agent.yaml new file mode 100644 index 00000000..20b715c0 --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/tests/claude-code/agent.yaml @@ -0,0 +1,10 @@ +kind: Agent +metadata: + name: "claude-code" +commands: + useVirtualHome: false + argTemplateMcpServer: "--mcp-config {{ .File }}" + argTemplateAllowedTools: "mcp__{{ .ServerName }}__{{ .ToolName }}" + allowedToolsJoinSeparator: "," + runPrompt: |- + claude {{ .McpServerFileArgs }} --strict-mcp-config --allowedTools "{{ .AllowedToolArgs }}" --print "{{ .Prompt }}" diff --git a/pkg/toolsets/kubevirt/vm/tests/claude-code/eval.yaml b/pkg/toolsets/kubevirt/vm/tests/claude-code/eval.yaml new file mode 100644 index 00000000..01478cd6 --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/tests/claude-code/eval.yaml @@ -0,0 +1,14 @@ +kind: Eval +metadata: + name: "kubevirt-vm-operations" +config: + agentFile: agent.yaml + mcpConfigFile: ../mcp-config.yaml + taskSets: + - glob: ../tasks/*/*.yaml + assertions: + toolsUsed: + - server: kubernetes + toolPattern: "(vm_create|vm_troubleshoot|resources_.*)" + minToolCalls: 1 + maxToolCalls: 30 diff --git a/pkg/toolsets/kubevirt/vm/tests/helpers/README.md b/pkg/toolsets/kubevirt/vm/tests/helpers/README.md new file mode 100644 index 00000000..941d8291 --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/tests/helpers/README.md @@ -0,0 +1,189 @@ +# Test Verification Helpers + +This directory contains shared helper functions for VirtualMachine test verification. + +## Usage + +Source the helper script in your test verification section: + +```bash +#!/usr/bin/env bash +source "$(dirname "${BASH_SOURCE[0]}")/../../helpers/verify-vm.sh" + +# Use helper functions +verify_vm_exists "test-vm" "vm-test" || exit 1 +verify_container_disk "test-vm" "vm-test" "fedora" || exit 1 +verify_run_strategy "test-vm" "vm-test" || exit 1 +verify_no_deprecated_running_field "test-vm" "vm-test" || exit 1 +``` + +## Available Functions + +### verify_vm_exists +Waits for a VirtualMachine to be created. + +**Usage:** `verify_vm_exists [timeout]` + +**Example:** +```bash +verify_vm_exists "my-vm" "vm-test" "30s" || exit 1 +``` + +**Default timeout:** 30s + +--- + +### verify_container_disk +Verifies that a VM uses a specific container disk OS (checks all volumes). + +**Usage:** `verify_container_disk ` + +**Example:** +```bash +verify_container_disk "my-vm" "vm-test" "fedora" || exit 1 +verify_container_disk "ubuntu-vm" "vm-test" "ubuntu" || exit 1 +``` + +--- + +### verify_run_strategy +Verifies that runStrategy is set (checks both spec and status). + +**Usage:** `verify_run_strategy ` + +**Example:** +```bash +verify_run_strategy "my-vm" "vm-test" || exit 1 +``` + +**Note:** This function accepts runStrategy in either `spec.runStrategy` or `status.runStrategy` to accommodate VMs created with the deprecated `running` field. + +--- + +### verify_no_deprecated_running_field +Verifies that the deprecated `running` field is NOT set in the VirtualMachine spec. + +**Usage:** `verify_no_deprecated_running_field ` + +**Example:** +```bash +verify_no_deprecated_running_field "my-vm" "vm-test" || exit 1 +``` + +**Note:** The `running` field is deprecated in KubeVirt. VirtualMachines should use `runStrategy` instead. This function ensures compliance with current best practices. + +--- + +### verify_instancetype +Verifies that a VM has an instancetype reference with optional exact match. + +**Usage:** `verify_instancetype [expected-instancetype] [expected-kind]` + +**Examples:** +```bash +# Just verify instancetype exists +verify_instancetype "my-vm" "vm-test" || exit 1 + +# Verify specific instancetype +verify_instancetype "my-vm" "vm-test" "u1.medium" || exit 1 + +# Verify instancetype and kind +verify_instancetype "my-vm" "vm-test" "u1.medium" "VirtualMachineClusterInstancetype" || exit 1 +``` + +**Default kind:** VirtualMachineClusterInstancetype + +--- + +### verify_instancetype_contains +Verifies that instancetype name contains a substring (e.g., size like "large"). + +**Usage:** `verify_instancetype_contains [description]` + +**Example:** +```bash +verify_instancetype_contains "my-vm" "vm-test" "large" "requested size 'large'" +verify_instancetype_contains "my-vm" "vm-test" "medium" +``` + +**Note:** Returns success even if substring not found (prints warning only). + +--- + +### verify_instancetype_prefix +Verifies that instancetype starts with a specific prefix (e.g., performance family like "c1"). + +**Usage:** `verify_instancetype_prefix [description]` + +**Example:** +```bash +verify_instancetype_prefix "my-vm" "vm-test" "c1" "compute-optimized" +verify_instancetype_prefix "my-vm" "vm-test" "u1" "general-purpose" +``` + +**Note:** Returns success even if prefix doesn't match (prints warning only). + +--- + +### verify_no_direct_resources +Verifies that VM uses instancetype for resources (no direct memory specification). + +**Usage:** `verify_no_direct_resources ` + +**Example:** +```bash +verify_no_direct_resources "my-vm" "vm-test" +``` + +**Note:** Returns success even if direct resources found (prints warning only). + +--- + +### verify_has_resources_or_instancetype +Verifies that VM has either an instancetype or direct resource specification. + +**Usage:** `verify_has_resources_or_instancetype ` + +**Example:** +```bash +verify_has_resources_or_instancetype "my-vm" "vm-test" || exit 1 +``` + +**Note:** Fails only if neither instancetype nor direct resources are present. + +## Design Principles + +1. **Flexible matching**: Functions use pattern matching instead of exact volume names to handle different VM creation approaches. + +2. **Clear output**: Each function prints clear success (✓) or failure (✗) messages. + +3. **Warning vs Error**: Some functions print warnings (⚠) for non-critical mismatches but still return success. + +4. **Return codes**: Functions return 0 for success, 1 for failure. Always check return codes with `|| exit 1` for critical validations. + +## Example Test Verification + +```bash +#!/usr/bin/env bash +source "$(dirname "${BASH_SOURCE[0]}")/../../helpers/verify-vm.sh" + +# Wait for VM to exist +verify_vm_exists "test-vm" "vm-test" || exit 1 + +# Verify container disk +verify_container_disk "test-vm" "vm-test" "fedora" || exit 1 + +# Verify runStrategy is used (not deprecated 'running' field) +verify_run_strategy "test-vm" "vm-test" || exit 1 +verify_no_deprecated_running_field "test-vm" "vm-test" || exit 1 + +# Verify instancetype with size +verify_instancetype "test-vm" "vm-test" || exit 1 +verify_instancetype_contains "test-vm" "vm-test" "large" + +# Verify no direct resources +verify_no_direct_resources "test-vm" "vm-test" + +echo "All validations passed" +exit 0 +``` diff --git a/pkg/toolsets/kubevirt/vm/tests/helpers/verify-vm.sh b/pkg/toolsets/kubevirt/vm/tests/helpers/verify-vm.sh new file mode 100644 index 00000000..0ad3929d --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/tests/helpers/verify-vm.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +# Shared verification helper functions for VirtualMachine tests + +# verify_vm_exists: Waits for a VirtualMachine to be created +# Usage: verify_vm_exists [timeout] +verify_vm_exists() { + local vm_name="$1" + local namespace="$2" + local timeout="${3:-30s}" + + if ! kubectl wait --for=jsonpath='{.metadata.name}'="$vm_name" virtualmachine/"$vm_name" -n "$namespace" --timeout="$timeout" 2>/dev/null; then + echo "VirtualMachine $vm_name not found in namespace $namespace" + kubectl get virtualmachines -n "$namespace" + return 1 + fi + echo "VirtualMachine $vm_name created successfully" + return 0 +} + +# verify_container_disk: Verifies that a VM uses a specific container disk OS +# Usage: verify_container_disk +# Example: verify_container_disk test-vm vm-test fedora +verify_container_disk() { + local vm_name="$1" + local namespace="$2" + local os_name="$3" + + # Get all container disk images from all volumes + local container_disks + container_disks=$(kubectl get virtualmachine "$vm_name" -n "$namespace" -o jsonpath='{.spec.template.spec.volumes[*].containerDisk.image}') + + if [[ "$container_disks" =~ $os_name ]]; then + echo "✓ VirtualMachine uses $os_name container disk" + return 0 + else + echo "✗ Expected $os_name container disk, found volumes with images: $container_disks" + kubectl get virtualmachine "$vm_name" -n "$namespace" -o yaml + return 1 + fi +} + +# verify_run_strategy: Verifies that runStrategy is set (in spec or status) +# Usage: verify_run_strategy +verify_run_strategy() { + local vm_name="$1" + local namespace="$2" + + local spec_run_strategy + local status_run_strategy + spec_run_strategy=$(kubectl get virtualmachine "$vm_name" -n "$namespace" -o jsonpath='{.spec.runStrategy}') + status_run_strategy=$(kubectl get virtualmachine "$vm_name" -n "$namespace" -o jsonpath='{.status.runStrategy}') + + if [[ -n "$spec_run_strategy" ]]; then + echo "✓ VirtualMachine uses runStrategy in spec: $spec_run_strategy" + return 0 + elif [[ -n "$status_run_strategy" ]]; then + echo "✓ VirtualMachine has runStrategy in status: $status_run_strategy" + echo " Note: VM may have been created with deprecated 'running' field, but runStrategy is set in status" + return 0 + else + echo "✗ VirtualMachine missing runStrategy field in both spec and status" + return 1 + fi +} + +# verify_no_deprecated_running_field: Verifies that deprecated 'running' field is NOT set +# Usage: verify_no_deprecated_running_field +verify_no_deprecated_running_field() { + local vm_name="$1" + local namespace="$2" + + local running_field + running_field=$(kubectl get virtualmachine "$vm_name" -n "$namespace" -o jsonpath='{.spec.running}') + + if [[ -z "$running_field" ]]; then + echo "✓ VirtualMachine does not use deprecated 'running' field" + return 0 + else + echo "✗ VirtualMachine uses deprecated 'running' field with value: $running_field" + echo " Please use 'runStrategy' instead of 'running'" + kubectl get virtualmachine "$vm_name" -n "$namespace" -o yaml + return 1 + fi +} + +# verify_instancetype: Verifies that a VM has an instancetype reference +# Usage: verify_instancetype [expected-instancetype] [expected-kind] +verify_instancetype() { + local vm_name="$1" + local namespace="$2" + local expected_instancetype="$3" + local expected_kind="${4:-VirtualMachineClusterInstancetype}" + + local instancetype + instancetype=$(kubectl get virtualmachine "$vm_name" -n "$namespace" -o jsonpath='{.spec.instancetype.name}') + + if [[ -z "$instancetype" ]]; then + echo "✗ VirtualMachine has no instancetype reference" + return 1 + fi + + echo "✓ VirtualMachine has instancetype reference: $instancetype" + + # Check expected instancetype if provided + if [[ -n "$expected_instancetype" ]]; then + if [[ "$instancetype" == "$expected_instancetype" ]]; then + echo "✓ Instancetype matches expected value: $expected_instancetype" + else + echo "✗ Expected instancetype '$expected_instancetype', found: $instancetype" + return 1 + fi + fi + + # Verify instancetype kind + local instancetype_kind + instancetype_kind=$(kubectl get virtualmachine "$vm_name" -n "$namespace" -o jsonpath='{.spec.instancetype.kind}') + if [[ "$instancetype_kind" == "$expected_kind" ]]; then + echo "✓ Instancetype kind is $expected_kind" + else + echo "⚠ Instancetype kind is: $instancetype_kind (expected: $expected_kind)" + fi + + return 0 +} + +# verify_instancetype_contains: Verifies that instancetype name contains a string +# Usage: verify_instancetype_contains [description] +verify_instancetype_contains() { + local vm_name="$1" + local namespace="$2" + local substring="$3" + local description="${4:-$substring}" + + local instancetype + instancetype=$(kubectl get virtualmachine "$vm_name" -n "$namespace" -o jsonpath='{.spec.instancetype.name}') + + if [[ -z "$instancetype" ]]; then + echo "✗ VirtualMachine has no instancetype reference" + return 1 + fi + + if [[ "$instancetype" =~ $substring ]]; then + echo "✓ Instancetype matches $description: $instancetype" + return 0 + else + echo "⚠ Instancetype '$instancetype' doesn't match $description" + return 0 # Return success for warnings + fi +} + +# verify_instancetype_prefix: Verifies that instancetype starts with a prefix +# Usage: verify_instancetype_prefix [description] +verify_instancetype_prefix() { + local vm_name="$1" + local namespace="$2" + local prefix="$3" + local description="${4:-$prefix}" + + local instancetype + instancetype=$(kubectl get virtualmachine "$vm_name" -n "$namespace" -o jsonpath='{.spec.instancetype.name}') + + if [[ -z "$instancetype" ]]; then + echo "✗ VirtualMachine has no instancetype reference" + return 1 + fi + + if [[ "$instancetype" =~ ^${prefix}\. ]]; then + echo "✓ Instancetype matches $description family: $instancetype" + return 0 + else + echo "⚠ Instancetype '$instancetype' doesn't start with '$prefix'" + return 0 # Return success for warnings + fi +} + +# verify_no_direct_resources: Verifies VM uses instancetype (no direct memory spec) +# Usage: verify_no_direct_resources +verify_no_direct_resources() { + local vm_name="$1" + local namespace="$2" + + local guest_memory + guest_memory=$(kubectl get virtualmachine "$vm_name" -n "$namespace" -o jsonpath='{.spec.template.spec.domain.memory.guest}') + + if [[ -z "$guest_memory" ]]; then + echo "✓ VirtualMachine uses instancetype for resources (no direct memory spec)" + return 0 + else + echo "⚠ VirtualMachine has direct memory specification: $guest_memory" + return 0 # Return success for warnings + fi +} + +# verify_has_resources_or_instancetype: Verifies VM has either instancetype or direct resources +# Usage: verify_has_resources_or_instancetype +verify_has_resources_or_instancetype() { + local vm_name="$1" + local namespace="$2" + + local instancetype + instancetype=$(kubectl get virtualmachine "$vm_name" -n "$namespace" -o jsonpath='{.spec.instancetype.name}') + + if [[ -n "$instancetype" ]]; then + echo "✓ VirtualMachine has instancetype reference: $instancetype" + return 0 + fi + + # Check for direct resource specification + local guest_memory + guest_memory=$(kubectl get virtualmachine "$vm_name" -n "$namespace" -o jsonpath='{.spec.template.spec.domain.memory.guest}') + + if [[ -n "$guest_memory" ]]; then + echo "⚠ No instancetype set, but VM has direct memory specification: $guest_memory" + return 0 + else + echo "✗ VirtualMachine has no instancetype and no direct resource specification" + kubectl get virtualmachine "$vm_name" -n "$namespace" -o yaml + return 1 + fi +} diff --git a/pkg/toolsets/kubevirt/vm/tests/mcp-config.yaml b/pkg/toolsets/kubevirt/vm/tests/mcp-config.yaml new file mode 100644 index 00000000..f79b279a --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/tests/mcp-config.yaml @@ -0,0 +1,5 @@ +mcpServers: + kubernetes: + type: http + url: http://localhost:8888/mcp + enableAllTools: true diff --git a/pkg/toolsets/kubevirt/vm/tests/tasks/create-vm-basic/create-vm-basic.yaml b/pkg/toolsets/kubevirt/vm/tests/tasks/create-vm-basic/create-vm-basic.yaml new file mode 100644 index 00000000..5c563cd0 --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/tests/tasks/create-vm-basic/create-vm-basic.yaml @@ -0,0 +1,34 @@ +kind: Task +metadata: + name: "create-basic-vm" + difficulty: easy +steps: + setup: + inline: |- + #!/usr/bin/env bash + kubectl delete namespace vm-test --ignore-not-found + kubectl create namespace vm-test + verify: + inline: |- + #!/usr/bin/env bash + source pkg/toolsets/kubevirt/vm/tests/helpers/verify-vm.sh + + # Wait for VirtualMachine to be created + verify_vm_exists "test-vm" "vm-test" || exit 1 + + # Verify container disk is Fedora + verify_container_disk "test-vm" "vm-test" "fedora" || exit 1 + + # Verify runStrategy is set and deprecated 'running' field is not used + verify_run_strategy "test-vm" "vm-test" || exit 1 + verify_no_deprecated_running_field "test-vm" "vm-test" || exit 1 + + echo "All validations passed" + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + kubectl delete virtualmachine test-vm -n vm-test --ignore-not-found + kubectl delete namespace vm-test --ignore-not-found + prompt: + inline: Please create a Fedora virtual machine named test-vm in the vm-test namespace. diff --git a/pkg/toolsets/kubevirt/vm/tests/tasks/create-vm-ubuntu/create-vm-ubuntu.yaml b/pkg/toolsets/kubevirt/vm/tests/tasks/create-vm-ubuntu/create-vm-ubuntu.yaml new file mode 100644 index 00000000..65507d85 --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/tests/tasks/create-vm-ubuntu/create-vm-ubuntu.yaml @@ -0,0 +1,34 @@ +kind: Task +metadata: + name: "create-ubuntu-vm" + difficulty: easy +steps: + setup: + inline: |- + #!/usr/bin/env bash + kubectl delete namespace vm-test --ignore-not-found + kubectl create namespace vm-test + verify: + inline: |- + #!/usr/bin/env bash + source pkg/toolsets/kubevirt/vm/tests/helpers/verify-vm.sh + + # Wait for VirtualMachine to be created + verify_vm_exists "ubuntu-vm" "vm-test" || exit 1 + + # Verify container disk is Ubuntu + verify_container_disk "ubuntu-vm" "vm-test" "ubuntu" || exit 1 + + # Verify runStrategy is set and deprecated 'running' field is not used + verify_run_strategy "ubuntu-vm" "vm-test" || exit 1 + verify_no_deprecated_running_field "ubuntu-vm" "vm-test" || exit 1 + + echo "All validations passed" + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + kubectl delete virtualmachine ubuntu-vm -n vm-test --ignore-not-found + kubectl delete namespace vm-test --ignore-not-found + prompt: + inline: Create an Ubuntu virtual machine named ubuntu-vm in the vm-test namespace. diff --git a/pkg/toolsets/kubevirt/vm/tests/tasks/create-vm-with-instancetype/create-vm-with-instancetype.yaml b/pkg/toolsets/kubevirt/vm/tests/tasks/create-vm-with-instancetype/create-vm-with-instancetype.yaml new file mode 100644 index 00000000..9e13142a --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/tests/tasks/create-vm-with-instancetype/create-vm-with-instancetype.yaml @@ -0,0 +1,40 @@ +kind: Task +metadata: + name: "create-vm-with-instancetype" + difficulty: medium +steps: + setup: + inline: |- + #!/usr/bin/env bash + kubectl delete namespace vm-test --ignore-not-found + kubectl create namespace vm-test + verify: + inline: |- + #!/usr/bin/env bash + source pkg/toolsets/kubevirt/vm/tests/helpers/verify-vm.sh + + # Wait for VirtualMachine to be created + verify_vm_exists "test-vm-instancetype" "vm-test" || exit 1 + + # Verify that it has the specific instancetype reference (u1.medium) + verify_instancetype "test-vm-instancetype" "vm-test" "u1.medium" || exit 1 + + # Verify runStrategy is set and deprecated 'running' field is not used + verify_run_strategy "test-vm-instancetype" "vm-test" || exit 1 + verify_no_deprecated_running_field "test-vm-instancetype" "vm-test" || exit 1 + + # Verify no direct resource specification (should use instancetype) + verify_no_direct_resources "test-vm-instancetype" "vm-test" + + # Verify container disk is Fedora (default workload) + verify_container_disk "test-vm-instancetype" "vm-test" "fedora" + + echo "All validations passed" + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + kubectl delete virtualmachine test-vm-instancetype -n vm-test --ignore-not-found + kubectl delete namespace vm-test --ignore-not-found + prompt: + inline: Create a Fedora virtual machine named test-vm-instancetype in the vm-test namespace with instancetype 'u1.medium'. Use the vm_create tool to generate the creation plan and then create the VirtualMachine resource. diff --git a/pkg/toolsets/kubevirt/vm/tests/tasks/create-vm-with-performance/create-vm-with-performance.yaml b/pkg/toolsets/kubevirt/vm/tests/tasks/create-vm-with-performance/create-vm-with-performance.yaml new file mode 100644 index 00000000..9400fe15 --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/tests/tasks/create-vm-with-performance/create-vm-with-performance.yaml @@ -0,0 +1,46 @@ +kind: Task +metadata: + name: "create-vm-with-performance" + difficulty: medium +steps: + setup: + inline: |- + #!/usr/bin/env bash + kubectl delete namespace vm-test --ignore-not-found + kubectl create namespace vm-test + verify: + inline: |- + #!/usr/bin/env bash + source pkg/toolsets/kubevirt/vm/tests/helpers/verify-vm.sh + + # Wait for VirtualMachine to be created + verify_vm_exists "test-vm-performance" "vm-test" || exit 1 + + # Verify that it has an instancetype reference + verify_instancetype "test-vm-performance" "vm-test" || exit 1 + + # Check if instancetype matches compute-optimized family (c1) + verify_instancetype_prefix "test-vm-performance" "vm-test" "c1" "compute-optimized" + + # Check if instancetype contains 'medium' (matching the requested size) + verify_instancetype_contains "test-vm-performance" "vm-test" "medium" "requested size 'medium'" + + # Verify runStrategy is set and deprecated 'running' field is not used + verify_run_strategy "test-vm-performance" "vm-test" || exit 1 + verify_no_deprecated_running_field "test-vm-performance" "vm-test" || exit 1 + + # Verify no direct resource specification (should use instancetype) + verify_no_direct_resources "test-vm-performance" "vm-test" + + # Verify container disk is Fedora (default workload) + verify_container_disk "test-vm-performance" "vm-test" "fedora" + + echo "All validations passed" + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + kubectl delete virtualmachine test-vm-performance -n vm-test --ignore-not-found + kubectl delete namespace vm-test --ignore-not-found + prompt: + inline: Create a Fedora virtual machine named test-vm-performance in the vm-test namespace with performance family 'compute-optimized' and size 'medium'. Use the vm_create tool to generate the creation plan and then create the VirtualMachine resource. diff --git a/pkg/toolsets/kubevirt/vm/tests/tasks/create-vm-with-size/create-vm-with-size.yaml b/pkg/toolsets/kubevirt/vm/tests/tasks/create-vm-with-size/create-vm-with-size.yaml new file mode 100644 index 00000000..cf41fde6 --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/tests/tasks/create-vm-with-size/create-vm-with-size.yaml @@ -0,0 +1,40 @@ +kind: Task +metadata: + name: "create-vm-with-size" + difficulty: medium +steps: + setup: + inline: |- + #!/usr/bin/env bash + kubectl delete namespace vm-test --ignore-not-found + kubectl create namespace vm-test + verify: + inline: |- + #!/usr/bin/env bash + source pkg/toolsets/kubevirt/vm/tests/helpers/verify-vm.sh + + # Wait for VirtualMachine to be created + verify_vm_exists "test-vm-size" "vm-test" || exit 1 + + # Verify that it has an instancetype or direct resources + verify_has_resources_or_instancetype "test-vm-size" "vm-test" || exit 1 + + # Check if instancetype contains 'large' (matching the requested size) + verify_instancetype_contains "test-vm-size" "vm-test" "large" "requested size 'large'" + + # Verify runStrategy is set and deprecated 'running' field is not used + verify_run_strategy "test-vm-size" "vm-test" || exit 1 + verify_no_deprecated_running_field "test-vm-size" "vm-test" || exit 1 + + # Verify container disk is Fedora (default workload) + verify_container_disk "test-vm-size" "vm-test" "fedora" + + echo "All validations passed" + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + kubectl delete virtualmachine test-vm-size -n vm-test --ignore-not-found + kubectl delete namespace vm-test --ignore-not-found + prompt: + inline: Create a Fedora virtual machine named test-vm-size in the vm-test namespace with size 'large'. Use the vm_create tool to generate the creation plan and then create the VirtualMachine resource. diff --git a/pkg/toolsets/kubevirt/vm/tests/tasks/troubleshoot-vm/troubleshoot-vm.yaml b/pkg/toolsets/kubevirt/vm/tests/tasks/troubleshoot-vm/troubleshoot-vm.yaml new file mode 100644 index 00000000..e6d3b665 --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/tests/tasks/troubleshoot-vm/troubleshoot-vm.yaml @@ -0,0 +1,57 @@ +kind: Task +metadata: + name: "troubleshoot-vm" + difficulty: easy +steps: + setup: + inline: |- + #!/usr/bin/env bash + kubectl delete namespace vm-test --ignore-not-found + kubectl create namespace vm-test + + # Create a VirtualMachine that can be troubleshot + cat </dev/null; then + echo "VirtualMachine broken-vm exists and troubleshooting was performed" + exit 0 + else + echo "VirtualMachine broken-vm not found" + exit 1 + fi + cleanup: + inline: |- + #!/usr/bin/env bash + kubectl delete virtualmachine broken-vm -n vm-test --ignore-not-found + kubectl delete namespace vm-test --ignore-not-found + prompt: + inline: A VirtualMachine named broken-vm in the vm-test namespace is having issues. 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..7e0f8ead --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/troubleshoot/tool.go @@ -0,0 +1,98 @@ +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 := getRequiredString(params, "namespace") + if err != nil { + return api.NewToolCallResult("", err), nil + } + + name, err := getRequiredString(params, "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 +} + +func getRequiredString(params api.ToolHandlerParams, key string) (string, error) { + args := params.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 +} 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..8d371d42 --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/troubleshoot/tool_test.go @@ -0,0 +1,110 @@ +package troubleshoot + +import ( + "context" + "strings" + "testing" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" +) + +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, + }, + } + + 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}, + } + + result, err := troubleshoot(params) + if err != nil { + t.Errorf("troubleshoot() 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) + } + } + }) + } +}