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
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)
}
129 changes: 129 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,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"

. "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(usedHSPorts string) *corev1.Node {
return &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "node-1", Annotations: map[string]string{
annotations.AnnUSBIPTotalPorts: "2",
annotations.AnnUSBIPHighSpeedHubUsedPorts: usedHSPorts,
annotations.AnnUSBIPSuperSpeedHubUsedPorts: "0",
}}}
}

newVM := func(statuses ...v1alpha2.USBDeviceStatusRef) *v1alpha2.VirtualMachine {
return &v1alpha2.VirtualMachine{
ObjectMeta: metav1.ObjectMeta{Name: "vm-1", Namespace: "default"},
Status: v1alpha2.VirtualMachineStatus{
Node: "node-1",
USBDevices: statuses,
},
}
}

newUSBDevice := func(name string, speed int) *v1alpha2.USBDevice {
return &v1alpha2.USBDevice{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
Status: v1alpha2.USBDeviceStatus{
NodeName: "node-1",
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("1"),
newVM(v1alpha2.USBDeviceStatusRef{Name: "usb-local", Attached: true}),
newUSBDevice("usb-local", 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("1"),
newVM(v1alpha2.USBDeviceStatusRef{Name: "usb-local-ss", Attached: true}),
newUSBDevice("usb-local-ss", 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("1"),
newVM(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("0"),
newVM(
v1alpha2.USBDeviceStatusRef{Name: "usb-local-1", Attached: true},
v1alpha2.USBDeviceStatusRef{Name: "usb-local-2", Attached: true},
),
newUSBDevice("usb-local-1", 480),
newUSBDevice("usb-local-2", 480),
)

hasFree, err := CheckFreePortForRequestOnNodeExcludingLocalUSBs(context.Background(), cl, "node-1", 480, 1)
Expect(err).NotTo(HaveOccurred())
Expect(hasFree).To(BeTrue())
})
})
78 changes: 78 additions & 0 deletions images/virtualization-artifact/pkg/common/usb/speed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
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) {
return CheckFreePortForRequest(nodeAnnotations, speed, 1)
}

// 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) {
totalPortsPerHub, err := GetTotalPortsPerHub(nodeAnnotations)
if err != nil {
return false, err
}

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

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