Make USB accessories opt-in for Tart VMs (#502)

This commit is contained in:
Yibo Zhuang
2026-09-23 12:24:57 -07:00
committed by GitHub
parent 7e31732df6
commit f716825279
8 changed files with 145 additions and 1 deletions
+9
View File
@@ -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: |
+3
View File
@@ -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,
+4
View File
@@ -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() {
@@ -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)
})
}
}
+4
View File
@@ -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")
}
+34 -1
View File
@@ -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 ;;
+30
View File
@@ -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())
}
+4
View File
@@ -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"`
}