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" - "66.66.0.0/16"
items: items:
type: string 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: suspendable:
type: boolean type: boolean
description: | description: |
+3
View File
@@ -31,6 +31,7 @@ var nested bool
var audio bool var audio bool
var clipboard bool var clipboard bool
var suspendable bool var suspendable bool
var usbAccessories bool
var username string var username string
var password string var password string
var resources map[string]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(&nested, "nested", false, "enable nested virtualization")
command.Flags().BoolVar(&audio, "audio", false, "enable audio pass-through to the host") 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(&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, "+ 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\" "+ "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") "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, NetSoftnetAllow: netSoftnetAllow,
NetSoftnetBlock: netSoftnetBlock, NetSoftnetBlock: netSoftnetBlock,
Suspendable: suspendable, Suspendable: suspendable,
USBAccessories: usbAccessories,
}, },
NetBridged: netBridged, NetBridged: netBridged,
Headless: headless, 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 "+ return responder.JSON(http.StatusPreconditionFailed, NewErrorResponse("\"netSoftnet\" cannot be "+
"toggled for suspendable VMs")) "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 // Power state-specific sanity checks
if !userVM.PowerState.Valid() { 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") runArgs = append(runArgs, "--no-clipboard")
} }
if !resource.USBAccessories {
runArgs = append(runArgs, "--no-usb-accessories")
}
if resource.Suspendable { if resource.Suspendable {
runArgs = append(runArgs, "--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) { func TestCloneAndConfigureConfiguresStoppedVM(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -197,7 +230,7 @@ if [ "$1" = "$ORCHARD_TEST_TART_FAILED_COMMAND" ]; then
exit 1 exit 1
fi fi
case "$1" in case "$1" in
clone|set) ;; clone|set|run) ;;
fqn) printf 'registry.example/source@sha256:abc\n' ;; fqn) printf 'registry.example/source@sha256:abc\n' ;;
get) printf '%s\n' "$ORCHARD_TEST_TART_INFO" ;; get) printf '%s\n' "$ORCHARD_TEST_TART_INFO" ;;
*) printf 'unexpected command: %s\n' "$*" >&2; exit 1 ;; *) 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 { if vm.Suspendable {
return unsupportedFieldError("suspendable") return unsupportedFieldError("suspendable")
} }
if vm.USBAccessories {
return unsupportedFieldError("usbAccessories")
}
} }
return nil return nil
@@ -211,6 +214,7 @@ type VMSpec struct {
NetSoftnetAllow []string `json:"netSoftnetAllow,omitempty"` NetSoftnetAllow []string `json:"netSoftnetAllow,omitempty"`
NetSoftnetBlock []string `json:"netSoftnetBlock,omitempty"` NetSoftnetBlock []string `json:"netSoftnetBlock,omitempty"`
Suspendable bool `json:"suspendable,omitempty"` Suspendable bool `json:"suspendable,omitempty"`
USBAccessories bool `json:"usbAccessories,omitempty"`
PowerState PowerState `json:"powerState,omitempty"` PowerState PowerState `json:"powerState,omitempty"`
} }