diff --git a/README.md b/README.md index b42e8dfc2..1574ae830 100644 --- a/README.md +++ b/README.md @@ -508,8 +508,8 @@ 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_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) +- **vm_lifecycle** - Manage 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/pkg/kubernetes/accesscontrol_round_tripper.go b/pkg/kubernetes/accesscontrol_round_tripper.go index a97db9adf..62503545b 100644 --- a/pkg/kubernetes/accesscontrol_round_tripper.go +++ b/pkg/kubernetes/accesscontrol_round_tripper.go @@ -72,10 +72,11 @@ func (rt *AccessControlRoundTripper) RoundTrip(req *http.Request) (*http.Respons gvk, err := restMapper.KindFor(gvr) if err != nil { if meta.IsNoMatchError(err) { - return nil, &api.ValidationError{ - Code: api.ErrorCodeResourceNotFound, - Message: fmt.Sprintf("Resource %s does not exist in the cluster", api.FormatResourceName(&gvr)), - } + // Some API groups (e.g. subresources.kubevirt.io) serve valid + // endpoints that are not discoverable via the REST mapper. + // Let the API server decide whether the resource exists. + klog.V(4).Infof("Resource %s not found in REST mapper, passing through to API server", api.FormatResourceName(&gvr)) + return rt.delegate.RoundTrip(req) } return nil, fmt.Errorf("failed to make request: AccessControlRoundTripper failed to get kind for gvr %v: %w", gvr, err) } diff --git a/pkg/kubernetes/accesscontrol_round_tripper_test.go b/pkg/kubernetes/accesscontrol_round_tripper_test.go index facfa5bbd..2590353a3 100644 --- a/pkg/kubernetes/accesscontrol_round_tripper_test.go +++ b/pkg/kubernetes/accesscontrol_round_tripper_test.go @@ -279,16 +279,14 @@ func (s *AccessControlRoundTripperTestSuite) TestRoundTripForDeniedAPIResources( }) - s.Run("RESTMapper error for unknown resource", func() { + s.Run("RESTMapper passes through for unknown resource", func() { rt.deniedResourcesProvider = nil delegateCalled = false req := httptest.NewRequest("GET", "/api/v1/unknownresources", nil) resp, err := rt.RoundTrip(req) - s.Error(err) - s.Nil(resp) - s.False(delegateCalled, "Expected delegate not to be called when RESTMapper fails") - s.Contains(err.Error(), "RESOURCE_NOT_FOUND") - s.Contains(err.Error(), "does not exist in the cluster") + s.NoError(err) + s.NotNil(resp) + s.True(delegateCalled, "Expected delegate to be called for unknown resources") }) } diff --git a/pkg/kubevirt/vm.go b/pkg/kubevirt/vm.go index 54b1856af..7690c6979 100644 --- a/pkg/kubevirt/vm.go +++ b/pkg/kubevirt/vm.go @@ -6,7 +6,11 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" ) // RunStrategy represents the run strategy for a VirtualMachine @@ -144,6 +148,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 = serializer.NewCodecFactory(runtime.NewScheme()) + 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/mcp/kubevirt_test.go b/pkg/mcp/kubevirt_test.go index 950900d7d..7813142c0 100644 --- a/pkg/mcp/kubevirt_test.go +++ b/pkg/mcp/kubevirt_test.go @@ -666,6 +666,19 @@ 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") + s.Truef(strings.Contains(toolResult.Content[0].(*mcp.TextContent).Text, "failed to"), + "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 183db2c79..460aafe58 100644 --- a/pkg/mcp/testdata/toolsets-kubevirt-tools.json +++ b/pkg/mcp/testdata/toolsets-kubevirt-tools.json @@ -162,15 +162,17 @@ "openWorldHint": false, "title": "Virtual Machine: Lifecycle" }, - "description": "Manage VirtualMachine lifecycle: start, stop, or restart a VM", + "description": "Manage 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 517b0bcae..fa62cbfee 100644 --- a/pkg/toolsets/kubevirt/vm/lifecycle/tool.go +++ b/pkg/toolsets/kubevirt/vm/lifecycle/tool.go @@ -18,6 +18,8 @@ const ( ActionStart Action = "start" ActionStop Action = "stop" ActionRestart Action = "restart" + ActionPause Action = "pause" + ActionUnpause Action = "unpause" ) func Tools() []api.ServerTool { @@ -25,7 +27,7 @@ func Tools() []api.ServerTool { { Tool: api.Tool{ Name: "vm_lifecycle", - Description: "Manage VirtualMachine lifecycle: start, stop, or restart a VM", + Description: "Manage VirtualMachine lifecycle: start, stop, restart, pause, or unpause a VM", InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -39,8 +41,8 @@ func Tools() []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"}, @@ -112,8 +114,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