diff --git a/README.md b/README.md index a521c002a..eee04c463 100644 --- a/README.md +++ b/README.md @@ -498,6 +498,11 @@ In case multi-cluster support is enabled (default) and you have access to multip - `storage` (`string`) - Optional storage size for the VM's root disk when using DataSources (e.g., '30Gi', '50Gi', '100Gi'). Defaults to 30Gi. Ignored when using container disks. - `workload` (`string`) - 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 +- **vm_guest_info** - Get guest operating system information from a VirtualMachine's QEMU guest agent. Requires the guest agent to be installed and running inside the VM. Provides detailed information about the OS, filesystems, network interfaces, and logged-in users. + - `info_type` (`string`) - Type of information to retrieve: 'all' (default - all available info), 'os' (operating system details), 'filesystem' (disk and filesystem info), 'users' (logged-in users), 'network' (network interfaces and IPs) + - `name` (`string`) **(required)** - The name of the virtual machine + - `namespace` (`string`) **(required)** - The namespace of the virtual machine + - **vm_lifecycle** - Manage VirtualMachine lifecycle: start, stop, or restart a VM - `action` (`string`) **(required)** - The lifecycle action to perform: 'start' (changes runStrategy to Always), 'stop' (changes runStrategy to Halted), or 'restart' (stops then starts the VM) - `name` (`string`) **(required)** - The name of the virtual machine diff --git a/evals/tasks/kubevirt/get-vm-filesystems/task.yaml b/evals/tasks/kubevirt/get-vm-filesystems/task.yaml new file mode 100644 index 000000000..6be09e45f --- /dev/null +++ b/evals/tasks/kubevirt/get-vm-filesystems/task.yaml @@ -0,0 +1,81 @@ +kind: Task +metadata: + labels: + suite: kubevirt + requires: kubevirt + name: "get-vm-filesystems" + difficulty: easy + description: "Use vm_guest_info to get filesystem information from inside a VM" +steps: + setup: + inline: |- + #!/usr/bin/env bash + set -e + NS="${EVAL_NAMESPACE:-vm-test}" + kubectl delete namespace "$NS" --ignore-not-found + kubectl create namespace "$NS" + + # Create a VM with guest agent + kubectl apply -f - </dev/null; then + echo "ERROR: VirtualMachine 'test-vm' not found" + exit 1 + fi + + echo "Verification passed: VM exists" + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + NS="${EVAL_NAMESPACE:-vm-test}" + kubectl delete virtualmachine test-vm -n "$NS" --ignore-not-found + kubectl delete namespace "$NS" --ignore-not-found + prompt: + inline: | + Get the filesystem information from the VirtualMachine "test-vm" in the ${EVAL_NAMESPACE:-vm-test} namespace. + + Report the mounted filesystems you find. diff --git a/evals/tasks/kubevirt/get-vm-ip-address/task.yaml b/evals/tasks/kubevirt/get-vm-ip-address/task.yaml new file mode 100644 index 000000000..ab53c4753 --- /dev/null +++ b/evals/tasks/kubevirt/get-vm-ip-address/task.yaml @@ -0,0 +1,83 @@ +kind: Task +metadata: + labels: + suite: kubevirt + requires: kubevirt + name: "get-vm-ip-address" + difficulty: easy + description: "Use vm_guest_info to get the IP address from inside a VM" +steps: + setup: + inline: |- + #!/usr/bin/env bash + set -e + NS="${EVAL_NAMESPACE:-vm-test}" + kubectl delete namespace "$NS" --ignore-not-found + kubectl create namespace "$NS" + + # Create a VM with guest agent and network interfaces + kubectl apply -f - </dev/null; then + echo "ERROR: VirtualMachine 'test-vm' not found" + exit 1 + fi + + echo "Verification passed: VM exists" + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + NS="${EVAL_NAMESPACE:-vm-test}" + kubectl delete virtualmachine test-vm -n "$NS" --ignore-not-found + kubectl delete namespace "$NS" --ignore-not-found + prompt: + inline: | + Get the IP address of the VirtualMachine "test-vm" in the ${EVAL_NAMESPACE:-vm-test} namespace from inside the guest OS. + + Report the IP address you find. diff --git a/evals/tasks/kubevirt/get-vm-os-info/task.yaml b/evals/tasks/kubevirt/get-vm-os-info/task.yaml new file mode 100644 index 000000000..b596e6654 --- /dev/null +++ b/evals/tasks/kubevirt/get-vm-os-info/task.yaml @@ -0,0 +1,82 @@ +kind: Task +metadata: + labels: + suite: kubevirt + requires: kubevirt + name: "get-vm-os-info" + difficulty: easy + description: "Use vm_guest_info to get OS information from inside a VM" +steps: + setup: + inline: |- + #!/usr/bin/env bash + set -e + NS="${EVAL_NAMESPACE:-vm-test}" + kubectl delete namespace "$NS" --ignore-not-found + kubectl create namespace "$NS" + + # Create a VM with guest agent + kubectl apply -f - </dev/null; then + echo "ERROR: VirtualMachine 'test-vm' not found" + exit 1 + fi + + echo "Verification passed: VM exists" + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + NS="${EVAL_NAMESPACE:-vm-test}" + kubectl delete virtualmachine test-vm -n "$NS" --ignore-not-found + kubectl delete namespace "$NS" --ignore-not-found + prompt: + inline: | + Get the OS information from the VirtualMachine "test-vm" in the ${EVAL_NAMESPACE:-vm-test} namespace. + + Report the OS name and version you find. diff --git a/evals/tasks/kubevirt/list-vm-users/task.yaml b/evals/tasks/kubevirt/list-vm-users/task.yaml new file mode 100644 index 000000000..2927b91ed --- /dev/null +++ b/evals/tasks/kubevirt/list-vm-users/task.yaml @@ -0,0 +1,86 @@ +kind: Task +metadata: + labels: + suite: kubevirt + requires: kubevirt + name: "list-vm-users" + difficulty: easy + description: "Use vm_guest_info to list currently logged-in users in a VM" +steps: + setup: + inline: |- + #!/usr/bin/env bash + set -e + NS="${EVAL_NAMESPACE:-vm-test}" + kubectl delete namespace "$NS" --ignore-not-found + kubectl create namespace "$NS" + + # Create a VM with guest agent and defined users + kubectl apply -f - </dev/null; then + echo "ERROR: VirtualMachine 'test-vm' not found" + exit 1 + fi + + echo "Verification passed: VM exists" + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + NS="${EVAL_NAMESPACE:-vm-test}" + kubectl delete virtualmachine test-vm -n "$NS" --ignore-not-found + kubectl delete namespace "$NS" --ignore-not-found + prompt: + inline: | + Get the list of currently logged-in users from the VirtualMachine "test-vm" in the ${EVAL_NAMESPACE:-vm-test} namespace. + + Report the usernames you find. diff --git a/evals/tasks/kubevirt/vm-guest-info/task.yaml b/evals/tasks/kubevirt/vm-guest-info/task.yaml new file mode 100644 index 000000000..a310b46f5 --- /dev/null +++ b/evals/tasks/kubevirt/vm-guest-info/task.yaml @@ -0,0 +1,106 @@ +kind: Task +metadata: + labels: + suite: kubevirt + requires: kubevirt + name: "vm-guest-info" + difficulty: medium +steps: + setup: + inline: |- + #!/usr/bin/env bash + set -e + NS="${EVAL_NAMESPACE:-vm-test}" + kubectl delete namespace "$NS" --ignore-not-found + kubectl create namespace "$NS" + + # Create a VM with guest agent enabled + # Using Fedora which has qemu-guest-agent pre-installed + kubectl apply -f - </dev/null; then + echo "ERROR: VirtualMachineInstance 'test-vm-with-agent' not found" + exit 1 + fi + + PHASE=$(kubectl get vmi test-vm-with-agent -n "$NS" -o jsonpath='{.status.phase}') + if [ "$PHASE" != "Running" ]; then + echo "ERROR: VMI is not running (phase: $PHASE)" + exit 1 + fi + + echo "Verification passed: VM is running" + echo "NOTE: Guest agent info availability depends on whether qemu-guest-agent started successfully in the VM" + exit 0 + cleanup: + inline: |- + #!/usr/bin/env bash + NS="${EVAL_NAMESPACE:-vm-test}" + kubectl delete virtualmachine test-vm-with-agent -n "$NS" --ignore-not-found + kubectl delete namespace "$NS" --ignore-not-found + prompt: + inline: | + A VirtualMachine named test-vm-with-agent is running in the ${EVAL_NAMESPACE:-vm-test} namespace with QEMU guest agent installed. + + Please retrieve guest agent information from this VM. Get all available information (OS, filesystems, network interfaces, and users). + + Note: If the guest agent is not yet available, it may indicate that cloud-init is still running or the guest agent service hasn't started. In that case, provide information about what guest agent data would typically include. diff --git a/pkg/mcp/common_test.go b/pkg/mcp/common_test.go index fb26a96c7..a3a2c7588 100644 --- a/pkg/mcp/common_test.go +++ b/pkg/mcp/common_test.go @@ -73,6 +73,7 @@ func TestMain(m *testing.M) { CRD("route.openshift.io", "v1", "routes", "Route", "route", true), // Kubevirt CRD("kubevirt.io", "v1", "virtualmachines", "VirtualMachine", "virtualmachine", true), + CRD("kubevirt.io", "v1", "virtualmachineinstances", "VirtualMachineInstance", "virtualmachineinstance", true), CRD("clone.kubevirt.io", "v1beta1", "virtualmachineclones", "VirtualMachineClone", "virtualmachineclone", true), CRD("cdi.kubevirt.io", "v1beta1", "datasources", "DataSource", "datasource", true), CRD("instancetype.kubevirt.io", "v1beta1", "virtualmachineclusterinstancetypes", "VirtualMachineClusterInstancetype", "virtualmachineclusterinstancetype", false), diff --git a/pkg/mcp/kubevirt_test.go b/pkg/mcp/kubevirt_test.go index 950900d7d..93ee07c48 100644 --- a/pkg/mcp/kubevirt_test.go +++ b/pkg/mcp/kubevirt_test.go @@ -22,6 +22,7 @@ import ( var kubevirtApis = []schema.GroupVersionResource{ {Group: "kubevirt.io", Version: "v1", Resource: "virtualmachines"}, + {Group: "kubevirt.io", Version: "v1", Resource: "virtualmachineinstances"}, {Group: "clone.kubevirt.io", Version: "v1beta1", Resource: "virtualmachineclones"}, {Group: "cdi.kubevirt.io", Version: "v1beta1", Resource: "datasources"}, {Group: "instancetype.kubevirt.io", Version: "v1beta1", Resource: "virtualmachineclusterinstancetypes"}, @@ -781,6 +782,263 @@ func (s *KubevirtSuite) TestVMTroubleshootPrompt() { }) } +func (s *KubevirtSuite) TestVMGuestInfo() { + s.Run("vm_guest_info missing required params", func() { + testCases := []string{"namespace", "name"} + for _, param := range testCases { + s.Run("missing "+param, func() { + params := map[string]interface{}{ + "namespace": "default", + "name": "test-vm", + } + delete(params, param) + toolResult, err := s.CallTool("vm_guest_info", params) + s.Require().Nilf(err, "call tool failed %v", err) + s.Truef(toolResult.IsError, "expected call tool to fail due to missing %s", param) + s.Equal(toolResult.Content[0].(*mcp.TextContent).Text, param+" parameter required") + }) + } + }) + + s.Run("vm_guest_info with non-existent VM", func() { + toolResult, err := s.CallTool("vm_guest_info", map[string]interface{}{ + "namespace": "default", + "name": "non-existent-vm", + }) + s.Nilf(err, "call tool failed %v", err) + s.Truef(toolResult.IsError, "expected call tool to fail for non-existent VM") + s.Contains(toolResult.Content[0].(*mcp.TextContent).Text, "VirtualMachineInstance not found") + }) + + s.Run("vm_guest_info with stopped VM", func() { + // Create a VM (not VMI, since it's not running) + dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig) + vm := &unstructured.Unstructured{} + vm.SetUnstructuredContent(map[string]interface{}{ + "apiVersion": "kubevirt.io/v1", + "kind": "VirtualMachine", + "metadata": map[string]interface{}{ + "name": "stopped-vm", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "runStrategy": "Halted", + }, + }) + _, err := dynamicClient.Resource(schema.GroupVersionResource{ + Group: "kubevirt.io", + Version: "v1", + Resource: "virtualmachines", + }).Namespace("default").Create(s.T().Context(), vm, metav1.CreateOptions{}) + s.Require().NoError(err, "failed to create stopped VM") + + toolResult, err := s.CallTool("vm_guest_info", map[string]interface{}{ + "namespace": "default", + "name": "stopped-vm", + }) + s.Nilf(err, "call tool failed %v", err) + s.Truef(toolResult.IsError, "expected call tool to fail for stopped VM") + s.Contains(toolResult.Content[0].(*mcp.TextContent).Text, "VirtualMachineInstance not found") + + // Cleanup + _ = dynamicClient.Resource(schema.GroupVersionResource{ + Group: "kubevirt.io", + Version: "v1", + Resource: "virtualmachines", + }).Namespace("default").Delete(s.T().Context(), "stopped-vm", metav1.DeleteOptions{}) + }) + + s.Run("vm_guest_info with running VM (no guest agent)", func() { + // Create a running VMI + dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig) + vmi := &unstructured.Unstructured{} + vmi.SetUnstructuredContent(map[string]interface{}{ + "apiVersion": "kubevirt.io/v1", + "kind": "VirtualMachineInstance", + "metadata": map[string]interface{}{ + "name": "running-vm", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "domain": map[string]interface{}{ + "devices": map[string]interface{}{}, + }, + }, + "status": map[string]interface{}{ + "phase": "Running", + }, + }) + _, err := dynamicClient.Resource(schema.GroupVersionResource{ + Group: "kubevirt.io", + Version: "v1", + Resource: "virtualmachineinstances", + }).Namespace("default").Create(s.T().Context(), vmi, metav1.CreateOptions{}) + s.Require().NoError(err, "failed to create running VMI") + + s.Run("info_type=all returns error when guest agent unavailable", func() { + toolResult, err := s.CallTool("vm_guest_info", map[string]interface{}{ + "namespace": "default", + "name": "running-vm", + "info_type": "all", + }) + s.Nilf(err, "call tool failed %v", err) + // In envtest without real KubeVirt, the subresource API calls will fail + // The tool should handle this gracefully and return an error + s.Truef(toolResult.IsError, "expected error when guest agent data is unavailable") + }) + + s.Run("info_type=os returns error when guest agent unavailable", func() { + toolResult, err := s.CallTool("vm_guest_info", map[string]interface{}{ + "namespace": "default", + "name": "running-vm", + "info_type": "os", + }) + s.Nilf(err, "call tool failed %v", err) + s.Truef(toolResult.IsError, "expected error when guest agent data is unavailable") + s.Contains(toolResult.Content[0].(*mcp.TextContent).Text, "guest agent") + }) + + s.Run("info_type=filesystem returns error when guest agent unavailable", func() { + toolResult, err := s.CallTool("vm_guest_info", map[string]interface{}{ + "namespace": "default", + "name": "running-vm", + "info_type": "filesystem", + }) + s.Nilf(err, "call tool failed %v", err) + s.Truef(toolResult.IsError, "expected error when guest agent data is unavailable") + s.Contains(toolResult.Content[0].(*mcp.TextContent).Text, "guest agent") + }) + + s.Run("info_type=users returns error when guest agent unavailable", func() { + toolResult, err := s.CallTool("vm_guest_info", map[string]interface{}{ + "namespace": "default", + "name": "running-vm", + "info_type": "users", + }) + s.Nilf(err, "call tool failed %v", err) + s.Truef(toolResult.IsError, "expected error when guest agent data is unavailable") + s.Contains(toolResult.Content[0].(*mcp.TextContent).Text, "guest agent") + }) + + s.Run("info_type=network returns error when guest agent unavailable", func() { + toolResult, err := s.CallTool("vm_guest_info", map[string]interface{}{ + "namespace": "default", + "name": "running-vm", + "info_type": "network", + }) + s.Nilf(err, "call tool failed %v", err) + s.Truef(toolResult.IsError, "expected error when guest agent data is unavailable") + s.Contains(toolResult.Content[0].(*mcp.TextContent).Text, "guest agent") + }) + + s.Run("invalid info_type returns error", func() { + toolResult, err := s.CallTool("vm_guest_info", map[string]interface{}{ + "namespace": "default", + "name": "running-vm", + "info_type": "invalid_type", + }) + s.Nilf(err, "call tool failed %v", err) + s.Truef(toolResult.IsError, "expected error for invalid info_type") + s.Contains(toolResult.Content[0].(*mcp.TextContent).Text, "invalid info_type") + }) + + // Cleanup + _ = dynamicClient.Resource(schema.GroupVersionResource{ + Group: "kubevirt.io", + Version: "v1", + Resource: "virtualmachineinstances", + }).Namespace("default").Delete(s.T().Context(), "running-vm", metav1.DeleteOptions{}) + }) + + s.Run("vm_guest_info with VMI not in Running phase", func() { + // Create a VMI in a non-running state + dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig) + vmi := &unstructured.Unstructured{} + vmi.SetUnstructuredContent(map[string]interface{}{ + "apiVersion": "kubevirt.io/v1", + "kind": "VirtualMachineInstance", + "metadata": map[string]interface{}{ + "name": "pending-vm", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "domain": map[string]interface{}{ + "devices": map[string]interface{}{}, + }, + }, + "status": map[string]interface{}{ + "phase": "Pending", + }, + }) + _, err := dynamicClient.Resource(schema.GroupVersionResource{ + Group: "kubevirt.io", + Version: "v1", + Resource: "virtualmachineinstances", + }).Namespace("default").Create(s.T().Context(), vmi, metav1.CreateOptions{}) + s.Require().NoError(err, "failed to create pending VMI") + + toolResult, err := s.CallTool("vm_guest_info", map[string]interface{}{ + "namespace": "default", + "name": "pending-vm", + }) + s.Nilf(err, "call tool failed %v", err) + s.Truef(toolResult.IsError, "expected error for non-running VM") + s.Contains(toolResult.Content[0].(*mcp.TextContent).Text, "not running") + s.Contains(toolResult.Content[0].(*mcp.TextContent).Text, "Pending") + + // Cleanup + _ = dynamicClient.Resource(schema.GroupVersionResource{ + Group: "kubevirt.io", + Version: "v1", + Resource: "virtualmachineinstances", + }).Namespace("default").Delete(s.T().Context(), "pending-vm", metav1.DeleteOptions{}) + }) + + s.Run("vm_guest_info with default info_type", func() { + // Create a running VMI + dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig) + vmi := &unstructured.Unstructured{} + vmi.SetUnstructuredContent(map[string]interface{}{ + "apiVersion": "kubevirt.io/v1", + "kind": "VirtualMachineInstance", + "metadata": map[string]interface{}{ + "name": "default-info-vm", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "domain": map[string]interface{}{ + "devices": map[string]interface{}{}, + }, + }, + "status": map[string]interface{}{ + "phase": "Running", + }, + }) + _, err := dynamicClient.Resource(schema.GroupVersionResource{ + Group: "kubevirt.io", + Version: "v1", + Resource: "virtualmachineinstances", + }).Namespace("default").Create(s.T().Context(), vmi, metav1.CreateOptions{}) + s.Require().NoError(err, "failed to create running VMI") + + // Call without info_type to test default behavior + toolResult, err := s.CallTool("vm_guest_info", map[string]interface{}{ + "namespace": "default", + "name": "default-info-vm", + }) + s.Nilf(err, "call tool failed %v", err) + // Should default to "all" and fail gracefully in envtest + s.Truef(toolResult.IsError, "expected error when guest agent data is unavailable") + + // Cleanup + _ = dynamicClient.Resource(schema.GroupVersionResource{ + Group: "kubevirt.io", + Version: "v1", + Resource: "virtualmachineinstances", + }).Namespace("default").Delete(s.T().Context(), "default-info-vm", metav1.DeleteOptions{}) + }) +} + func TestKubevirt(t *testing.T) { suite.Run(t, new(KubevirtSuite)) } diff --git a/pkg/mcp/testdata/toolsets-kubevirt-tools.json b/pkg/mcp/testdata/toolsets-kubevirt-tools.json index 183db2c79..3f1a86c1d 100644 --- a/pkg/mcp/testdata/toolsets-kubevirt-tools.json +++ b/pkg/mcp/testdata/toolsets-kubevirt-tools.json @@ -156,6 +156,47 @@ "name": "vm_create", "title": "Virtual Machine: Create" }, + { + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Virtual Machine: Guest Agent Info" + }, + "description": "Get guest operating system information from a VirtualMachine's QEMU guest agent. Requires the guest agent to be installed and running inside the VM. Provides detailed information about the OS, filesystems, network interfaces, and logged-in users.", + "inputSchema": { + "properties": { + "info_type": { + "default": "all", + "description": "Type of information to retrieve: 'all' (default - all available info), 'os' (operating system details), 'filesystem' (disk and filesystem info), 'users' (logged-in users), 'network' (network interfaces and IPs)", + "enum": [ + "all", + "os", + "filesystem", + "users", + "network" + ], + "type": "string" + }, + "name": { + "description": "The name of the virtual machine", + "type": "string" + }, + "namespace": { + "description": "The namespace of the virtual machine", + "type": "string" + } + }, + "required": [ + "namespace", + "name" + ], + "type": "object" + }, + "name": "vm_guest_info", + "title": "Virtual Machine: Guest Agent Info" + }, { "annotations": { "destructiveHint": true, diff --git a/pkg/toolsets/kubevirt/toolset.go b/pkg/toolsets/kubevirt/toolset.go index 471015ff8..8d0760880 100644 --- a/pkg/toolsets/kubevirt/toolset.go +++ b/pkg/toolsets/kubevirt/toolset.go @@ -7,6 +7,7 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/toolsets" vm_clone "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt/vm/clone" vm_create "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt/vm/create" + vm_guestagent "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt/vm/guestagent" vm_lifecycle "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kubevirt/vm/lifecycle" ) @@ -26,12 +27,15 @@ func (t *Toolset) GetTools(_ api.Openshift) []api.ServerTool { return slices.Concat( vm_clone.Tools(), vm_create.Tools(), + vm_guestagent.Tools(), vm_lifecycle.Tools(), ) } func (t *Toolset) GetPrompts() []api.ServerPrompt { - return initVMTroubleshoot() + return slices.Concat( + initVMTroubleshoot(), + ) } func init() { diff --git a/pkg/toolsets/kubevirt/vm/guestagent/tool.go b/pkg/toolsets/kubevirt/vm/guestagent/tool.go new file mode 100644 index 000000000..fb046deea --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/guestagent/tool.go @@ -0,0 +1,265 @@ +package guestagent + +import ( + "context" + "fmt" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/kubevirt" + "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" +) + +// GuestAgentInfoType represents the type of information to retrieve from guest agent +type GuestAgentInfoType string + +const ( + InfoTypeAll GuestAgentInfoType = "all" + InfoTypeOS GuestAgentInfoType = "os" + InfoTypeFilesystem GuestAgentInfoType = "filesystem" + InfoTypeUsers GuestAgentInfoType = "users" + InfoTypeNetwork GuestAgentInfoType = "network" +) + +func Tools() []api.ServerTool { + return []api.ServerTool{ + { + Tool: api.Tool{ + Name: "vm_guest_info", + Description: "Get guest operating system information from a VirtualMachine's QEMU guest agent. Requires the guest agent to be installed and running inside the VM. Provides detailed information about the OS, filesystems, network interfaces, and logged-in users.", + 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", + }, + "info_type": { + Type: "string", + Enum: []any{"all", "os", "filesystem", "users", "network"}, + Description: "Type of information to retrieve: 'all' (default - all available info), 'os' (operating system details), 'filesystem' (disk and filesystem info), 'users' (logged-in users), 'network' (network interfaces and IPs)", + Default: api.ToRawMessage("all"), + }, + }, + Required: []string{"namespace", "name"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Virtual Machine: Guest Agent Info", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(false), + }, + }, + Handler: guestInfo, + }, + } +} + +func guestInfo(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + // Parse input parameters + namespace, err := api.RequiredString(params, "namespace") + if err != nil { + return api.NewToolCallResult("", err), nil + } + + name, err := api.RequiredString(params, "name") + if err != nil { + return api.NewToolCallResult("", err), nil + } + + infoType := api.OptionalString(params, "info_type", "all") + + dynamicClient := params.DynamicClient() + ctx := params.Context + + // First, check if the VMI exists + vmi, err := dynamicClient.Resource(kubevirt.VirtualMachineInstanceGVR). + Namespace(namespace). + Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("VirtualMachineInstance not found - VM may not be running: %w", err)), nil + } + + // Check VMI status to see if it's running + phase, found, err := unstructured.NestedString(vmi.Object, "status", "phase") + if err != nil || !found || phase != "Running" { + return api.NewToolCallResult("", fmt.Errorf("VirtualMachineInstance is not running (phase: %s) - guest agent requires VM to be running", phase)), nil + } + + // Gather guest agent information based on info_type + var result map[string]any + switch GuestAgentInfoType(infoType) { + case InfoTypeOS: + result, err = getGuestOSInfo(ctx, dynamicClient, namespace, name) + case InfoTypeFilesystem: + result, err = getFilesystemInfo(ctx, dynamicClient, namespace, name) + case InfoTypeUsers: + result, err = getUserInfo(ctx, dynamicClient, namespace, name) + case InfoTypeNetwork: + result, err = getNetworkInfo(ctx, dynamicClient, namespace, name) + case InfoTypeAll: + result, err = getAllGuestInfo(ctx, dynamicClient, namespace, name) + default: + return api.NewToolCallResult("", fmt.Errorf("invalid info_type '%s': must be one of 'all', 'os', 'filesystem', 'users', 'network'", infoType)), nil + } + + if err != nil { + return api.NewToolCallResult("", err), nil + } + + // Format the output + marshalledYaml, err := output.MarshalYaml(result) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to marshal guest agent info: %w", err)), nil + } + + message := fmt.Sprintf("# Guest Agent Information for VM: %s/%s\n\n", namespace, name) + if infoType != "all" { + message += fmt.Sprintf("**Info Type:** %s\n\n", infoType) + } + + return api.NewToolCallResult(message+marshalledYaml, nil), nil +} + +// getGuestOSInfo retrieves operating system information from the guest agent +func getGuestOSInfo(ctx context.Context, dynamicClient dynamic.Interface, namespace, name string) (map[string]any, error) { + gvr := schema.GroupVersionResource{ + Group: "subresources.kubevirt.io", + Version: "v1", + Resource: "virtualmachineinstances", + } + + result, err := dynamicClient.Resource(gvr). + Namespace(namespace). + Get(ctx, name+"/guestosinfo", metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get guest OS info - guest agent may not be installed or running: %w", err) + } + + return map[string]any{ + "guestOSInfo": result.Object, + }, nil +} + +// getFilesystemInfo retrieves filesystem and disk information from the guest agent +func getFilesystemInfo(ctx context.Context, dynamicClient dynamic.Interface, namespace, name string) (map[string]any, error) { + gvr := schema.GroupVersionResource{ + Group: "subresources.kubevirt.io", + Version: "v1", + Resource: "virtualmachineinstances", + } + + result, err := dynamicClient.Resource(gvr). + Namespace(namespace). + Get(ctx, name+"/filesystemlist", metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get filesystem info - guest agent may not be installed or running: %w", err) + } + + return map[string]any{ + "filesystems": result.Object, + }, nil +} + +// getUserInfo retrieves logged-in user information from the guest agent +func getUserInfo(ctx context.Context, dynamicClient dynamic.Interface, namespace, name string) (map[string]any, error) { + gvr := schema.GroupVersionResource{ + Group: "subresources.kubevirt.io", + Version: "v1", + Resource: "virtualmachineinstances", + } + + result, err := dynamicClient.Resource(gvr). + Namespace(namespace). + Get(ctx, name+"/userlist", metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get user info - guest agent may not be installed or running: %w", err) + } + + return map[string]any{ + "users": result.Object, + }, nil +} + +// getNetworkInfo retrieves network interface information from the guest agent +func getNetworkInfo(ctx context.Context, dynamicClient dynamic.Interface, namespace, name string) (map[string]any, error) { + gvr := schema.GroupVersionResource{ + Group: "subresources.kubevirt.io", + Version: "v1", + Resource: "virtualmachineinstances", + } + + result, err := dynamicClient.Resource(gvr). + Namespace(namespace). + Get(ctx, name+"/interfacelist", metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get network interface info - guest agent may not be installed or running: %w", err) + } + + return map[string]any{ + "networkInterfaces": result.Object, + }, nil +} + +// getAllGuestInfo retrieves all available guest agent information +func getAllGuestInfo(ctx context.Context, dynamicClient dynamic.Interface, namespace, name string) (map[string]any, error) { + result := make(map[string]any) + + // Collect all info types, but don't fail if one is unavailable + osInfo, err := getGuestOSInfo(ctx, dynamicClient, namespace, name) + if err == nil { + for k, v := range osInfo { + result[k] = v + } + } else { + result["guestOSInfo"] = map[string]string{"error": err.Error()} + } + + fsInfo, err := getFilesystemInfo(ctx, dynamicClient, namespace, name) + if err == nil { + for k, v := range fsInfo { + result[k] = v + } + } else { + result["filesystems"] = map[string]string{"error": err.Error()} + } + + userInfo, err := getUserInfo(ctx, dynamicClient, namespace, name) + if err == nil { + for k, v := range userInfo { + result[k] = v + } + } else { + result["users"] = map[string]string{"error": err.Error()} + } + + netInfo, err := getNetworkInfo(ctx, dynamicClient, namespace, name) + if err == nil { + for k, v := range netInfo { + result[k] = v + } + } else { + result["networkInterfaces"] = map[string]string{"error": err.Error()} + } + + // If all failed, return an error + if len(result) == 4 && + result["guestOSInfo"] != nil && + result["filesystems"] != nil && + result["users"] != nil && + result["networkInterfaces"] != nil { + return nil, fmt.Errorf("guest agent is not responding - ensure QEMU guest agent is installed and running in the VM") + } + + return result, nil +} diff --git a/pkg/toolsets/kubevirt/vm/guestagent/tool_test.go b/pkg/toolsets/kubevirt/vm/guestagent/tool_test.go new file mode 100644 index 000000000..2e1658b09 --- /dev/null +++ b/pkg/toolsets/kubevirt/vm/guestagent/tool_test.go @@ -0,0 +1,61 @@ +package guestagent + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type GuestAgentToolSuite struct { + suite.Suite +} + +func (s *GuestAgentToolSuite) TestToolRegistration() { + s.Run("tool is registered", func() { + tools := Tools() + s.Require().Len(tools, 1, "Expected 1 guest agent tool") + s.Equal("vm_guest_info", tools[0].Tool.Name) + s.Equal("Virtual Machine: Guest Agent Info", tools[0].Tool.Annotations.Title) + s.NotNil(tools[0].Tool.InputSchema) + s.NotNil(tools[0].Handler) + }) + + s.Run("tool has correct properties", func() { + tools := Tools() + tool := tools[0].Tool + + // Check annotations + s.True(*tool.Annotations.ReadOnlyHint, "guest info should be read-only") + s.False(*tool.Annotations.DestructiveHint, "guest info should not be destructive") + s.True(*tool.Annotations.IdempotentHint, "guest info should be idempotent") + + // Check schema + schema := tool.InputSchema + s.Require().NotNil(schema.Properties) + s.Contains(schema.Properties, "namespace") + s.Contains(schema.Properties, "name") + s.Contains(schema.Properties, "info_type") + + // Check required fields + s.ElementsMatch([]string{"namespace", "name"}, schema.Required) + + // Check info_type enum values + infoTypeSchema := schema.Properties["info_type"] + s.Require().NotNil(infoTypeSchema) + s.ElementsMatch([]any{"all", "os", "filesystem", "users", "network"}, infoTypeSchema.Enum) + }) +} + +func (s *GuestAgentToolSuite) TestInfoTypeConstants() { + s.Run("info type constants are defined", func() { + s.Equal(GuestAgentInfoType("all"), InfoTypeAll) + s.Equal(GuestAgentInfoType("os"), InfoTypeOS) + s.Equal(GuestAgentInfoType("filesystem"), InfoTypeFilesystem) + s.Equal(GuestAgentInfoType("users"), InfoTypeUsers) + s.Equal(GuestAgentInfoType("network"), InfoTypeNetwork) + }) +} + +func TestGuestAgentToolSuite(t *testing.T) { + suite.Run(t, new(GuestAgentToolSuite)) +}