From f7168252793411b4ea7eebbcdf31be3b8cbfa99a Mon Sep 17 00:00:00 2001 From: Yibo Zhuang Date: Wed, 23 Sep 2026 12:24:57 -0700 Subject: [PATCH] Make USB accessories opt-in for Tart VMs (#502) --- api/openapi.yaml | 9 ++++ internal/command/create/vm.go | 3 ++ internal/controller/api_vms.go | 4 ++ internal/tests/usb_accessories_spec_test.go | 57 +++++++++++++++++++++ internal/worker/vmmanager/tart/tart.go | 4 ++ internal/worker/vmmanager/tart/tart_test.go | 35 ++++++++++++- pkg/resource/v1/usb_accessories_test.go | 30 +++++++++++ pkg/resource/v1/v1.go | 4 ++ 8 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 internal/tests/usb_accessories_spec_test.go create mode 100644 pkg/resource/v1/usb_accessories_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index 76dbb06..b7b7c6f 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -729,6 +729,15 @@ components: - "66.66.0.0/16" items: type: string + usbAccessories: + type: boolean + description: | + Whether to enable USB accessories in a Tart VM. When disabled, the worker + passes `--no-usb-accessories` to `tart run`. Requires a Tart version that supports + this flag. Native Mac input remains available where supported. + Changing this setting restarts the VM and is not supported for + suspendable VMs. + default: false suspendable: type: boolean description: | diff --git a/internal/command/create/vm.go b/internal/command/create/vm.go index 8714e92..f9f2afb 100644 --- a/internal/command/create/vm.go +++ b/internal/command/create/vm.go @@ -31,6 +31,7 @@ var nested bool var audio bool var clipboard bool var suspendable bool +var usbAccessories bool var username string var password string var resources map[string]string @@ -75,6 +76,7 @@ func newCreateVMCommand() *cobra.Command { command.Flags().BoolVar(&nested, "nested", false, "enable nested virtualization") command.Flags().BoolVar(&audio, "audio", false, "enable audio pass-through to the host") command.Flags().BoolVar(&clipboard, "clipboard", false, "enable clipboard sharing between host and guest") + command.Flags().BoolVar(&usbAccessories, "usb-accessories", false, "enable USB accessories in a Tart VM") command.Flags().BoolVar(&suspendable, "suspendable", false, "treat the VM as suspendable, "+ "disabling certain devices for suspendability support and issuing \"tart suspend\" instead of \"tart stop\" "+ "when VM's specification is updated, thus preserving the VM's state between specification generations") @@ -160,6 +162,7 @@ func runCreateVM(cmd *cobra.Command, args []string) error { NetSoftnetAllow: netSoftnetAllow, NetSoftnetBlock: netSoftnetBlock, Suspendable: suspendable, + USBAccessories: usbAccessories, }, NetBridged: netBridged, Headless: headless, diff --git a/internal/controller/api_vms.go b/internal/controller/api_vms.go index 823b576..71292df 100644 --- a/internal/controller/api_vms.go +++ b/internal/controller/api_vms.go @@ -238,6 +238,10 @@ func (controller *Controller) updateVMSpec(ctx *gin.Context) responder.Responder return responder.JSON(http.StatusPreconditionFailed, NewErrorResponse("\"netSoftnet\" cannot be "+ "toggled for suspendable VMs")) } + if dbVM.Suspendable && dbVM.USBAccessories != userVM.USBAccessories { + return responder.JSON(http.StatusPreconditionFailed, NewErrorResponse("\"usbAccessories\" cannot be "+ + "toggled for suspendable VMs")) + } // Power state-specific sanity checks if !userVM.PowerState.Valid() { diff --git a/internal/tests/usb_accessories_spec_test.go b/internal/tests/usb_accessories_spec_test.go new file mode 100644 index 0000000..59fa5be --- /dev/null +++ b/internal/tests/usb_accessories_spec_test.go @@ -0,0 +1,57 @@ +package tests_test + +import ( + "testing" + + "github.com/cirruslabs/orchard/internal/controller" + "github.com/cirruslabs/orchard/internal/tests/devcontroller" + "github.com/cirruslabs/orchard/internal/worker" + v1 "github.com/cirruslabs/orchard/pkg/resource/v1" + "github.com/stretchr/testify/require" +) + +func TestUSBAccessoriesSpecUpdate(t *testing.T) { + devClient, _, _ := devcontroller.StartIntegrationTestEnvironmentWithAdditionalOpts( + t, false, []controller.Option{controller.WithSynthetic()}, + true, []worker.Option{worker.WithSynthetic()}, + ) + + for _, test := range []struct { + name string + usbAccessories bool + suspendable bool + }{ + {name: "enable"}, + {name: "disable", usbAccessories: true}, + {name: "suspendable-enable", suspendable: true}, + {name: "suspendable-disable", usbAccessories: true, suspendable: true}, + } { + t.Run(test.name, func(t *testing.T) { + require.NoError(t, devClient.VMs().Create(t.Context(), &v1.VM{ + Name: test.name, + Image: "example.com/test:latest", + USBAccessories: test.usbAccessories, + Suspendable: test.suspendable, + })) + vm, err := devClient.VMs().Get(t.Context(), test.name) + require.NoError(t, err) + generation := vm.Generation + vm.USBAccessories = !vm.USBAccessories + + updated, err := devClient.VMs().Update(t.Context(), *vm) + if test.suspendable { + require.ErrorContains(t, err, `"usbAccessories" cannot be toggled for suspendable VMs`) + unchanged, getErr := devClient.VMs().Get(t.Context(), test.name) + require.NoError(t, getErr) + require.Equal(t, generation, unchanged.Generation) + require.Equal(t, test.usbAccessories, unchanged.USBAccessories) + return + } + + require.NoError(t, err) + require.Equal(t, generation+1, updated.Generation) + require.Equal(t, !test.usbAccessories, updated.USBAccessories) + require.False(t, updated.Suspendable) + }) + } +} diff --git a/internal/worker/vmmanager/tart/tart.go b/internal/worker/vmmanager/tart/tart.go index 771b39a..5e81b54 100644 --- a/internal/worker/vmmanager/tart/tart.go +++ b/internal/worker/vmmanager/tart/tart.go @@ -372,6 +372,10 @@ func (vm *VM) run(ctx context.Context, eventStreamer *client.EventStreamer) { runArgs = append(runArgs, "--no-clipboard") } + if !resource.USBAccessories { + runArgs = append(runArgs, "--no-usb-accessories") + } + if resource.Suspendable { runArgs = append(runArgs, "--suspendable") } diff --git a/internal/worker/vmmanager/tart/tart_test.go b/internal/worker/vmmanager/tart/tart_test.go index 87688aa..ee0048b 100644 --- a/internal/worker/vmmanager/tart/tart_test.go +++ b/internal/worker/vmmanager/tart/tart_test.go @@ -54,6 +54,39 @@ func TestCloneAndConfigurePreservesSuspendedVM(t *testing.T) { } } +func TestRunUSBAccessoriesConfiguration(t *testing.T) { + for _, test := range []struct { + name string + usbAccessories bool + suspendable bool + wantFlags string + }{ + {name: "default", wantFlags: "--no-usb-accessories "}, + {name: "USB accessories enabled", usbAccessories: true}, + {name: "suspendable", suspendable: true, wantFlags: "--no-usb-accessories --suspendable "}, + {name: "both", usbAccessories: true, suspendable: true, wantFlags: "--suspendable "}, + } { + t.Run(test.name, func(t *testing.T) { + commandLog := installCloneFakeTart(t, "", "") + vm := newCloneTestVM(v1.VM{ + Name: "test-vm", + UID: "00112233-4455-6677-8899-aabbccddeeff", + Audio: true, + Clipboard: true, + USBAccessories: test.usbAccessories, + Suspendable: test.suspendable, + }) + vm.ctx = t.Context() + vm.ConditionsSet().Add(v1.ConditionTypeStopping) + + vm.run(t.Context(), nil) + + require.NoError(t, vm.Err()) + requireCloneCommands(t, commandLog, []string{"run " + test.wantFlags + vm.id()}) + }) + } +} + func TestCloneAndConfigureConfiguresStoppedVM(t *testing.T) { tests := []struct { name string @@ -197,7 +230,7 @@ if [ "$1" = "$ORCHARD_TEST_TART_FAILED_COMMAND" ]; then exit 1 fi case "$1" in - clone|set) ;; + clone|set|run) ;; fqn) printf 'registry.example/source@sha256:abc\n' ;; get) printf '%s\n' "$ORCHARD_TEST_TART_INFO" ;; *) printf 'unexpected command: %s\n' "$*" >&2; exit 1 ;; diff --git a/pkg/resource/v1/usb_accessories_test.go b/pkg/resource/v1/usb_accessories_test.go new file mode 100644 index 0000000..4d67194 --- /dev/null +++ b/pkg/resource/v1/usb_accessories_test.go @@ -0,0 +1,30 @@ +package v1_test + +import ( + "encoding/json" + "testing" + + v1 "github.com/cirruslabs/orchard/pkg/resource/v1" + "github.com/stretchr/testify/require" +) + +func TestUSBAccessoriesSpecification(t *testing.T) { + var vm v1.VM + require.NoError(t, json.Unmarshal([]byte(`{"runtime":"tart"}`), &vm)) + require.False(t, vm.USBAccessories) + require.False(t, vm.Suspendable) + require.NoError(t, vm.Validate()) + + previous := vm.VMSpec + vm.USBAccessories = true + require.False(t, v1.SemanticallyEqual(previous, vm.VMSpec)) + + encoded, err := json.Marshal(vm.VMSpec) + require.NoError(t, err) + require.Contains(t, string(encoded), `"usbAccessories":true`) + + vm.Runtime = v1.RuntimeVetu + require.ErrorContains(t, vm.Validate(), `does not support field "usbAccessories"`) + vm.USBAccessories = false + require.NoError(t, vm.Validate()) +} diff --git a/pkg/resource/v1/v1.go b/pkg/resource/v1/v1.go index 4700c16..02bc814 100644 --- a/pkg/resource/v1/v1.go +++ b/pkg/resource/v1/v1.go @@ -180,6 +180,9 @@ func (vm *VM) Validate() error { if vm.Suspendable { return unsupportedFieldError("suspendable") } + if vm.USBAccessories { + return unsupportedFieldError("usbAccessories") + } } return nil @@ -211,6 +214,7 @@ type VMSpec struct { NetSoftnetAllow []string `json:"netSoftnetAllow,omitempty"` NetSoftnetBlock []string `json:"netSoftnetBlock,omitempty"` Suspendable bool `json:"suspendable,omitempty"` + USBAccessories bool `json:"usbAccessories,omitempty"` PowerState PowerState `json:"powerState,omitempty"` }