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
2 changes: 2 additions & 0 deletions api/core/v1alpha2/usbdevicecondition/condition.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ const (
Available AttachedReason = "Available"
// DetachedForMigration signifies that device was detached for migration (e.g. live migration).
DetachedForMigration AttachedReason = "DetachedForMigration"
// NoFreeUSBIPPort signifies that device cannot be attached because there are no free USBIP ports on the target node.
NoFreeUSBIPPort AttachedReason = "NoFreeUSBIPPort"
)

func (r ReadyReason) String() string {
Expand Down
1 change: 1 addition & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3734,6 +3734,7 @@ The [USBDevice](/modules/virtualization/cr.html#usbdevice) resource provides sta
- **Attached**: Indicates whether the device is attached to a virtual machine.
- `AttachedToVirtualMachine`: Device is attached to a VM.
- `Available`: Device is available for attachment.
- `NoFreeUSBIPPort`: Device is requested by a VM but cannot be attached because there are no free USBIP ports on the target node. In this case, `Attached=False`.

### Attaching USB Device to VM

Expand Down
1 change: 1 addition & 0 deletions docs/USER_GUIDE.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -3770,6 +3770,7 @@ logitech-webcam node-2 Logitech Webcam C920 ABC123456 False
- **Attached**: Указывает, подключено ли устройство к виртуальной машине.
- `AttachedToVirtualMachine` — устройство подключено к ВМ;
- `Available` — устройство доступно для подключения;
- `NoFreeUSBIPPort` — устройство запрошено ВМ, но не может быть подключено, так как на целевом узле нет свободных USBIP-портов. В этом случае `Attached=False`.

### Подключение USB-устройства к ВМ

Expand Down
129 changes: 129 additions & 0 deletions images/virtualization-artifact/pkg/common/usb/availability.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
Copyright 2026 Flant JSC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package usb

import (
"context"
"fmt"

corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"sigs.k8s.io/controller-runtime/pkg/client"

"github.com/deckhouse/virtualization-controller/pkg/common/annotations"
"github.com/deckhouse/virtualization-controller/pkg/controller/indexer"
"github.com/deckhouse/virtualization/api/core/v1alpha2"
)

func CheckFreePortOnNodeExcludingLocalUSBs(ctx context.Context, cl client.Client, nodeName string, speed int) (bool, error) {
return CheckFreePortForRequestOnNodeExcludingLocalUSBs(ctx, cl, nodeName, speed, 1)
}

func CheckFreePortForRequestOnNodeExcludingLocalUSBs(ctx context.Context, cl client.Client, nodeName string, speed, requestedCount int) (bool, error) {
node := &corev1.Node{}
if err := cl.Get(ctx, client.ObjectKey{Name: nodeName}, node); err != nil {
return false, err
}

isHS, isSS := ResolveSpeed(speed)
if !isHS && !isSS {
return false, fmt.Errorf("unsupported USB speed: %d", speed)
}

totalPortsPerHub, err := GetTotalPortsPerHub(node.Annotations)
if err != nil {
return false, err
}

usedPorts, err := getUsedPortsForSpeed(node.Annotations, speed)
if err != nil {
return false, err
}

excludedLocalUSBs, err := countLocalAttachedUSBsOnNodeBySpeed(ctx, cl, nodeName, speed)
if err != nil {
return false, err
}

effectiveUsedPorts := usedPorts - excludedLocalUSBs
if effectiveUsedPorts < 0 {
effectiveUsedPorts = 0
}

return (effectiveUsedPorts + requestedCount) <= totalPortsPerHub, nil
}

func getUsedPortsForSpeed(nodeAnnotations map[string]string, speed int) (int, error) {
isHS, isSS := ResolveSpeed(speed)

switch {
case isHS:
return GetUsedPorts(nodeAnnotations, annotations.AnnUSBIPHighSpeedHubUsedPorts)
case isSS:
return GetUsedPorts(nodeAnnotations, annotations.AnnUSBIPSuperSpeedHubUsedPorts)
default:
return 0, fmt.Errorf("unsupported USB speed: %d", speed)
}
}

func countLocalAttachedUSBsOnNodeBySpeed(ctx context.Context, cl client.Client, nodeName string, speed int) (int, error) {
var vmList v1alpha2.VirtualMachineList
if err := cl.List(ctx, &vmList, client.MatchingFields{indexer.IndexFieldVMByNode: nodeName}); err != nil {
return 0, err
}

count := 0
usbCache := make(map[client.ObjectKey]*v1alpha2.USBDevice)
for i := range vmList.Items {
vm := &vmList.Items[i]
for _, usbStatus := range vm.Status.USBDevices {
if !usbStatus.Attached {
continue
}

key := client.ObjectKey{Name: usbStatus.Name, Namespace: vm.Namespace}
usbDevice, ok := usbCache[key]
if !ok {
usbDevice = &v1alpha2.USBDevice{}
if err := cl.Get(ctx, key, usbDevice); err != nil {
if apierrors.IsNotFound(err) {
continue
}
return 0, err
}
usbCache[key] = usbDevice
}

if usbDevice.Status.NodeName != nodeName {
continue
}

if sameSpeedClass(usbDevice.Status.Attributes.Speed, speed) {
count++
}
}
}

return count, nil
}

func sameSpeedClass(deviceSpeed, requestedSpeed int) bool {
deviceHS, deviceSS := ResolveSpeed(deviceSpeed)
requestedHS, requestedSS := ResolveSpeed(requestedSpeed)

return (deviceHS && requestedHS) || (deviceSS && requestedSS)
}
131 changes: 131 additions & 0 deletions images/virtualization-artifact/pkg/common/usb/availability_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
Copyright 2026 Flant JSC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package usb

import (
"context"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
apiruntime "k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"

"github.com/deckhouse/virtualization-controller/pkg/common/annotations"
"github.com/deckhouse/virtualization-controller/pkg/controller/indexer"
"github.com/deckhouse/virtualization/api/core/v1alpha2"
)

var _ = Describe("availability helpers", func() {
newNode := func(name string, totalPorts, usedHSPorts, usedSSPorts string) *corev1.Node {
return &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: name, Annotations: map[string]string{
annotations.AnnUSBIPTotalPorts: totalPorts,
annotations.AnnUSBIPHighSpeedHubUsedPorts: usedHSPorts,
annotations.AnnUSBIPSuperSpeedHubUsedPorts: usedSSPorts,
}}}
}

newVM := func(name, nodeName string, statuses ...v1alpha2.USBDeviceStatusRef) *v1alpha2.VirtualMachine {
return &v1alpha2.VirtualMachine{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
Status: v1alpha2.VirtualMachineStatus{
Node: nodeName,
USBDevices: statuses,
},
}
}

newUSBDevice := func(name, nodeName string, speed int) *v1alpha2.USBDevice {
return &v1alpha2.USBDevice{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
Status: v1alpha2.USBDeviceStatus{
NodeName: nodeName,
Attributes: v1alpha2.NodeUSBDeviceAttributes{
Speed: speed,
},
},
}
}

newClient := func(objects ...client.Object) client.Client {
scheme := apiruntime.NewScheme()
Expect(v1alpha2.AddToScheme(scheme)).To(Succeed())
Expect(corev1.AddToScheme(scheme)).To(Succeed())

vmNodeObj, vmNodeField, vmNodeExtractValue := indexer.IndexVMByNode()
return fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(objects...).
WithIndex(vmNodeObj, vmNodeField, vmNodeExtractValue).
Build()
}

It("excludes local attached USB devices of the same speed class from used port accounting", func() {
cl := newClient(
newNode("node-1", "2", "1", "0"),
newVM("vm-1", "node-1", v1alpha2.USBDeviceStatusRef{Name: "usb-local", Attached: true}),
newUSBDevice("usb-local", "node-1", 480),
)

hasFree, err := CheckFreePortForRequestOnNodeExcludingLocalUSBs(context.Background(), cl, "node-1", 480, 1)
Expect(err).NotTo(HaveOccurred())
Expect(hasFree).To(BeTrue())
})

It("does not exclude local attached USB devices from another speed class", func() {
cl := newClient(
newNode("node-1", "2", "1", "0"),
newVM("vm-1", "node-1", v1alpha2.USBDeviceStatusRef{Name: "usb-local-ss", Attached: true}),
newUSBDevice("usb-local-ss", "node-1", 5000),
)

hasFree, err := CheckFreePortForRequestOnNodeExcludingLocalUSBs(context.Background(), cl, "node-1", 480, 1)
Expect(err).NotTo(HaveOccurred())
Expect(hasFree).To(BeFalse())
})

It("ignores stale VM status entries when the referenced USBDevice is missing", func() {
cl := newClient(
newNode("node-1", "2", "1", "0"),
newVM("vm-1", "node-1", v1alpha2.USBDeviceStatusRef{Name: "missing-usb", Attached: true}),
)

hasFree, err := CheckFreePortForRequestOnNodeExcludingLocalUSBs(context.Background(), cl, "node-1", 480, 1)
Expect(err).NotTo(HaveOccurred())
Expect(hasFree).To(BeFalse())
})

It("clamps effective used ports to zero when excluded local devices exceed node annotations", func() {
cl := newClient(
newNode("node-1", "2", "0", "0"),
newVM(
"vm-1",
"node-1",
v1alpha2.USBDeviceStatusRef{Name: "usb-local-1", Attached: true},
v1alpha2.USBDeviceStatusRef{Name: "usb-local-2", Attached: true},
),
newUSBDevice("usb-local-1", "node-1", 480),
newUSBDevice("usb-local-2", "node-1", 480),
)

hasFree, err := CheckFreePortForRequestOnNodeExcludingLocalUSBs(context.Background(), cl, "node-1", 480, 1)
Expect(err).NotTo(HaveOccurred())
Expect(hasFree).To(BeTrue())
})
})
112 changes: 112 additions & 0 deletions images/virtualization-artifact/pkg/common/usb/speed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
Copyright 2026 Flant JSC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package usb

import (
"fmt"
"strconv"

"github.com/deckhouse/virtualization-controller/pkg/common/annotations"
)

// ResolveSpeed determines USB hub type from speed in Mbps.
// https://mjmwired.net/kernel/Documentation/ABI/testing/sysfs-bus-usb#502
func ResolveSpeed(speed int) (isHS, isSS bool) {
return speed == 480, speed >= 5000
}

// GetTotalPortsPerHub returns the number of ports per hub (total / 2).
func GetTotalPortsPerHub(nodeAnnotations map[string]string) (int, error) {
totalPortsStr, exists := nodeAnnotations[annotations.AnnUSBIPTotalPorts]
if !exists {
return 0, fmt.Errorf("node does not have %s annotation", annotations.AnnUSBIPTotalPorts)
}
totalPorts, err := strconv.Atoi(totalPortsStr)
if err != nil {
return 0, fmt.Errorf("failed to parse %s annotation: %w", annotations.AnnUSBIPTotalPorts, err)
}
return totalPorts / 2, nil
}

// GetUsedPorts returns the number of used ports for the given hub type.
func GetUsedPorts(nodeAnnotations map[string]string, hubAnnotation string) (int, error) {
usedPortsStr, exists := nodeAnnotations[hubAnnotation]
if !exists {
return 0, fmt.Errorf("node does not have %s annotation", hubAnnotation)
}
usedPorts, err := strconv.Atoi(usedPortsStr)
if err != nil {
return 0, fmt.Errorf("failed to parse %s annotation: %w", hubAnnotation, err)
}
return usedPorts, nil
}

// CheckFreePort checks if a node has free USBIP ports for the given speed.
// Returns true if there is at least one free port, false otherwise.
func CheckFreePort(nodeAnnotations map[string]string, speed int) (bool, error) {
isHS, isSS := ResolveSpeed(speed)

var hubAnnotation string
switch {
case isHS:
hubAnnotation = annotations.AnnUSBIPHighSpeedHubUsedPorts
case isSS:
hubAnnotation = annotations.AnnUSBIPSuperSpeedHubUsedPorts
default:
return false, fmt.Errorf("unsupported USB speed: %d", speed)
}

totalPortsPerHub, err := GetTotalPortsPerHub(nodeAnnotations)
if err != nil {
return false, err
}

usedPorts, err := GetUsedPorts(nodeAnnotations, hubAnnotation)
if err != nil {
return false, err
}

return usedPorts < totalPortsPerHub, nil
}

// CheckFreePortForRequest checks if there are enough free ports for a specific request.
// It adds the requested count to the currently used ports and compares with total.
func CheckFreePortForRequest(nodeAnnotations map[string]string, speed, requestedCount int) (bool, error) {
isHS, isSS := ResolveSpeed(speed)

var hubAnnotation string
switch {
case isHS:
hubAnnotation = annotations.AnnUSBIPHighSpeedHubUsedPorts
case isSS:
hubAnnotation = annotations.AnnUSBIPSuperSpeedHubUsedPorts
default:
return false, fmt.Errorf("unsupported USB speed: %d", speed)
}

totalPortsPerHub, err := GetTotalPortsPerHub(nodeAnnotations)
if err != nil {
return false, err
}

usedPorts, err := GetUsedPorts(nodeAnnotations, hubAnnotation)
if err != nil {
return false, err
}

return (usedPorts + requestedCount) <= totalPortsPerHub, nil
}
Loading
Loading