From 1f92b8163806b5812b11eba9b08204c0a7f9b349 Mon Sep 17 00:00:00 2001 From: Lee Yarwood Date: Thu, 5 Mar 2026 14:39:43 +0000 Subject: [PATCH] feat(kubevirt): add pause and unpause actions to vm_lifecycle tool The vm_lifecycle tool only supported start, stop, and restart actions. This meant AI agents could not pause/unpause VMs, causing them to fall back to stopping VMs instead (which is a fundamentally different operation). Add pause and unpause actions that use the KubeVirt subresource API (subresources.kubevirt.io/v1) to suspend and resume running VMIs in-place. Assisted-By: Claude Signed-off-by: Lee Yarwood --- README.md | 6 +- evals/tasks/kubevirt/pause-vm/task.yaml | 78 ++++++++ evals/tasks/kubevirt/unpause-vm/task.yaml | 90 +++++++++ pkg/kubevirt/guestagent.go | 13 +- pkg/kubevirt/vm.go | 51 +++++ pkg/kubevirt/vm_test.go | 186 ++++++++++++++++++ pkg/mcp/kubevirt_test.go | 14 ++ pkg/mcp/testdata/toolsets-kubevirt-tools.json | 8 +- pkg/toolsets/kubevirt/vm/lifecycle/tool.go | 24 ++- 9 files changed, 448 insertions(+), 22 deletions(-) create mode 100644 evals/tasks/kubevirt/pause-vm/task.yaml create mode 100644 evals/tasks/kubevirt/unpause-vm/task.yaml diff --git a/README.md b/README.md index c93622bc7..464edb557 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ and only needed for the project-specific scenarios noted. | [Istio](https://istio.io) | `kiali` | 5 | | [Kiali](https://kiali.io) | `kiali` | 16 | | [Kubernetes](https://kubernetes.io) | - | 32 | -| [KubeVirt](https://kubevirt.io) | `kubevirt`, `tekton` | 24 | +| [KubeVirt](https://kubevirt.io) | `kubevirt`, `tekton` | 26 | | [NetObserv](https://netobserv.io) | `netobserv` | 4 | | [Tekton](https://tekton.dev) | `tekton` | 9 | @@ -561,8 +561,8 @@ In case multi-cluster support is enabled (default) and you have access to multip - `name` (`string`) **(required)** - The name of the virtual machine - `namespace` (`string`) **(required)** - The namespace of the virtual machine -- **vm_lifecycle** - Manage KubeVirt 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) +- **vm_lifecycle** - Manage KubeVirt VirtualMachine lifecycle: start, stop, restart, pause, or unpause a VM + - `action` (`string`) **(required)** - The lifecycle action to perform: 'start' (changes runStrategy to Always), 'stop' (changes runStrategy to Halted), 'restart' (stops then starts the VM), 'pause' (suspends the running VMI in-place), or 'unpause' (resumes a paused VMI) - `name` (`string`) **(required)** - The name of the virtual machine - `namespace` (`string`) **(required)** - The namespace of the virtual machine diff --git a/evals/tasks/kubevirt/pause-vm/task.yaml b/evals/tasks/kubevirt/pause-vm/task.yaml new file mode 100644 index 000000000..3f7a8fe42 --- /dev/null +++ b/evals/tasks/kubevirt/pause-vm/task.yaml @@ -0,0 +1,78 @@ +kind: Task +apiVersion: mcpchecker/v1alpha2 +metadata: + labels: + suite: kubevirt + project: kubevirt + requires: kubevirt + annotations: + project-name: "KubeVirt" + project-url: "https://kubevirt.io" + name: "pause-vm" + difficulty: easy +spec: + requires: + - extension: kubernetes + as: k8s + setup: + - k8s.delete: + apiVersion: v1 + kind: Namespace + metadata: + name: vm-test-pause + ignoreNotFound: true + - k8s.create: + apiVersion: v1 + kind: Namespace + metadata: + name: vm-test-pause + - k8s.create: + apiVersion: kubevirt.io/v1 + kind: VirtualMachine + metadata: + name: test-vm + namespace: vm-test-pause + spec: + runStrategy: Always + template: + spec: + domain: + devices: + disks: + - name: containerdisk + disk: + bus: virtio + memory: + guest: 2Gi + terminationGracePeriodSeconds: 0 + volumes: + - name: containerdisk + containerDisk: + image: quay.io/containerdisks/fedora:latest + - k8s.wait: + apiVersion: kubevirt.io/v1 + kind: VirtualMachine + metadata: + name: test-vm + namespace: vm-test-pause + condition: Ready + timeout: 300s + verify: + - k8s.wait: + apiVersion: kubevirt.io/v1 + kind: VirtualMachineInstance + metadata: + name: test-vm + namespace: vm-test-pause + condition: Paused + timeout: 120s + cleanup: + - k8s.delete: + apiVersion: v1 + kind: Namespace + metadata: + name: vm-test-pause + ignoreNotFound: true + prompt: + inline: | + Please pause the running virtual machine named test-vm in the vm-test-pause namespace. diff --git a/evals/tasks/kubevirt/unpause-vm/task.yaml b/evals/tasks/kubevirt/unpause-vm/task.yaml new file mode 100644 index 000000000..31827da28 --- /dev/null +++ b/evals/tasks/kubevirt/unpause-vm/task.yaml @@ -0,0 +1,90 @@ +kind: Task +apiVersion: mcpchecker/v1alpha2 +metadata: + labels: + suite: kubevirt + project: kubevirt + requires: kubevirt + annotations: + project-name: "KubeVirt" + project-url: "https://kubevirt.io" + name: "unpause-vm" + difficulty: easy +spec: + requires: + - extension: kubernetes + as: k8s + setup: + - k8s.delete: + apiVersion: v1 + kind: Namespace + metadata: + name: vm-test-unpause + ignoreNotFound: true + - k8s.create: + apiVersion: v1 + kind: Namespace + metadata: + name: vm-test-unpause + - k8s.create: + apiVersion: kubevirt.io/v1 + kind: VirtualMachine + metadata: + name: test-vm + namespace: vm-test-unpause + spec: + runStrategy: Always + startStrategy: Paused + template: + spec: + domain: + devices: + disks: + - name: containerdisk + disk: + bus: virtio + memory: + guest: 2Gi + terminationGracePeriodSeconds: 0 + volumes: + - name: containerdisk + containerDisk: + image: quay.io/containerdisks/fedora:latest + - k8s.wait: + apiVersion: kubevirt.io/v1 + kind: VirtualMachineInstance + metadata: + name: test-vm + namespace: vm-test-unpause + condition: Paused + timeout: 60s + verify: + - k8s.wait: + apiVersion: kubevirt.io/v1 + kind: VirtualMachineInstance + metadata: + name: test-vm + namespace: vm-test-unpause + condition: Ready + timeout: 120s + - script: + inline: |- + #!/usr/bin/env bash + # The Paused condition is removed entirely when unpaused so + # k8s.wait cannot be used. Verify the condition is absent. + PAUSED=$(kubectl get vmi test-vm -n vm-test-unpause -o jsonpath='{.status.conditions[?(@.type=="Paused")].status}') + if [[ "$PAUSED" == "True" ]]; then + echo "✗ VirtualMachineInstance still has Paused=True condition" + exit 1 + fi + echo "✓ VirtualMachineInstance is unpaused" + cleanup: + - k8s.delete: + apiVersion: v1 + kind: Namespace + metadata: + name: vm-test-unpause + ignoreNotFound: true + prompt: + inline: | + The virtual machine named test-vm in the vm-test-unpause namespace is currently paused. Please unpause it so it resumes running. diff --git a/pkg/kubevirt/guestagent.go b/pkg/kubevirt/guestagent.go index d66a7eafc..1372890e1 100644 --- a/pkg/kubevirt/guestagent.go +++ b/pkg/kubevirt/guestagent.go @@ -5,7 +5,6 @@ import ( "fmt" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/rest" ) @@ -31,21 +30,11 @@ type AllGuestInfo struct { // getVMISubresource retrieves a VMI subresource using the REST client func getVMISubresource(ctx context.Context, restConfig *rest.Config, namespace, vmiName, subresource string) (map[string]any, error) { - // Create a copy to avoid mutating the original config - config := rest.CopyConfig(restConfig) - - // Create a REST client configured for the subresources.kubevirt.io API group - gv := schema.GroupVersion{Group: "subresources.kubevirt.io", Version: "v1"} - config.GroupVersion = &gv - config.APIPath = "/apis" - config.NegotiatedSerializer = subresourcesCodec.WithoutConversion() - - restClient, err := rest.RESTClientFor(config) + restClient, err := newSubresourceClient(restConfig) if err != nil { return nil, fmt.Errorf("failed to create REST client for subresources: %w", err) } - // Make the request using SubResource() to properly construct the URL result := &unstructured.Unstructured{} err = restClient.Get(). Namespace(namespace). diff --git a/pkg/kubevirt/vm.go b/pkg/kubevirt/vm.go index 54b1856af..c75453aa7 100644 --- a/pkg/kubevirt/vm.go +++ b/pkg/kubevirt/vm.go @@ -6,7 +6,9 @@ import ( 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/client-go/rest" ) // RunStrategy represents the run strategy for a VirtualMachine @@ -144,6 +146,55 @@ func CloneVM(ctx context.Context, dynamicClient dynamic.Interface, namespace, so return result, nil } +// newSubresourceClient creates a REST client for the KubeVirt subresources API group +func newSubresourceClient(restConfig *rest.Config) (rest.Interface, error) { + cfg := rest.CopyConfig(restConfig) + cfg.GroupVersion = &schema.GroupVersion{Group: "subresources.kubevirt.io", Version: "v1"} + cfg.APIPath = "/apis" + cfg.NegotiatedSerializer = subresourcesCodec.WithoutConversion() + return rest.RESTClientFor(cfg) +} + +// PauseVM pauses a running VirtualMachineInstance via the KubeVirt subresource API +// and returns the parent VirtualMachine +func PauseVM(ctx context.Context, dynamicClient dynamic.Interface, restConfig *rest.Config, namespace, name string) (*unstructured.Unstructured, error) { + client, err := newSubresourceClient(restConfig) + if err != nil { + return nil, fmt.Errorf("failed to create subresource client: %w", err) + } + result := client.Put(). + Namespace(namespace). + Resource("virtualmachineinstances"). + Name(name). + SubResource("pause"). + Body([]byte("{}")). + Do(ctx) + if err := result.Error(); err != nil { + return nil, fmt.Errorf("failed to pause VirtualMachineInstance: %w", err) + } + return GetVirtualMachine(ctx, dynamicClient, namespace, name) +} + +// UnpauseVM unpauses a paused VirtualMachineInstance via the KubeVirt subresource API +// and returns the parent VirtualMachine +func UnpauseVM(ctx context.Context, dynamicClient dynamic.Interface, restConfig *rest.Config, namespace, name string) (*unstructured.Unstructured, error) { + client, err := newSubresourceClient(restConfig) + if err != nil { + return nil, fmt.Errorf("failed to create subresource client: %w", err) + } + result := client.Put(). + Namespace(namespace). + Resource("virtualmachineinstances"). + Name(name). + SubResource("unpause"). + Body([]byte("{}")). + Do(ctx) + if err := result.Error(); err != nil { + return nil, fmt.Errorf("failed to unpause VirtualMachineInstance: %w", err) + } + return GetVirtualMachine(ctx, dynamicClient, namespace, name) +} + // RestartVM restarts a VirtualMachine by temporarily setting runStrategy to Halted then back to Always func RestartVM(ctx context.Context, dynamicClient dynamic.Interface, namespace, name string) (*unstructured.Unstructured, error) { // Get the current VirtualMachine diff --git a/pkg/kubevirt/vm_test.go b/pkg/kubevirt/vm_test.go index 6afd4dbe2..2d90d7564 100644 --- a/pkg/kubevirt/vm_test.go +++ b/pkg/kubevirt/vm_test.go @@ -2,6 +2,8 @@ package kubevirt import ( "context" + "net/http" + "net/http/httptest" "strings" "testing" @@ -327,3 +329,187 @@ func TestRestartVMNotFound(t *testing.T) { t.Errorf("Error = %v, want to contain 'failed to get VirtualMachine'", err) } } + +func TestPauseVM(t *testing.T) { + tests := []struct { + name string + serverStatus int + serverBody string + initialVM *unstructured.Unstructured + wantError bool + errorContains string + }{ + { + name: "Pause running VM", + serverStatus: http.StatusOK, + initialVM: createTestVM("test-vm", "default", RunStrategyAlways), + wantError: false, + }, + { + name: "VMI not found", + serverStatus: http.StatusNotFound, + serverBody: `{"kind":"Status","apiVersion":"v1","status":"Failure","reason":"NotFound","message":"virtualmachineinstances.subresources.kubevirt.io \"test-vm\" not found","code":404}`, + initialVM: createTestVM("test-vm", "default", RunStrategyAlways), + wantError: true, + errorContains: "failed to pause VirtualMachineInstance", + }, + { + name: "VM already paused", + serverStatus: http.StatusConflict, + serverBody: `{"kind":"Status","apiVersion":"v1","status":"Failure","reason":"Conflict","message":"VM is already paused","code":409}`, + initialVM: createTestVM("test-vm", "default", RunStrategyAlways), + wantError: true, + errorContains: "failed to pause VirtualMachineInstance", + }, + { + name: "Migration in progress", + serverStatus: http.StatusConflict, + serverBody: `{"kind":"Status","apiVersion":"v1","status":"Failure","reason":"Conflict","message":"cannot pause VMI with a live migration in progress","code":409}`, + initialVM: createTestVM("test-vm", "default", RunStrategyAlways), + wantError: true, + errorContains: "failed to pause VirtualMachineInstance", + }, + { + name: "Pause fails when subresource returns error", + serverStatus: http.StatusInternalServerError, + initialVM: createTestVM("test-vm", "default", RunStrategyAlways), + wantError: true, + errorContains: "failed to pause VirtualMachineInstance", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if !strings.HasSuffix(r.URL.Path, "/pause") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if tt.serverBody != "" { + w.Header().Set("Content-Type", "application/json") + } + w.WriteHeader(tt.serverStatus) + if tt.serverBody != "" { + _, _ = w.Write([]byte(tt.serverBody)) + } + })) + defer server.Close() + + restConfig := createTestRESTConfig(server) + scheme := runtime.NewScheme() + dynamicClient := fake.NewSimpleDynamicClient(scheme, tt.initialVM) + ctx := context.Background() + + vm, err := PauseVM(ctx, dynamicClient, restConfig, tt.initialVM.GetNamespace(), tt.initialVM.GetName()) + + if tt.wantError { + if err == nil { + t.Errorf("Expected error, got nil") + return + } + if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Error = %v, want to contain %q", err, tt.errorContains) + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + if vm == nil { + t.Errorf("Expected non-nil VM, got nil") + } + }) + } +} + +func TestUnpauseVM(t *testing.T) { + tests := []struct { + name string + serverStatus int + serverBody string + initialVM *unstructured.Unstructured + wantError bool + errorContains string + }{ + { + name: "Unpause paused VM", + serverStatus: http.StatusOK, + initialVM: createTestVM("test-vm", "default", RunStrategyAlways), + wantError: false, + }, + { + name: "VMI not found", + serverStatus: http.StatusNotFound, + serverBody: `{"kind":"Status","apiVersion":"v1","status":"Failure","reason":"NotFound","message":"virtualmachineinstances.subresources.kubevirt.io \"test-vm\" not found","code":404}`, + initialVM: createTestVM("test-vm", "default", RunStrategyAlways), + wantError: true, + errorContains: "failed to unpause VirtualMachineInstance", + }, + { + name: "VM not paused", + serverStatus: http.StatusConflict, + serverBody: `{"kind":"Status","apiVersion":"v1","status":"Failure","reason":"Conflict","message":"VMI is not paused","code":409}`, + initialVM: createTestVM("test-vm", "default", RunStrategyAlways), + wantError: true, + errorContains: "failed to unpause VirtualMachineInstance", + }, + { + name: "Unpause fails when subresource returns error", + serverStatus: http.StatusInternalServerError, + initialVM: createTestVM("test-vm", "default", RunStrategyAlways), + wantError: true, + errorContains: "failed to unpause VirtualMachineInstance", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + if !strings.HasSuffix(r.URL.Path, "/unpause") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if tt.serverBody != "" { + w.Header().Set("Content-Type", "application/json") + } + w.WriteHeader(tt.serverStatus) + if tt.serverBody != "" { + _, _ = w.Write([]byte(tt.serverBody)) + } + })) + defer server.Close() + + restConfig := createTestRESTConfig(server) + scheme := runtime.NewScheme() + dynamicClient := fake.NewSimpleDynamicClient(scheme, tt.initialVM) + ctx := context.Background() + + vm, err := UnpauseVM(ctx, dynamicClient, restConfig, tt.initialVM.GetNamespace(), tt.initialVM.GetName()) + + if tt.wantError { + if err == nil { + t.Errorf("Expected error, got nil") + return + } + if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Error = %v, want to contain %q", err, tt.errorContains) + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + if vm == nil { + t.Errorf("Expected non-nil VM, got nil") + } + }) + } +} diff --git a/pkg/mcp/kubevirt_test.go b/pkg/mcp/kubevirt_test.go index 51c9d0c46..41d8ff0e0 100644 --- a/pkg/mcp/kubevirt_test.go +++ b/pkg/mcp/kubevirt_test.go @@ -669,6 +669,20 @@ func (s *KubevirtSuite) TestVMLifecycle() { "Expected error message about VM not found, got %v", toolResult.Content[0].(*mcp.TextContent).Text) }) } + for _, action := range []string{"pause", "unpause"} { + s.Run("action="+action, func() { + toolResult, err := s.CallTool("vm_lifecycle", map[string]interface{}{ + "name": "non-existent-vm", + "namespace": "default", + "action": action, + }) + s.Nilf(err, "call tool failed %v", err) + s.Truef(toolResult.IsError, "expected call tool to fail for non-existent VM") + expectedErr := fmt.Sprintf("failed to %s VirtualMachineInstance", action) + s.Truef(strings.Contains(toolResult.Content[0].(*mcp.TextContent).Text, expectedErr), + "Expected error message for non-existent VM, got %v", toolResult.Content[0].(*mcp.TextContent).Text) + }) + } }) } diff --git a/pkg/mcp/testdata/toolsets-kubevirt-tools.json b/pkg/mcp/testdata/toolsets-kubevirt-tools.json index 3f4da3b7d..b65653188 100644 --- a/pkg/mcp/testdata/toolsets-kubevirt-tools.json +++ b/pkg/mcp/testdata/toolsets-kubevirt-tools.json @@ -243,15 +243,17 @@ "readOnlyHint": false, "title": "Virtual Machine: Lifecycle" }, - "description": "Manage KubeVirt VirtualMachine lifecycle: start, stop, or restart a VM", + "description": "Manage KubeVirt VirtualMachine lifecycle: start, stop, restart, pause, or unpause a VM", "inputSchema": { "properties": { "action": { - "description": "The lifecycle action to perform: 'start' (changes runStrategy to Always), 'stop' (changes runStrategy to Halted), or 'restart' (stops then starts the VM)", + "description": "The lifecycle action to perform: 'start' (changes runStrategy to Always), 'stop' (changes runStrategy to Halted), 'restart' (stops then starts the VM), 'pause' (suspends the running VMI in-place), or 'unpause' (resumes a paused VMI)", "enum": [ "start", "stop", - "restart" + "restart", + "pause", + "unpause" ], "type": "string" }, diff --git a/pkg/toolsets/kubevirt/vm/lifecycle/tool.go b/pkg/toolsets/kubevirt/vm/lifecycle/tool.go index 474cba43d..f427448cb 100644 --- a/pkg/toolsets/kubevirt/vm/lifecycle/tool.go +++ b/pkg/toolsets/kubevirt/vm/lifecycle/tool.go @@ -19,6 +19,8 @@ const ( ActionStart Action = "start" ActionStop Action = "stop" ActionRestart Action = "restart" + ActionPause Action = "pause" + ActionUnpause Action = "unpause" ) func Tools(p api.FilteringProvider) []api.ServerTool { @@ -26,7 +28,7 @@ func Tools(p api.FilteringProvider) []api.ServerTool { { Tool: api.Tool{ Name: "vm_lifecycle", - Description: fmt.Sprintf("Manage %s VirtualMachine lifecycle: start, stop, or restart a VM", defaults.ProductName()), + Description: fmt.Sprintf("Manage %s VirtualMachine lifecycle: start, stop, restart, pause, or unpause a VM", defaults.ProductName()), InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -40,8 +42,8 @@ func Tools(p api.FilteringProvider) []api.ServerTool { }, "action": { Type: "string", - Enum: []any{string(ActionStart), string(ActionStop), string(ActionRestart)}, - Description: "The lifecycle action to perform: 'start' (changes runStrategy to Always), 'stop' (changes runStrategy to Halted), or 'restart' (stops then starts the VM)", + Enum: []any{string(ActionStart), string(ActionStop), string(ActionRestart), string(ActionPause), string(ActionUnpause)}, + Description: "The lifecycle action to perform: 'start' (changes runStrategy to Always), 'stop' (changes runStrategy to Halted), 'restart' (stops then starts the VM), 'pause' (suspends the running VMI in-place), or 'unpause' (resumes a paused VMI)", }, }, Required: []string{"namespace", "name", "action"}, @@ -116,8 +118,22 @@ func lifecycle(params api.ToolHandlerParams) (*api.ToolCallResult, error) { } message = "# VirtualMachine restarted successfully\n" + case ActionPause: + vm, err = kubevirt.PauseVM(params.Context, dynamicClient, params.RESTConfig(), namespace, name) + if err != nil { + return api.NewToolCallResult("", err), nil + } + message = "# VirtualMachine paused successfully\n" + + case ActionUnpause: + vm, err = kubevirt.UnpauseVM(params.Context, dynamicClient, params.RESTConfig(), namespace, name) + if err != nil { + return api.NewToolCallResult("", err), nil + } + message = "# VirtualMachine unpaused successfully\n" + default: - return api.NewToolCallResult("", fmt.Errorf("invalid action '%s': must be one of 'start', 'stop', 'restart'", action)), nil + return api.NewToolCallResult("", fmt.Errorf("invalid action '%s': must be one of 'start', 'stop', 'restart', 'pause', 'unpause'", action)), nil } // Format the output