Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 5 additions & 4 deletions pkg/kubernetes/accesscontrol_round_tripper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +75 to +79
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lyarwood I get why you need this change for kubevirt, but the original behavior was actually a requirement we were given.

I believe the AccessControlRoundTripperConfig already contains a DiscoveryProvider which should contain all the API groups (including subresources). Would that work for your use case?

If not we can discuss moving forwards with the proposed change, I would just prefer to find a way to keep the existing behaviour for actual invalid groups

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AccessControlRoundTripper SHOULD NOT be made.
If certain API groups need exceptions, we'll need to find a proper way to handle those via the toolset API, and make it clear and explicit that certain routes are being allowed regardless of the security enforcements.

}
return nil, fmt.Errorf("failed to make request: AccessControlRoundTripper failed to get kind for gvr %v: %w", gvr, err)
}
Expand Down
10 changes: 4 additions & 6 deletions pkg/kubernetes/accesscontrol_round_tripper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
}

Expand Down
53 changes: 53 additions & 0 deletions pkg/kubevirt/vm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions pkg/mcp/kubevirt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
})
}

Expand Down
8 changes: 5 additions & 3 deletions pkg/mcp/testdata/toolsets-kubevirt-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
24 changes: 20 additions & 4 deletions pkg/toolsets/kubevirt/vm/lifecycle/tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@ const (
ActionStart Action = "start"
ActionStop Action = "stop"
ActionRestart Action = "restart"
ActionPause Action = "pause"
ActionUnpause Action = "unpause"
)

func Tools() []api.ServerTool {
return []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{
Expand All @@ -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"},
Expand Down Expand Up @@ -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
Expand Down