From e84c3294396fe0f9e7074c6e9e971caf81de9d2f Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Fri, 23 Jan 2026 18:51:10 +0200 Subject: [PATCH 01/53] add controller Signed-off-by: Daniil Antoshin --- api/core/v1alpha2/finalizers.go | 1 + api/core/v1alpha2/node_device_usb.go | 106 ++++++ .../cmd/virtualization-controller/main.go | 7 + .../nodeusbdevice/internal/assigned.go | 91 +++++ .../nodeusbdevice/internal/deletion.go | 82 +++++ .../nodeusbdevice/internal/discovery.go | 335 ++++++++++++++++++ .../nodeusbdevice/internal/ready.go | 180 ++++++++++ .../nodeusbdevice/internal/state/state.go | 53 +++ .../internal/watcher/resourceslice_watcher.go | 76 ++++ .../nodeusbdevice/nodeusbdevice_controller.go | 73 ++++ .../nodeusbdevice/nodeusbdevice_reconciler.go | 117 ++++++ 11 files changed, 1121 insertions(+) create mode 100644 api/core/v1alpha2/node_device_usb.go create mode 100644 images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go create mode 100644 images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go create mode 100644 images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go create mode 100644 images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go create mode 100644 images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go create mode 100644 images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go create mode 100644 images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go create mode 100644 images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go diff --git a/api/core/v1alpha2/finalizers.go b/api/core/v1alpha2/finalizers.go index e2038aff5f..77ad59d383 100644 --- a/api/core/v1alpha2/finalizers.go +++ b/api/core/v1alpha2/finalizers.go @@ -41,4 +41,5 @@ const ( FinalizerVMBDACleanup = "virtualization.deckhouse.io/vmbda-cleanup" FinalizerMACAddressCleanup = "virtualization.deckhouse.io/vmmac-cleanup" FinalizerMACAddressLeaseCleanup = "virtualization.deckhouse.io/vmmacl-cleanup" + FinalizerNodeUSBDeviceCleanup = "virtualization.deckhouse.io/nodeusbdevice-cleanup" ) diff --git a/api/core/v1alpha2/node_device_usb.go b/api/core/v1alpha2/node_device_usb.go new file mode 100644 index 0000000000..6f5c4ce927 --- /dev/null +++ b/api/core/v1alpha2/node_device_usb.go @@ -0,0 +1,106 @@ +/* +Copyright 2024 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 v1alpha2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// NodeUSBDevice represents a USB device discovered on a specific node in the cluster. +// This resource is created automatically by the DRA (Dynamic Resource Allocation) system +// when a USB device is detected on a node. +// +genclient +// +kubebuilder:object:root=true +// +kubebuilder:metadata:labels={heritage=deckhouse,module=virtualization} +// +kubebuilder:resource:categories={virtualization},scope=Namespaced,shortName={nusb},singular=nodeusbdevice +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Node",type=string,JSONPath=`.status.nodeName` +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Assigned",type=string,JSONPath=`.status.conditions[?(@.type=="Assigned")].status` +// +kubebuilder:printcolumn:name="Namespace",type=string,JSONPath=`.spec.assignedNamespace` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type NodeUSBDevice struct { + metav1.TypeMeta `json:",inline"` + + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec NodeUSBDeviceSpec `json:"spec"` + + Status NodeUSBDeviceStatus `json:"status,omitempty"` +} + +// NodeUSBDeviceList provides the needed parameters +// for requesting a list of NodeUSBDevices from the system. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type NodeUSBDeviceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + // Items provides a list of NodeUSBDevices. + Items []NodeUSBDevice `json:"items"` +} + +type NodeUSBDeviceSpec struct { + // Namespace in which the device usage is allowed. By default, created with an empty value "". + // When set, a corresponding USBDevice resource is created in this namespace. + // +kubebuilder:default:="" + AssignedNamespace string `json:"assignedNamespace,omitempty"` +} + +type NodeUSBDeviceStatus struct { + // All device attributes obtained through DRA for the device. + Attributes NodeUSBDeviceAttributes `json:"attributes,omitempty"` + // Name of the node where the USB device is located. + NodeName string `json:"nodeName,omitempty"` + // The latest available observations of an object's current state. + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// NodeUSBDeviceAttributes contains all attributes of a USB device. +type NodeUSBDeviceAttributes struct { + // BCD (Binary Coded Decimal) device version. + BCD string `json:"bcd,omitempty"` + // USB bus number. + Bus string `json:"bus,omitempty"` + // USB device number on the bus. + DeviceNumber string `json:"deviceNumber,omitempty"` + // Device path in the filesystem. + DevicePath string `json:"devicePath,omitempty"` + // Major device number. + Major int `json:"major,omitempty"` + // Minor device number. + Minor int `json:"minor,omitempty"` + // Device name. + Name string `json:"name,omitempty"` + // USB vendor ID in hexadecimal format. + VendorID string `json:"vendorID,omitempty"` + // USB product ID in hexadecimal format. + ProductID string `json:"productID,omitempty"` + // Device serial number. + Serial string `json:"serial,omitempty"` + // Device manufacturer name. + Manufacturer string `json:"manufacturer,omitempty"` + // Device product name. + Product string `json:"product,omitempty"` + // Node name where the device is located. + NodeName string `json:"nodeName,omitempty"` + // Hash calculated based on all main attributes. Required to uniquely match + // the resource with a resource from the slice. + Hash string `json:"hash,omitempty"` +} + diff --git a/images/virtualization-artifact/cmd/virtualization-controller/main.go b/images/virtualization-artifact/cmd/virtualization-controller/main.go index bee96df3e9..06945999cf 100644 --- a/images/virtualization-artifact/cmd/virtualization-controller/main.go +++ b/images/virtualization-artifact/cmd/virtualization-controller/main.go @@ -57,6 +57,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/controller/vmiplease" "github.com/deckhouse/virtualization-controller/pkg/controller/vmmac" "github.com/deckhouse/virtualization-controller/pkg/controller/vmmaclease" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice" "github.com/deckhouse/virtualization-controller/pkg/controller/vmop" "github.com/deckhouse/virtualization-controller/pkg/controller/vmrestore" "github.com/deckhouse/virtualization-controller/pkg/controller/vmsnapshot" @@ -375,6 +376,12 @@ func main() { os.Exit(1) } + nodeusbdeviceLogger := logger.NewControllerLogger(nodeusbdevice.ControllerName, logLevel, logOutput, logDebugVerbosity, logDebugControllerList) + if _, err = nodeusbdevice.NewController(ctx, mgr, nodeusbdeviceLogger); err != nil { + log.Error(err.Error()) + os.Exit(1) + } + vdsnapshotLogger := logger.NewControllerLogger(vdsnapshot.ControllerName, logLevel, logOutput, logDebugVerbosity, logDebugControllerList) if _, err = vdsnapshot.NewController(ctx, mgr, vdsnapshotLogger, virtClient); err != nil { log.Error(err.Error()) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go new file mode 100644 index 0000000000..36a7ed23e2 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go @@ -0,0 +1,91 @@ +/* +Copyright 2025 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 internal + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" +) + +const ( + nameAssignedHandler = "AssignedHandler" +) + +func NewAssignedHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *AssignedHandler { + return &AssignedHandler{ + client: client, + recorder: recorder, + } +} + +type AssignedHandler struct { + client client.Client + recorder eventrecord.EventRecorderLogger +} + +func (h *AssignedHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) (reconcile.Result, error) { + nodeUSBDevice := s.NodeUSBDevice() + + if nodeUSBDevice.IsEmpty() { + return reconcile.Result{}, nil + } + + current := nodeUSBDevice.Current() + changed := nodeUSBDevice.Changed() + + assignedNamespace := changed.Spec.AssignedNamespace + previousNamespace := current.Spec.AssignedNamespace + + // Update Assigned condition + var reason, message string + var status metav1.ConditionStatus + + if assignedNamespace != "" { + // TODO: When USBDevice resource is defined, create/check it here + // For now, just mark as Assigned when namespace is set + reason = "Assigned" + message = fmt.Sprintf("Namespace %s is assigned for the device", assignedNamespace) + status = metav1.ConditionTrue + } else { + reason = "Available" + message = "No namespace is assigned for the device" + status = metav1.ConditionFalse + } + + cb := conditions.NewConditionBuilder("Assigned"). + Generation(changed.Generation). + Status(status). + Reason(reason). + Message(message) + + conditions.SetCondition(cb, &changed.Status.Conditions) + + return reconcile.Result{}, nil +} + +// TODO: Implement USBDevice creation/deletion when USBDevice resource is defined + +func (h *AssignedHandler) Name() string { + return nameAssignedHandler +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go new file mode 100644 index 0000000000..d97e4f2eb2 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go @@ -0,0 +1,82 @@ +/* +Copyright 2025 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 internal + +import ( + "context" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +const ( + nameDeletionHandler = "DeletionHandler" +) + +func NewDeletionHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *DeletionHandler { + return &DeletionHandler{ + client: client, + recorder: recorder, + } +} + +type DeletionHandler struct { + client client.Client + recorder eventrecord.EventRecorderLogger +} + +func (h *DeletionHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) (reconcile.Result, error) { + nodeUSBDevice := s.NodeUSBDevice() + + if nodeUSBDevice.IsEmpty() { + return reconcile.Result{}, nil + } + + current := nodeUSBDevice.Current() + changed := nodeUSBDevice.Changed() + + // Add finalizer if not deleting + if current.GetDeletionTimestamp().IsZero() { + controllerutil.AddFinalizer(changed, v1alpha2.FinalizerNodeUSBDeviceCleanup) + return reconcile.Result{}, nil + } + + // TODO: When USBDevice resource is defined, delete it from namespace here + // Resource is being deleted - clean up USBDevice in namespace + // if current.Spec.AssignedNamespace != "" { + // if err := h.deleteUSBDevice(ctx, current.Spec.AssignedNamespace, current); err != nil { + // return reconcile.Result{}, err + // } + // } + + // Remove finalizer + controllerutil.RemoveFinalizer(changed, v1alpha2.FinalizerNodeUSBDeviceCleanup) + + return reconcile.Result{}, nil +} + +// TODO: Implement USBDevice deletion when USBDevice resource is defined + +func (h *DeletionHandler) Name() string { + return nameDeletionHandler +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go new file mode 100644 index 0000000000..6c471b65b1 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go @@ -0,0 +1,335 @@ +/* +Copyright 2025 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 internal + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strconv" + "strings" + + corev1 "k8s.io/api/core/v1" + resourcev1beta1 "k8s.io/api/resource/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +const ( + nameDiscoveryHandler = "DiscoveryHandler" + draDriverName = "virtualization-dra" +) + +func NewDiscoveryHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *DiscoveryHandler { + return &DiscoveryHandler{ + client: client, + recorder: recorder, + } +} + +type DiscoveryHandler struct { + client client.Client + recorder eventrecord.EventRecorderLogger +} + +func (h *DiscoveryHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) (reconcile.Result, error) { + nodeUSBDevice := s.NodeUSBDevice() + + // Always check for new devices in ResourceSlice and create NodeUSBDevice if needed + // This ensures we discover new devices even if reconcile was triggered for other reasons + if err := h.discoverAndCreate(ctx); err != nil { + // Log error but don't fail reconciliation + // This is a best-effort discovery mechanism + } + + if nodeUSBDevice.IsEmpty() { + // Resource doesn't exist - nothing to update + return reconcile.Result{}, nil + } + + current := nodeUSBDevice.Current() + changed := nodeUSBDevice.Changed() + + // Update attributes from ResourceSlice if needed + resourceSlices, err := h.getResourceSlices(ctx) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to get resource slices: %w", err) + } + + deviceInfo, found := h.findDeviceInSlices(resourceSlices, current.Status.Attributes.Hash, current.Status.NodeName) + if !found { + // Device not found in slices - mark as NotFound + return h.updateReadyCondition(changed, "NotFound", "Device not found in ResourceSlice", metav1.ConditionFalse) + } + + // Update attributes if they changed + if !h.attributesEqual(current.Status.Attributes, deviceInfo) { + changed.Status.Attributes = deviceInfo + } + + return reconcile.Result{}, nil +} + +func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context) (reconcile.Result, error) { + resourceSlices, err := h.getResourceSlices(ctx) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to get resource slices: %w", err) + } + + // Get all existing NodeUSBDevices to avoid duplicates + var existingDevices v1alpha2.NodeUSBDeviceList + if err := h.client.List(ctx, &existingDevices); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to list existing NodeUSBDevices: %w", err) + } + + existingHashes := make(map[string]bool) + for _, device := range existingDevices.Items { + if device.Status.Attributes.Hash != "" { + existingHashes[device.Status.Attributes.Hash] = true + } + } + + // Create NodeUSBDevice for each USB device in ResourceSlices + for _, slice := range resourceSlices { + if slice.Spec.Driver != draDriverName { + continue + } + + for _, device := range slice.Spec.Devices { + if !strings.HasPrefix(device.Name, "usb-") { + continue + } + + attributes := h.convertDeviceToAttributes(device, slice.Spec.Pool.Name) + hash := h.calculateHash(attributes) + + if existingHashes[hash] { + continue + } + + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: h.generateName(hash, attributes.NodeName), + }, + Spec: v1alpha2.NodeUSBDeviceSpec{ + AssignedNamespace: "", + }, + Status: v1alpha2.NodeUSBDeviceStatus{ + Attributes: attributes, + NodeName: attributes.NodeName, + Conditions: []metav1.Condition{ + { + Type: "Ready", + Status: metav1.ConditionTrue, + Reason: "Ready", + Message: "Device is ready to use", + LastTransitionTime: metav1.Now(), + }, + { + Type: "Assigned", + Status: metav1.ConditionFalse, + Reason: "Available", + Message: "No namespace is assigned for the device", + LastTransitionTime: metav1.Now(), + }, + }, + }, + } + + if err := h.client.Create(ctx, nodeUSBDevice); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to create NodeUSBDevice: %w", err) + } + } + } + + return reconcile.Result{}, nil +} + +func (h *DiscoveryHandler) getResourceSlices(ctx context.Context) ([]resourcev1beta1.ResourceSlice, error) { + var slices resourcev1beta1.ResourceSliceList + if err := h.client.List(ctx, &slices, client.MatchingLabels{}); err != nil { + return nil, err + } + + result := make([]resourcev1beta1.ResourceSlice, 0) + for _, slice := range slices.Items { + if slice.Spec.Driver == draDriverName { + result = append(result, slice) + } + } + + return result, nil +} + +func (h *DiscoveryHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice, hash, nodeName string) (v1alpha2.NodeUSBDeviceAttributes, bool) { + for _, slice := range slices { + if slice.Spec.Pool.Name != nodeName { + continue + } + + for _, device := range slice.Spec.Devices { + if !strings.HasPrefix(device.Name, "usb-") { + continue + } + + attributes := h.convertDeviceToAttributes(device, nodeName) + deviceHash := h.calculateHash(attributes) + + if deviceHash == hash { + return attributes, true + } + } + } + + return v1alpha2.NodeUSBDeviceAttributes{}, false +} + +func (h *DiscoveryHandler) convertDeviceToAttributes(device resourcev1beta1.Device, nodeName string) v1alpha2.NodeUSBDeviceAttributes { + attrs := v1alpha2.NodeUSBDeviceAttributes{ + NodeName: nodeName, + Name: device.Name, + } + + if device.Basic == nil { + return attrs + } + + for key, attr := range device.Basic.Attributes { + switch key { + case "name": + if attr.StringValue != nil { + attrs.Name = *attr.StringValue + } + case "manufacturer": + if attr.StringValue != nil { + attrs.Manufacturer = *attr.StringValue + } + case "product": + if attr.StringValue != nil { + attrs.Product = *attr.StringValue + } + case "vendorID": + if attr.StringValue != nil { + attrs.VendorID = *attr.StringValue + } + case "productID": + if attr.StringValue != nil { + attrs.ProductID = *attr.StringValue + } + case "bcd": + if attr.StringValue != nil { + attrs.BCD = *attr.StringValue + } + case "bus": + if attr.StringValue != nil { + attrs.Bus = *attr.StringValue + } + case "deviceNumber": + if attr.StringValue != nil { + attrs.DeviceNumber = *attr.StringValue + } + case "serial": + if attr.StringValue != nil { + attrs.Serial = *attr.StringValue + } + case "devicePath": + if attr.StringValue != nil { + attrs.DevicePath = *attr.StringValue + } + case "major": + if attr.IntValue != nil { + attrs.Major = int(*attr.IntValue) + } + case "minor": + if attr.IntValue != nil { + attrs.Minor = int(*attr.IntValue) + } + } + } + + attrs.Hash = h.calculateHash(attrs) + return attrs +} + +func (h *DiscoveryHandler) calculateHash(attrs v1alpha2.NodeUSBDeviceAttributes) string { + // Calculate hash based on main attributes + hashInput := fmt.Sprintf("%s:%s:%s:%s:%s:%s:%s", + attrs.NodeName, + attrs.VendorID, + attrs.ProductID, + attrs.Bus, + attrs.DeviceNumber, + attrs.Serial, + attrs.DevicePath, + ) + + hash := sha256.Sum256([]byte(hashInput)) + return hex.EncodeToString(hash[:])[:16] // Use first 16 characters +} + +func (h *DiscoveryHandler) generateName(hash, nodeName string) string { + // Generate name based on hash and node name + // Format: nusb-- + nodeNameSanitized := strings.ToLower(strings.ReplaceAll(nodeName, ".", "-")) + return fmt.Sprintf("nusb-%s-%s", hash[:8], nodeNameSanitized) +} + +func (h *DiscoveryHandler) attributesEqual(a, b v1alpha2.NodeUSBDeviceAttributes) bool { + return a.Hash == b.Hash && + a.NodeName == b.NodeName && + a.VendorID == b.VendorID && + a.ProductID == b.ProductID && + a.Bus == b.Bus && + a.DeviceNumber == b.DeviceNumber +} + +func (h *DiscoveryHandler) updateReadyCondition(obj *v1alpha2.NodeUSBDevice, reason, message string, status metav1.ConditionStatus) (reconcile.Result, error) { + now := metav1.Now() + condition := metav1.Condition{ + Type: "Ready", + Status: status, + Reason: reason, + Message: message, + LastTransitionTime: now, + ObservedGeneration: obj.Generation, + } + + // Update or add condition + found := false + for i := range obj.Status.Conditions { + if obj.Status.Conditions[i].Type == "Ready" { + obj.Status.Conditions[i] = condition + found = true + break + } + } + if !found { + obj.Status.Conditions = append(obj.Status.Conditions, condition) + } + + return reconcile.Result{}, nil +} + +func (h *DiscoveryHandler) Name() string { + return nameDiscoveryHandler +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go new file mode 100644 index 0000000000..1a0173db94 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go @@ -0,0 +1,180 @@ +/* +Copyright 2025 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 internal + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + resourcev1beta1 "k8s.io/api/resource/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" +) + +const ( + nameReadyHandler = "ReadyHandler" + draDriverName = "virtualization-dra" +) + +func NewReadyHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *ReadyHandler { + return &ReadyHandler{ + client: client, + recorder: recorder, + } +} + +type ReadyHandler struct { + client client.Client + recorder eventrecord.EventRecorderLogger +} + +func (h *ReadyHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) (reconcile.Result, error) { + nodeUSBDevice := s.NodeUSBDevice() + + if nodeUSBDevice.IsEmpty() { + return reconcile.Result{}, nil + } + + current := nodeUSBDevice.Current() + changed := nodeUSBDevice.Changed() + + // Check if device exists in ResourceSlice + resourceSlices, err := h.getResourceSlices(ctx) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to get resource slices: %w", err) + } + + deviceFound := h.findDeviceInSlices(resourceSlices, current.Status.Attributes.Hash, current.Status.NodeName) + + var reason, message string + var status metav1.ConditionStatus + + if !deviceFound { + // Device not found - mark as NotFound + reason = "NotFound" + message = "Device is absent on the host" + status = metav1.ConditionFalse + } else { + // Device found - check if it's ready + // For now, if device exists in ResourceSlice, we consider it ready + reason = "Ready" + message = "Device is ready to use" + status = metav1.ConditionTrue + } + + cb := conditions.NewConditionBuilder("Ready"). + Generation(changed.Generation). + Status(status). + Reason(reason). + Message(message) + + conditions.SetCondition(cb, &changed.Status.Conditions) + + return reconcile.Result{}, nil +} + +func (h *ReadyHandler) getResourceSlices(ctx context.Context) ([]resourcev1beta1.ResourceSlice, error) { + var slices resourcev1beta1.ResourceSliceList + if err := h.client.List(ctx, &slices, client.MatchingLabels{}); err != nil { + return nil, err + } + + result := make([]resourcev1beta1.ResourceSlice, 0) + for _, slice := range slices.Items { + if slice.Spec.Driver == draDriverName { + result = append(result, slice) + } + } + + return result, nil +} + +func (h *ReadyHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice, hash, nodeName string) bool { + for _, slice := range slices { + if slice.Spec.Pool.Name != nodeName { + continue + } + + for _, device := range slice.Spec.Devices { + if !strings.HasPrefix(device.Name, "usb-") { + continue + } + + // Calculate hash for this device and compare + deviceHash := h.calculateDeviceHash(device, nodeName) + if deviceHash == hash { + return true + } + } + } + + return false +} + +func (h *ReadyHandler) calculateDeviceHash(device resourcev1beta1.Device, nodeName string) string { + // Extract attributes and calculate hash similar to discovery handler + var vendorID, productID, bus, deviceNumber, serial, devicePath string + + if device.Basic != nil { + for key, attr := range device.Basic.Attributes { + switch key { + case "vendorID": + if attr.StringValue != nil { + vendorID = *attr.StringValue + } + case "productID": + if attr.StringValue != nil { + productID = *attr.StringValue + } + case "bus": + if attr.StringValue != nil { + bus = *attr.StringValue + } + case "deviceNumber": + if attr.StringValue != nil { + deviceNumber = *attr.StringValue + } + case "serial": + if attr.StringValue != nil { + serial = *attr.StringValue + } + case "devicePath": + if attr.StringValue != nil { + devicePath = *attr.StringValue + } + } + } + } + + hashInput := fmt.Sprintf("%s:%s:%s:%s:%s:%s:%s", + nodeName, vendorID, productID, bus, deviceNumber, serial, devicePath) + + hash := sha256.Sum256([]byte(hashInput)) + return hex.EncodeToString(hash[:])[:16] +} + +func (h *ReadyHandler) Name() string { + return nameReadyHandler +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go new file mode 100644 index 0000000000..392a915909 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go @@ -0,0 +1,53 @@ +/* +Copyright 2025 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 state + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type NodeUSBDeviceState interface { + NodeUSBDevice() reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus] + ResourceSlices(ctx context.Context) ([]v1alpha2.ResourceSlice, error) +} + +func New(client client.Client, nodeUSBDevice reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus]) NodeUSBDeviceState { + return &nodeUSBDeviceState{ + client: client, + nodeUSBDevice: nodeUSBDevice, + } +} + +type nodeUSBDeviceState struct { + client client.Client + nodeUSBDevice reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus] +} + +func (s *nodeUSBDeviceState) NodeUSBDevice() reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus] { + return s.nodeUSBDevice +} + +func (s *nodeUSBDeviceState) ResourceSlices(ctx context.Context) ([]v1alpha2.ResourceSlice, error) { + // TODO: implement ResourceSlice fetching + // This should fetch ResourceSlice resources that contain USB device information + return nil, nil +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go new file mode 100644 index 0000000000..db59d7d38d --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go @@ -0,0 +1,76 @@ +/* +Copyright 2025 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 watcher + +import ( + "context" + + resourcev1beta1 "k8s.io/api/resource/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func NewResourceSliceWatcher() *ResourceSliceWatcher { + return &ResourceSliceWatcher{} +} + +type ResourceSliceWatcher struct{} + +func (w *ResourceSliceWatcher) Watch(mgr manager.Manager, ctr controller.Controller) error { + return ctr.Watch( + source.Kind(mgr.GetCache(), + &resourcev1beta1.ResourceSlice{}, + handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, slice *resourcev1beta1.ResourceSlice) []reconcile.Request { + // Only watch ResourceSlices from virtualization-dra driver + if slice.Spec.Driver != "virtualization-dra" { + return nil + } + + var result []reconcile.Request + + // Enqueue all existing NodeUSBDevices for reconciliation + deviceList := &v1alpha2.NodeUSBDeviceList{} + if err := mgr.GetClient().List(ctx, deviceList); err != nil { + return nil + } + + for _, device := range deviceList.Items { + // Only enqueue devices from the same node as the ResourceSlice + if device.Status.NodeName == slice.Spec.Pool.Name { + result = append(result, reconcile.Request{ + NamespacedName: object.NamespacedName(&device), + }) + } + } + + // Also trigger discovery to create new NodeUSBDevices + // This is done by enqueueing a special request that will trigger discovery + // For now, we'll rely on periodic reconciliation or manual creation + // TODO: Implement automatic creation of NodeUSBDevice from ResourceSlice + + return result + }), + ), + ) +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go new file mode 100644 index 0000000000..4f1c759c8f --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go @@ -0,0 +1,73 @@ +/* +Copyright 2025 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 nodeusbdevice + +import ( + "context" + "time" + + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/manager" + + "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +const ( + ControllerName = "nodeusbdevice-controller" +) + +func NewController( + ctx context.Context, + mgr manager.Manager, + log *log.Logger, +) (controller.Controller, error) { + recorder := eventrecord.NewEventRecorderLogger(mgr, ControllerName) + client := mgr.GetClient() + + handlers := []Handler{ + internal.NewDeletionHandler(client, recorder), + internal.NewReadyHandler(client, recorder), + internal.NewAssignedHandler(client, recorder), + internal.NewDiscoveryHandler(client, recorder), + } + + r := NewReconciler(client, handlers...) + + c, err := controller.New(ControllerName, mgr, controller.Options{ + Reconciler: r, + RecoverPanic: ptr.To(true), + LogConstructor: logger.NewConstructor(log), + CacheSyncTimeout: 10 * time.Minute, + UsePriorityQueue: ptr.To(true), + }) + if err != nil { + return nil, err + } + + if err = r.SetupController(ctx, mgr, c); err != nil { + return nil, err + } + + log.Info("Initialized NodeUSBDevice controller") + return c, nil +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go new file mode 100644 index 0000000000..e7ce4091ee --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go @@ -0,0 +1,117 @@ +/* +Copyright 2025 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 nodeusbdevice + +import ( + "context" + "fmt" + "reflect" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/watcher" + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type Handler interface { + Handle(ctx context.Context, s state.NodeUSBDeviceState) (reconcile.Result, error) + Name() string +} + +type Watcher interface { + Watch(mgr manager.Manager, ctr controller.Controller) error +} + +func NewReconciler(client client.Client, handlers ...Handler) *Reconciler { + return &Reconciler{ + client: client, + handlers: handlers, + } +} + +type Reconciler struct { + client client.Client + handlers []Handler +} + +func (r *Reconciler) SetupController(_ context.Context, mgr manager.Manager, ctr controller.Controller) error { + if err := ctr.Watch( + source.Kind(mgr.GetCache(), + &v1alpha2.NodeUSBDevice{}, + &handler.TypedEnqueueRequestForObject[*v1alpha2.NodeUSBDevice]{}, + ), + ); err != nil { + return fmt.Errorf("error setting watch on NodeUSBDevice: %w", err) + } + + for _, w := range []Watcher{ + watcher.NewResourceSliceWatcher(), + } { + err := w.Watch(mgr, ctr) + if err != nil { + return fmt.Errorf("failed to run watcher %s: %w", reflect.TypeOf(w).Elem().Name(), err) + } + } + + return nil +} + +func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + log := logger.FromContext(ctx) + + nodeUSBDevice := reconciler.NewResource(req.NamespacedName, r.client, r.factory, r.statusGetter) + + err := nodeUSBDevice.Fetch(ctx) + if err != nil { + return reconcile.Result{}, err + } + + if nodeUSBDevice.IsEmpty() { + log.Info("Reconcile observe an absent NodeUSBDevice: it may be deleted") + return reconcile.Result{}, nil + } + + s := state.New(r.client, nodeUSBDevice) + + rec := reconciler.NewBaseReconciler[Handler](r.handlers) + rec.SetHandlerExecutor(func(ctx context.Context, h Handler) (reconcile.Result, error) { + return h.Handle(ctx, s) + }) + rec.SetResourceUpdater(func(ctx context.Context) error { + nodeUSBDevice.Changed().Status.ObservedGeneration = nodeUSBDevice.Changed().Generation + + return nodeUSBDevice.Update(ctx) + }) + + return rec.Reconcile(ctx) +} + +func (r *Reconciler) factory() *v1alpha2.NodeUSBDevice { + return &v1alpha2.NodeUSBDevice{} +} + +func (r *Reconciler) statusGetter(obj *v1alpha2.NodeUSBDevice) v1alpha2.NodeUSBDeviceStatus { + return obj.Status +} From cd1f658d27606e3a5c9549c6cc51f742e10531e2 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Mon, 26 Jan 2026 11:49:47 +0200 Subject: [PATCH 02/53] update gen Signed-off-by: Daniil Antoshin --- .../typed/core/v1alpha2/core_client.go | 5 + .../core/v1alpha2/fake/fake_core_client.go | 4 + .../core/v1alpha2/fake/fake_nodeusbdevice.go | 52 ++++ .../core/v1alpha2/generated_expansion.go | 2 + .../typed/core/v1alpha2/nodeusbdevice.go | 70 ++++++ .../core/v1alpha2/interface.go | 7 + .../core/v1alpha2/nodeusbdevice.go | 102 ++++++++ .../informers/externalversions/generic.go | 2 + .../core/v1alpha2/expansion_generated.go | 8 + .../listers/core/v1alpha2/nodeusbdevice.go | 70 ++++++ api/core/v1alpha2/node_device_usb.go | 3 +- .../nodeusbdevicecondition/condition.go | 62 +++++ api/core/v1alpha2/zz_generated.deepcopy.go | 117 +++++++++ api/scripts/update-codegen.sh | 3 +- crds/nodeusbdevices.yaml | 225 ++++++++++++++++++ .../nodeusbdevice/internal/assigned.go | 13 +- .../nodeusbdevice/internal/deletion.go | 1 - .../nodeusbdevice/internal/discovery.go | 8 +- .../nodeusbdevice/internal/ready.go | 11 +- .../nodeusbdevice/internal/state/state.go | 13 +- .../internal/watcher/resourceslice_watcher.go | 1 - .../nodeusbdevice/nodeusbdevice_controller.go | 2 - 22 files changed, 753 insertions(+), 28 deletions(-) create mode 100644 api/client/generated/clientset/versioned/typed/core/v1alpha2/fake/fake_nodeusbdevice.go create mode 100644 api/client/generated/clientset/versioned/typed/core/v1alpha2/nodeusbdevice.go create mode 100644 api/client/generated/informers/externalversions/core/v1alpha2/nodeusbdevice.go create mode 100644 api/client/generated/listers/core/v1alpha2/nodeusbdevice.go create mode 100644 api/core/v1alpha2/nodeusbdevicecondition/condition.go create mode 100644 crds/nodeusbdevices.yaml diff --git a/api/client/generated/clientset/versioned/typed/core/v1alpha2/core_client.go b/api/client/generated/clientset/versioned/typed/core/v1alpha2/core_client.go index 5fc598681d..57a105ee39 100644 --- a/api/client/generated/clientset/versioned/typed/core/v1alpha2/core_client.go +++ b/api/client/generated/clientset/versioned/typed/core/v1alpha2/core_client.go @@ -29,6 +29,7 @@ import ( type VirtualizationV1alpha2Interface interface { RESTClient() rest.Interface ClusterVirtualImagesGetter + NodeUSBDevicesGetter VirtualDisksGetter VirtualDiskSnapshotsGetter VirtualImagesGetter @@ -54,6 +55,10 @@ func (c *VirtualizationV1alpha2Client) ClusterVirtualImages() ClusterVirtualImag return newClusterVirtualImages(c) } +func (c *VirtualizationV1alpha2Client) NodeUSBDevices(namespace string) NodeUSBDeviceInterface { + return newNodeUSBDevices(c, namespace) +} + func (c *VirtualizationV1alpha2Client) VirtualDisks(namespace string) VirtualDiskInterface { return newVirtualDisks(c, namespace) } diff --git a/api/client/generated/clientset/versioned/typed/core/v1alpha2/fake/fake_core_client.go b/api/client/generated/clientset/versioned/typed/core/v1alpha2/fake/fake_core_client.go index 816406b63d..88f1f8632c 100644 --- a/api/client/generated/clientset/versioned/typed/core/v1alpha2/fake/fake_core_client.go +++ b/api/client/generated/clientset/versioned/typed/core/v1alpha2/fake/fake_core_client.go @@ -32,6 +32,10 @@ func (c *FakeVirtualizationV1alpha2) ClusterVirtualImages() v1alpha2.ClusterVirt return newFakeClusterVirtualImages(c) } +func (c *FakeVirtualizationV1alpha2) NodeUSBDevices(namespace string) v1alpha2.NodeUSBDeviceInterface { + return newFakeNodeUSBDevices(c, namespace) +} + func (c *FakeVirtualizationV1alpha2) VirtualDisks(namespace string) v1alpha2.VirtualDiskInterface { return newFakeVirtualDisks(c, namespace) } diff --git a/api/client/generated/clientset/versioned/typed/core/v1alpha2/fake/fake_nodeusbdevice.go b/api/client/generated/clientset/versioned/typed/core/v1alpha2/fake/fake_nodeusbdevice.go new file mode 100644 index 0000000000..7061539366 --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/core/v1alpha2/fake/fake_nodeusbdevice.go @@ -0,0 +1,52 @@ +/* +Copyright 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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + corev1alpha2 "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/typed/core/v1alpha2" + v1alpha2 "github.com/deckhouse/virtualization/api/core/v1alpha2" + gentype "k8s.io/client-go/gentype" +) + +// fakeNodeUSBDevices implements NodeUSBDeviceInterface +type fakeNodeUSBDevices struct { + *gentype.FakeClientWithList[*v1alpha2.NodeUSBDevice, *v1alpha2.NodeUSBDeviceList] + Fake *FakeVirtualizationV1alpha2 +} + +func newFakeNodeUSBDevices(fake *FakeVirtualizationV1alpha2, namespace string) corev1alpha2.NodeUSBDeviceInterface { + return &fakeNodeUSBDevices{ + gentype.NewFakeClientWithList[*v1alpha2.NodeUSBDevice, *v1alpha2.NodeUSBDeviceList]( + fake.Fake, + namespace, + v1alpha2.SchemeGroupVersion.WithResource("nodeusbdevices"), + v1alpha2.SchemeGroupVersion.WithKind("NodeUSBDevice"), + func() *v1alpha2.NodeUSBDevice { return &v1alpha2.NodeUSBDevice{} }, + func() *v1alpha2.NodeUSBDeviceList { return &v1alpha2.NodeUSBDeviceList{} }, + func(dst, src *v1alpha2.NodeUSBDeviceList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha2.NodeUSBDeviceList) []*v1alpha2.NodeUSBDevice { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v1alpha2.NodeUSBDeviceList, items []*v1alpha2.NodeUSBDevice) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/api/client/generated/clientset/versioned/typed/core/v1alpha2/generated_expansion.go b/api/client/generated/clientset/versioned/typed/core/v1alpha2/generated_expansion.go index 944819c8d7..120e8698df 100644 --- a/api/client/generated/clientset/versioned/typed/core/v1alpha2/generated_expansion.go +++ b/api/client/generated/clientset/versioned/typed/core/v1alpha2/generated_expansion.go @@ -20,6 +20,8 @@ package v1alpha2 type ClusterVirtualImageExpansion interface{} +type NodeUSBDeviceExpansion interface{} + type VirtualDiskExpansion interface{} type VirtualDiskSnapshotExpansion interface{} diff --git a/api/client/generated/clientset/versioned/typed/core/v1alpha2/nodeusbdevice.go b/api/client/generated/clientset/versioned/typed/core/v1alpha2/nodeusbdevice.go new file mode 100644 index 0000000000..92b65459ee --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/core/v1alpha2/nodeusbdevice.go @@ -0,0 +1,70 @@ +/* +Copyright 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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + context "context" + + scheme "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/scheme" + corev1alpha2 "github.com/deckhouse/virtualization/api/core/v1alpha2" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" +) + +// NodeUSBDevicesGetter has a method to return a NodeUSBDeviceInterface. +// A group's client should implement this interface. +type NodeUSBDevicesGetter interface { + NodeUSBDevices(namespace string) NodeUSBDeviceInterface +} + +// NodeUSBDeviceInterface has methods to work with NodeUSBDevice resources. +type NodeUSBDeviceInterface interface { + Create(ctx context.Context, nodeUSBDevice *corev1alpha2.NodeUSBDevice, opts v1.CreateOptions) (*corev1alpha2.NodeUSBDevice, error) + Update(ctx context.Context, nodeUSBDevice *corev1alpha2.NodeUSBDevice, opts v1.UpdateOptions) (*corev1alpha2.NodeUSBDevice, error) + // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + UpdateStatus(ctx context.Context, nodeUSBDevice *corev1alpha2.NodeUSBDevice, opts v1.UpdateOptions) (*corev1alpha2.NodeUSBDevice, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*corev1alpha2.NodeUSBDevice, error) + List(ctx context.Context, opts v1.ListOptions) (*corev1alpha2.NodeUSBDeviceList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *corev1alpha2.NodeUSBDevice, err error) + NodeUSBDeviceExpansion +} + +// nodeUSBDevices implements NodeUSBDeviceInterface +type nodeUSBDevices struct { + *gentype.ClientWithList[*corev1alpha2.NodeUSBDevice, *corev1alpha2.NodeUSBDeviceList] +} + +// newNodeUSBDevices returns a NodeUSBDevices +func newNodeUSBDevices(c *VirtualizationV1alpha2Client, namespace string) *nodeUSBDevices { + return &nodeUSBDevices{ + gentype.NewClientWithList[*corev1alpha2.NodeUSBDevice, *corev1alpha2.NodeUSBDeviceList]( + "nodeusbdevices", + c.RESTClient(), + scheme.ParameterCodec, + namespace, + func() *corev1alpha2.NodeUSBDevice { return &corev1alpha2.NodeUSBDevice{} }, + func() *corev1alpha2.NodeUSBDeviceList { return &corev1alpha2.NodeUSBDeviceList{} }, + ), + } +} diff --git a/api/client/generated/informers/externalversions/core/v1alpha2/interface.go b/api/client/generated/informers/externalversions/core/v1alpha2/interface.go index c3126a2c07..0da07e89e6 100644 --- a/api/client/generated/informers/externalversions/core/v1alpha2/interface.go +++ b/api/client/generated/informers/externalversions/core/v1alpha2/interface.go @@ -26,6 +26,8 @@ import ( type Interface interface { // ClusterVirtualImages returns a ClusterVirtualImageInformer. ClusterVirtualImages() ClusterVirtualImageInformer + // NodeUSBDevices returns a NodeUSBDeviceInformer. + NodeUSBDevices() NodeUSBDeviceInformer // VirtualDisks returns a VirtualDiskInformer. VirtualDisks() VirtualDiskInformer // VirtualDiskSnapshots returns a VirtualDiskSnapshotInformer. @@ -72,6 +74,11 @@ func (v *version) ClusterVirtualImages() ClusterVirtualImageInformer { return &clusterVirtualImageInformer{factory: v.factory, tweakListOptions: v.tweakListOptions} } +// NodeUSBDevices returns a NodeUSBDeviceInformer. +func (v *version) NodeUSBDevices() NodeUSBDeviceInformer { + return &nodeUSBDeviceInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + // VirtualDisks returns a VirtualDiskInformer. func (v *version) VirtualDisks() VirtualDiskInformer { return &virtualDiskInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} diff --git a/api/client/generated/informers/externalversions/core/v1alpha2/nodeusbdevice.go b/api/client/generated/informers/externalversions/core/v1alpha2/nodeusbdevice.go new file mode 100644 index 0000000000..78cf870b16 --- /dev/null +++ b/api/client/generated/informers/externalversions/core/v1alpha2/nodeusbdevice.go @@ -0,0 +1,102 @@ +/* +Copyright 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. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + context "context" + time "time" + + versioned "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned" + internalinterfaces "github.com/deckhouse/virtualization/api/client/generated/informers/externalversions/internalinterfaces" + corev1alpha2 "github.com/deckhouse/virtualization/api/client/generated/listers/core/v1alpha2" + apicorev1alpha2 "github.com/deckhouse/virtualization/api/core/v1alpha2" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// NodeUSBDeviceInformer provides access to a shared informer and lister for +// NodeUSBDevices. +type NodeUSBDeviceInformer interface { + Informer() cache.SharedIndexInformer + Lister() corev1alpha2.NodeUSBDeviceLister +} + +type nodeUSBDeviceInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewNodeUSBDeviceInformer constructs a new informer for NodeUSBDevice type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewNodeUSBDeviceInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredNodeUSBDeviceInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredNodeUSBDeviceInformer constructs a new informer for NodeUSBDevice type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredNodeUSBDeviceInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.VirtualizationV1alpha2().NodeUSBDevices(namespace).List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.VirtualizationV1alpha2().NodeUSBDevices(namespace).Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.VirtualizationV1alpha2().NodeUSBDevices(namespace).List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.VirtualizationV1alpha2().NodeUSBDevices(namespace).Watch(ctx, options) + }, + }, + &apicorev1alpha2.NodeUSBDevice{}, + resyncPeriod, + indexers, + ) +} + +func (f *nodeUSBDeviceInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredNodeUSBDeviceInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *nodeUSBDeviceInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&apicorev1alpha2.NodeUSBDevice{}, f.defaultInformer) +} + +func (f *nodeUSBDeviceInformer) Lister() corev1alpha2.NodeUSBDeviceLister { + return corev1alpha2.NewNodeUSBDeviceLister(f.Informer().GetIndexer()) +} diff --git a/api/client/generated/informers/externalversions/generic.go b/api/client/generated/informers/externalversions/generic.go index e2b56006b0..7499e273d9 100644 --- a/api/client/generated/informers/externalversions/generic.go +++ b/api/client/generated/informers/externalversions/generic.go @@ -56,6 +56,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource // Group=virtualization.deckhouse.io, Version=v1alpha2 case v1alpha2.SchemeGroupVersion.WithResource("clustervirtualimages"): return &genericInformer{resource: resource.GroupResource(), informer: f.Virtualization().V1alpha2().ClusterVirtualImages().Informer()}, nil + case v1alpha2.SchemeGroupVersion.WithResource("nodeusbdevices"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Virtualization().V1alpha2().NodeUSBDevices().Informer()}, nil case v1alpha2.SchemeGroupVersion.WithResource("virtualdisks"): return &genericInformer{resource: resource.GroupResource(), informer: f.Virtualization().V1alpha2().VirtualDisks().Informer()}, nil case v1alpha2.SchemeGroupVersion.WithResource("virtualdisksnapshots"): diff --git a/api/client/generated/listers/core/v1alpha2/expansion_generated.go b/api/client/generated/listers/core/v1alpha2/expansion_generated.go index c3daaded06..1d26c80143 100644 --- a/api/client/generated/listers/core/v1alpha2/expansion_generated.go +++ b/api/client/generated/listers/core/v1alpha2/expansion_generated.go @@ -22,6 +22,14 @@ package v1alpha2 // ClusterVirtualImageLister. type ClusterVirtualImageListerExpansion interface{} +// NodeUSBDeviceListerExpansion allows custom methods to be added to +// NodeUSBDeviceLister. +type NodeUSBDeviceListerExpansion interface{} + +// NodeUSBDeviceNamespaceListerExpansion allows custom methods to be added to +// NodeUSBDeviceNamespaceLister. +type NodeUSBDeviceNamespaceListerExpansion interface{} + // VirtualDiskListerExpansion allows custom methods to be added to // VirtualDiskLister. type VirtualDiskListerExpansion interface{} diff --git a/api/client/generated/listers/core/v1alpha2/nodeusbdevice.go b/api/client/generated/listers/core/v1alpha2/nodeusbdevice.go new file mode 100644 index 0000000000..7c9ba6e8ca --- /dev/null +++ b/api/client/generated/listers/core/v1alpha2/nodeusbdevice.go @@ -0,0 +1,70 @@ +/* +Copyright 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. +*/ + +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + corev1alpha2 "github.com/deckhouse/virtualization/api/core/v1alpha2" + labels "k8s.io/apimachinery/pkg/labels" + listers "k8s.io/client-go/listers" + cache "k8s.io/client-go/tools/cache" +) + +// NodeUSBDeviceLister helps list NodeUSBDevices. +// All objects returned here must be treated as read-only. +type NodeUSBDeviceLister interface { + // List lists all NodeUSBDevices in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*corev1alpha2.NodeUSBDevice, err error) + // NodeUSBDevices returns an object that can list and get NodeUSBDevices. + NodeUSBDevices(namespace string) NodeUSBDeviceNamespaceLister + NodeUSBDeviceListerExpansion +} + +// nodeUSBDeviceLister implements the NodeUSBDeviceLister interface. +type nodeUSBDeviceLister struct { + listers.ResourceIndexer[*corev1alpha2.NodeUSBDevice] +} + +// NewNodeUSBDeviceLister returns a new NodeUSBDeviceLister. +func NewNodeUSBDeviceLister(indexer cache.Indexer) NodeUSBDeviceLister { + return &nodeUSBDeviceLister{listers.New[*corev1alpha2.NodeUSBDevice](indexer, corev1alpha2.Resource("nodeusbdevice"))} +} + +// NodeUSBDevices returns an object that can list and get NodeUSBDevices. +func (s *nodeUSBDeviceLister) NodeUSBDevices(namespace string) NodeUSBDeviceNamespaceLister { + return nodeUSBDeviceNamespaceLister{listers.NewNamespaced[*corev1alpha2.NodeUSBDevice](s.ResourceIndexer, namespace)} +} + +// NodeUSBDeviceNamespaceLister helps list and get NodeUSBDevices. +// All objects returned here must be treated as read-only. +type NodeUSBDeviceNamespaceLister interface { + // List lists all NodeUSBDevices in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*corev1alpha2.NodeUSBDevice, err error) + // Get retrieves the NodeUSBDevice from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*corev1alpha2.NodeUSBDevice, error) + NodeUSBDeviceNamespaceListerExpansion +} + +// nodeUSBDeviceNamespaceLister implements the NodeUSBDeviceNamespaceLister +// interface. +type nodeUSBDeviceNamespaceLister struct { + listers.ResourceIndexer[*corev1alpha2.NodeUSBDevice] +} diff --git a/api/core/v1alpha2/node_device_usb.go b/api/core/v1alpha2/node_device_usb.go index 6f5c4ce927..4337ae7ef8 100644 --- a/api/core/v1alpha2/node_device_usb.go +++ b/api/core/v1alpha2/node_device_usb.go @@ -69,6 +69,8 @@ type NodeUSBDeviceStatus struct { NodeName string `json:"nodeName,omitempty"` // The latest available observations of an object's current state. Conditions []metav1.Condition `json:"conditions,omitempty"` + // Resource generation last processed by the controller. + ObservedGeneration int64 `json:"observedGeneration,omitempty"` } // NodeUSBDeviceAttributes contains all attributes of a USB device. @@ -103,4 +105,3 @@ type NodeUSBDeviceAttributes struct { // the resource with a resource from the slice. Hash string `json:"hash,omitempty"` } - diff --git a/api/core/v1alpha2/nodeusbdevicecondition/condition.go b/api/core/v1alpha2/nodeusbdevicecondition/condition.go new file mode 100644 index 0000000000..ebe3140557 --- /dev/null +++ b/api/core/v1alpha2/nodeusbdevicecondition/condition.go @@ -0,0 +1,62 @@ +/* +Copyright 2025 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 nodeusbdevicecondition + +// Type represents the various condition types for the `NodeUSBDevice`. +type Type string + +const ( + // AssignedType indicates whether a namespace is assigned for the device. + AssignedType Type = "Assigned" + // ReadyType indicates whether the device is ready to use. + ReadyType Type = "Ready" +) + +func (t Type) String() string { + return string(t) +} + +type ( + // AssignedReason represents the various reasons for the `Assigned` condition type. + AssignedReason string + // ReadyReason represents the various reasons for the `Ready` condition type. + ReadyReason string +) + +const ( + // Assigned signifies that namespace is assigned for the device and corresponding USBDevice resource is created in this namespace. + Assigned AssignedReason = "Assigned" + // Available signifies that no namespace is assigned for the device. + Available AssignedReason = "Available" + // InProgress signifies that device connection to namespace is in progress (USBDevice resource creation). + InProgress AssignedReason = "InProgress" + + // Ready signifies that device is ready to use. + Ready ReadyReason = "Ready" + // NotReady signifies that device exists in the system but is not ready to use. + NotReady ReadyReason = "NotReady" + // NotFound signifies that device is absent on the host. + NotFound ReadyReason = "NotFound" +) + +func (r AssignedReason) String() string { + return string(r) +} + +func (r ReadyReason) String() string { + return string(r) +} diff --git a/api/core/v1alpha2/zz_generated.deepcopy.go b/api/core/v1alpha2/zz_generated.deepcopy.go index e1ad5e58e5..6f77684e9f 100644 --- a/api/core/v1alpha2/zz_generated.deepcopy.go +++ b/api/core/v1alpha2/zz_generated.deepcopy.go @@ -683,6 +683,123 @@ func (in *NodeSelector) DeepCopy() *NodeSelector { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeUSBDevice) DeepCopyInto(out *NodeUSBDevice) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeUSBDevice. +func (in *NodeUSBDevice) DeepCopy() *NodeUSBDevice { + if in == nil { + return nil + } + out := new(NodeUSBDevice) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NodeUSBDevice) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeUSBDeviceAttributes) DeepCopyInto(out *NodeUSBDeviceAttributes) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeUSBDeviceAttributes. +func (in *NodeUSBDeviceAttributes) DeepCopy() *NodeUSBDeviceAttributes { + if in == nil { + return nil + } + out := new(NodeUSBDeviceAttributes) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeUSBDeviceList) DeepCopyInto(out *NodeUSBDeviceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]NodeUSBDevice, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeUSBDeviceList. +func (in *NodeUSBDeviceList) DeepCopy() *NodeUSBDeviceList { + if in == nil { + return nil + } + out := new(NodeUSBDeviceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NodeUSBDeviceList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeUSBDeviceSpec) DeepCopyInto(out *NodeUSBDeviceSpec) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeUSBDeviceSpec. +func (in *NodeUSBDeviceSpec) DeepCopy() *NodeUSBDeviceSpec { + if in == nil { + return nil + } + out := new(NodeUSBDeviceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeUSBDeviceStatus) DeepCopyInto(out *NodeUSBDeviceStatus) { + *out = *in + out.Attributes = in.Attributes + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeUSBDeviceStatus. +func (in *NodeUSBDeviceStatus) DeepCopy() *NodeUSBDeviceStatus { + if in == nil { + return nil + } + out := new(NodeUSBDeviceStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Provisioning) DeepCopyInto(out *Provisioning) { *out = *in diff --git a/api/scripts/update-codegen.sh b/api/scripts/update-codegen.sh index 8214b2f44b..8c50383068 100755 --- a/api/scripts/update-codegen.sh +++ b/api/scripts/update-codegen.sh @@ -40,7 +40,8 @@ function source::settings { "VirtualMachineSnapshotOperation" "VirtualDisk" "VirtualImage" - "ClusterVirtualImage") + "ClusterVirtualImage" + "NodeUSBDevices") source "${CODEGEN_PKG}/kube_codegen.sh" } diff --git a/crds/nodeusbdevices.yaml b/crds/nodeusbdevices.yaml new file mode 100644 index 0000000000..30c7a818a5 --- /dev/null +++ b/crds/nodeusbdevices.yaml @@ -0,0 +1,225 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + labels: + heritage: deckhouse + module: virtualization + name: nodeusbdevices.virtualization.deckhouse.io +spec: + group: virtualization.deckhouse.io + names: + categories: + - virtualization + kind: NodeUSBDevice + listKind: NodeUSBDeviceList + plural: nodeusbdevices + shortNames: + - nusb + singular: nodeusbdevice + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.nodeName + name: NODENAME + type: string + - jsonPath: .status.attributes.manufacturer + name: MANUFACTURER + type: string + - jsonPath: .status.attributes.product + name: PRODUCT + type: string + - jsonPath: .status.attributes.vendorID + name: VENDORID + priority: 1 + type: string + - jsonPath: .status.attributes.productID + name: PRODUCTID + priority: 1 + type: string + - jsonPath: .status.attributes.bus + name: BUS + priority: 1 + type: string + - jsonPath: .status.attributes.deviceNumber + name: DEVICENUMBER + priority: 1 + type: string + - jsonPath: .status.attributes.serial + name: SERIAL + priority: 1 + type: string + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1alpha2 + schema: + openAPIV3Schema: + description: |- + NodeUSBDevice represents a USB device discovered on a specific node in the cluster. + This resource is created automatically by the DRA (Dynamic Resource Allocation) system + when a USB device is detected on a node. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + assignedNamespace: + default: "" + description: |- + Namespace in which the device usage is allowed. By default, created with an empty value "". + When set, a corresponding USBDevice resource is created in this namespace. + type: string + type: object + status: + properties: + attributes: + description: All device attributes obtained through DRA for the device. + properties: + bcd: + description: BCD (Binary Coded Decimal) device version. + type: string + bus: + description: USB bus number. + type: string + deviceNumber: + description: USB device number on the bus. + type: string + devicePath: + description: Device path in the filesystem. + type: string + hash: + description: |- + Hash calculated based on all main attributes. Required to uniquely match + the resource with a resource from the slice. + type: string + major: + description: Major device number. + type: integer + manufacturer: + description: Device manufacturer name. + type: string + minor: + description: Minor device number. + type: integer + name: + description: Device name. + type: string + nodeName: + description: Node name where the device is located. + type: string + product: + description: Device product name. + type: string + productID: + description: USB product ID in hexadecimal format. + type: string + serial: + description: Device serial number. + type: string + vendorID: + description: USB vendor ID in hexadecimal format. + type: string + type: object + conditions: + description: The latest available observations of an object's current state. + items: + description: |- + Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + For Ready condition type, possible values are: + * Ready - device is ready to use + * NotReady - device exists in the system but is not ready to use + * NotFound - device is absent on the host + For Assigned condition type, possible values are: + * Assigned - namespace is assigned for the device and corresponding USBDevice resource is created in this namespace + * Available - no namespace is assigned for the device + * InProgress - device connection to namespace is in progress (USBDevice resource creation) + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: |- + type of condition in CamelCase or in foo.example.com/CamelCase. + Supported condition types: + * Ready - indicates whether the device is ready to use. When reason is "Ready", status is "True". + When reason is "NotReady" or "NotFound", status is "False". When transitioning to NotFound, + the resource remains in the cluster, administrator can delete it manually. Based on lastTransitionTime, + a Garbage Collector can be implemented for automatic cleanup. + * Assigned - indicates whether a namespace is assigned for the device. When reason is "Assigned", + status is "True". When reason is "Available" or "InProgress", status is "False". + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + nodeName: + description: Name of the node where the USB device is located. + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go index 36a7ed23e2..415d744c96 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go @@ -21,11 +21,13 @@ import ( "fmt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" ) const ( @@ -51,29 +53,28 @@ func (h *AssignedHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState return reconcile.Result{}, nil } - current := nodeUSBDevice.Current() changed := nodeUSBDevice.Changed() assignedNamespace := changed.Spec.AssignedNamespace - previousNamespace := current.Spec.AssignedNamespace // Update Assigned condition - var reason, message string + var reason nodeusbdevicecondition.AssignedReason + var message string var status metav1.ConditionStatus if assignedNamespace != "" { // TODO: When USBDevice resource is defined, create/check it here // For now, just mark as Assigned when namespace is set - reason = "Assigned" + reason = nodeusbdevicecondition.Assigned message = fmt.Sprintf("Namespace %s is assigned for the device", assignedNamespace) status = metav1.ConditionTrue } else { - reason = "Available" + reason = nodeusbdevicecondition.Available message = "No namespace is assigned for the device" status = metav1.ConditionFalse } - cb := conditions.NewConditionBuilder("Assigned"). + cb := conditions.NewConditionBuilder(nodeusbdevicecondition.AssignedType). Generation(changed.Generation). Status(status). Reason(reason). diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go index d97e4f2eb2..a15b071add 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go @@ -19,7 +19,6 @@ package internal import ( "context" - k8serrors "k8s.io/apimachinery/pkg/api/errors" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/reconcile" diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go index 6c471b65b1..a966ad6daa 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go @@ -21,10 +21,8 @@ import ( "crypto/sha256" "encoding/hex" "fmt" - "strconv" "strings" - corev1 "k8s.io/api/core/v1" resourcev1beta1 "k8s.io/api/resource/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -37,7 +35,7 @@ import ( const ( nameDiscoveryHandler = "DiscoveryHandler" - draDriverName = "virtualization-dra" + draDriverName = "virtualization-dra" ) func NewDiscoveryHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *DiscoveryHandler { @@ -57,7 +55,7 @@ func (h *DiscoveryHandler) Handle(ctx context.Context, s state.NodeUSBDeviceStat // Always check for new devices in ResourceSlice and create NodeUSBDevice if needed // This ensures we discover new devices even if reconcile was triggered for other reasons - if err := h.discoverAndCreate(ctx); err != nil { + if _, err := h.discoverAndCreate(ctx); err != nil { // Log error but don't fail reconciliation // This is a best-effort discovery mechanism } @@ -207,7 +205,7 @@ func (h *DiscoveryHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceS func (h *DiscoveryHandler) convertDeviceToAttributes(device resourcev1beta1.Device, nodeName string) v1alpha2.NodeUSBDeviceAttributes { attrs := v1alpha2.NodeUSBDeviceAttributes{ NodeName: nodeName, - Name: device.Name, + Name: device.Name, } if device.Basic == nil { diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go index 1a0173db94..f6ba960708 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go @@ -28,6 +28,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" @@ -35,7 +36,6 @@ import ( const ( nameReadyHandler = "ReadyHandler" - draDriverName = "virtualization-dra" ) func NewReadyHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *ReadyHandler { @@ -68,23 +68,24 @@ func (h *ReadyHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) ( deviceFound := h.findDeviceInSlices(resourceSlices, current.Status.Attributes.Hash, current.Status.NodeName) - var reason, message string + var reason nodeusbdevicecondition.ReadyReason + var message string var status metav1.ConditionStatus if !deviceFound { // Device not found - mark as NotFound - reason = "NotFound" + reason = nodeusbdevicecondition.NotFound message = "Device is absent on the host" status = metav1.ConditionFalse } else { // Device found - check if it's ready // For now, if device exists in ResourceSlice, we consider it ready - reason = "Ready" + reason = nodeusbdevicecondition.Ready message = "Device is ready to use" status = metav1.ConditionTrue } - cb := conditions.NewConditionBuilder("Ready"). + cb := conditions.NewConditionBuilder(nodeusbdevicecondition.ReadyType). Generation(changed.Generation). Status(status). Reason(reason). diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go index 392a915909..44677564dd 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go @@ -19,6 +19,7 @@ package state import ( "context" + resourcev1beta1 "k8s.io/api/resource/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" @@ -26,11 +27,11 @@ import ( ) type NodeUSBDeviceState interface { - NodeUSBDevice() reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus] - ResourceSlices(ctx context.Context) ([]v1alpha2.ResourceSlice, error) + NodeUSBDevice() *reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus] + ResourceSlices(ctx context.Context) ([]resourcev1beta1.ResourceSlice, error) } -func New(client client.Client, nodeUSBDevice reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus]) NodeUSBDeviceState { +func New(client client.Client, nodeUSBDevice *reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus]) NodeUSBDeviceState { return &nodeUSBDeviceState{ client: client, nodeUSBDevice: nodeUSBDevice, @@ -39,14 +40,14 @@ func New(client client.Client, nodeUSBDevice reconciler.Resource[*v1alpha2.NodeU type nodeUSBDeviceState struct { client client.Client - nodeUSBDevice reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus] + nodeUSBDevice *reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus] } -func (s *nodeUSBDeviceState) NodeUSBDevice() reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus] { +func (s *nodeUSBDeviceState) NodeUSBDevice() *reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus] { return s.nodeUSBDevice } -func (s *nodeUSBDeviceState) ResourceSlices(ctx context.Context) ([]v1alpha2.ResourceSlice, error) { +func (s *nodeUSBDeviceState) ResourceSlices(ctx context.Context) ([]resourcev1beta1.ResourceSlice, error) { // TODO: implement ResourceSlice fetching // This should fetch ResourceSlice resources that contain USB device information return nil, nil diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go index db59d7d38d..a1a7177148 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go @@ -20,7 +20,6 @@ import ( "context" resourcev1beta1 "k8s.io/api/resource/v1beta1" - "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go index 4f1c759c8f..328ced4392 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go @@ -21,7 +21,6 @@ import ( "time" "k8s.io/utils/ptr" - "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/manager" @@ -29,7 +28,6 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization-controller/pkg/logger" - "github.com/deckhouse/virtualization/api/core/v1alpha2" ) const ( From 72c2543e9fcd76849c618f12a851fe689bba9a47 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Mon, 26 Jan 2026 12:47:17 +0200 Subject: [PATCH 03/53] upd Signed-off-by: Daniil Antoshin --- .../nodeusbdevice/internal/assigned.go | 5 +- .../nodeusbdevice/internal/discovery.go | 48 ++++++++----------- .../nodeusbdevice/internal/ready.go | 3 +- 3 files changed, 25 insertions(+), 31 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go index 415d744c96..a1f7d0a66a 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go @@ -53,9 +53,10 @@ func (h *AssignedHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState return reconcile.Result{}, nil } + current := nodeUSBDevice.Current() changed := nodeUSBDevice.Changed() - assignedNamespace := changed.Spec.AssignedNamespace + assignedNamespace := current.Spec.AssignedNamespace // Update Assigned condition var reason nodeusbdevicecondition.AssignedReason @@ -75,7 +76,7 @@ func (h *AssignedHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState } cb := conditions.NewConditionBuilder(nodeusbdevicecondition.AssignedType). - Generation(changed.Generation). + Generation(current.GetGeneration()). Status(status). Reason(reason). Message(message) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go index a966ad6daa..98487a01f1 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go @@ -28,9 +28,11 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" ) const ( @@ -77,7 +79,13 @@ func (h *DiscoveryHandler) Handle(ctx context.Context, s state.NodeUSBDeviceStat deviceInfo, found := h.findDeviceInSlices(resourceSlices, current.Status.Attributes.Hash, current.Status.NodeName) if !found { // Device not found in slices - mark as NotFound - return h.updateReadyCondition(changed, "NotFound", "Device not found in ResourceSlice", metav1.ConditionFalse) + cb := conditions.NewConditionBuilder(nodeusbdevicecondition.ReadyType). + Generation(current.GetGeneration()). + Status(metav1.ConditionFalse). + Reason(nodeusbdevicecondition.NotFound). + Message("Device not found in ResourceSlice") + conditions.SetCondition(cb, &changed.Status.Conditions) + return reconcile.Result{}, nil } // Update attributes if they changed @@ -137,16 +145,16 @@ func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context) (reconcile.Res NodeName: attributes.NodeName, Conditions: []metav1.Condition{ { - Type: "Ready", + Type: string(nodeusbdevicecondition.ReadyType), Status: metav1.ConditionTrue, - Reason: "Ready", + Reason: string(nodeusbdevicecondition.Ready), Message: "Device is ready to use", LastTransitionTime: metav1.Now(), }, { - Type: "Assigned", + Type: string(nodeusbdevicecondition.AssignedType), Status: metav1.ConditionFalse, - Reason: "Available", + Reason: string(nodeusbdevicecondition.Available), Message: "No namespace is assigned for the device", LastTransitionTime: metav1.Now(), }, @@ -302,29 +310,13 @@ func (h *DiscoveryHandler) attributesEqual(a, b v1alpha2.NodeUSBDeviceAttributes } func (h *DiscoveryHandler) updateReadyCondition(obj *v1alpha2.NodeUSBDevice, reason, message string, status metav1.ConditionStatus) (reconcile.Result, error) { - now := metav1.Now() - condition := metav1.Condition{ - Type: "Ready", - Status: status, - Reason: reason, - Message: message, - LastTransitionTime: now, - ObservedGeneration: obj.Generation, - } - - // Update or add condition - found := false - for i := range obj.Status.Conditions { - if obj.Status.Conditions[i].Type == "Ready" { - obj.Status.Conditions[i] = condition - found = true - break - } - } - if !found { - obj.Status.Conditions = append(obj.Status.Conditions, condition) - } - + // This method is deprecated - use conditions.NewConditionBuilder instead + cb := conditions.NewConditionBuilder(nodeusbdevicecondition.ReadyType). + Generation(obj.GetGeneration()). + Status(status). + Reason(nodeusbdevicecondition.ReadyReason(reason)). + Message(message) + conditions.SetCondition(cb, &obj.Status.Conditions) return reconcile.Result{}, nil } diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go index f6ba960708..5914dd3d48 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go @@ -36,6 +36,7 @@ import ( const ( nameReadyHandler = "ReadyHandler" + draDriverName = "virtualization-dra" ) func NewReadyHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *ReadyHandler { @@ -86,7 +87,7 @@ func (h *ReadyHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) ( } cb := conditions.NewConditionBuilder(nodeusbdevicecondition.ReadyType). - Generation(changed.Generation). + Generation(current.GetGeneration()). Status(status). Reason(reason). Message(message) From e5e3b038e143505f4ee579d3dc9a0055e29bbcc3 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Mon, 26 Jan 2026 13:58:27 +0200 Subject: [PATCH 04/53] check if exist Signed-off-by: Daniil Antoshin --- .../nodeusbdevice/internal/assigned.go | 28 +++++++++++++++---- .../nodeusbdevice/internal/discovery.go | 5 +--- .../internal/watcher/resourceslice_watcher.go | 6 +++- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go index a1f7d0a66a..1cc92b6f7a 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go @@ -20,7 +20,10 @@ import ( "context" "fmt" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -64,11 +67,26 @@ func (h *AssignedHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState var status metav1.ConditionStatus if assignedNamespace != "" { - // TODO: When USBDevice resource is defined, create/check it here - // For now, just mark as Assigned when namespace is set - reason = nodeusbdevicecondition.Assigned - message = fmt.Sprintf("Namespace %s is assigned for the device", assignedNamespace) - status = metav1.ConditionTrue + // Check if namespace exists + var namespace corev1.Namespace + err := h.client.Get(ctx, types.NamespacedName{Name: assignedNamespace}, &namespace) + if err != nil { + if errors.IsNotFound(err) { + // Namespace doesn't exist - mark as Available + reason = nodeusbdevicecondition.Available + message = fmt.Sprintf("Namespace %s does not exist", assignedNamespace) + status = metav1.ConditionFalse + } else { + // Error checking namespace - return error to retry + return reconcile.Result{}, fmt.Errorf("failed to check namespace %s: %w", assignedNamespace, err) + } + } else { + // Namespace exists - mark as Assigned + // TODO: When USBDevice resource is defined, create/check it here + reason = nodeusbdevicecondition.Assigned + message = fmt.Sprintf("Namespace %s is assigned for the device", assignedNamespace) + status = metav1.ConditionTrue + } } else { reason = nodeusbdevicecondition.Available message = "No namespace is assigned for the device" diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go index 98487a01f1..756306a236 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go @@ -116,11 +116,8 @@ func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context) (reconcile.Res } // Create NodeUSBDevice for each USB device in ResourceSlices + // Note: resourceSlices are already filtered by draDriverName in getResourceSlices for _, slice := range resourceSlices { - if slice.Spec.Driver != draDriverName { - continue - } - for _, device := range slice.Spec.Devices { if !strings.HasPrefix(device.Name, "usb-") { continue diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go index a1a7177148..ea7b7a8f0a 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go @@ -30,6 +30,10 @@ import ( "github.com/deckhouse/virtualization/api/core/v1alpha2" ) +const ( + draDriverName = "virtualization-dra" +) + func NewResourceSliceWatcher() *ResourceSliceWatcher { return &ResourceSliceWatcher{} } @@ -42,7 +46,7 @@ func (w *ResourceSliceWatcher) Watch(mgr manager.Manager, ctr controller.Control &resourcev1beta1.ResourceSlice{}, handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, slice *resourcev1beta1.ResourceSlice) []reconcile.Request { // Only watch ResourceSlices from virtualization-dra driver - if slice.Spec.Driver != "virtualization-dra" { + if slice.Spec.Driver != draDriverName { return nil } From 98f9deb8600d95775a8f7ed9425a16d0012df6c3 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Mon, 26 Jan 2026 14:48:22 +0200 Subject: [PATCH 05/53] add USBDevice controller Signed-off-by: Daniil Antoshin --- .../typed/core/v1alpha2/core_client.go | 5 + .../core/v1alpha2/fake/fake_core_client.go | 4 + .../core/v1alpha2/fake/fake_usbdevice.go | 50 +++++ .../core/v1alpha2/generated_expansion.go | 2 + .../typed/core/v1alpha2/usbdevice.go | 70 ++++++ .../core/v1alpha2/interface.go | 7 + .../core/v1alpha2/usbdevice.go | 102 +++++++++ .../informers/externalversions/generic.go | 2 + .../core/v1alpha2/expansion_generated.go | 8 + .../listers/core/v1alpha2/usbdevice.go | 70 ++++++ api/core/v1alpha2/finalizers.go | 1 + api/core/v1alpha2/register.go | 4 + api/core/v1alpha2/usb_device.go | 74 +++++++ .../v1alpha2/usbdevicecondition/condition.go | 60 +++++ api/core/v1alpha2/zz_generated.deepcopy.go | 84 +++++++ api/scripts/update-codegen.sh | 3 +- crds/usbdevices.yaml | 207 ++++++++++++++++++ .../cmd/virtualization-controller/main.go | 7 + .../nodeusbdevice/internal/assigned.go | 118 +++++++++- .../nodeusbdevice/internal/deletion.go | 22 +- .../nodeusbdevice/internal/discovery.go | 21 +- .../nodeusbdevice/internal/ready.go | 1 - .../controller/usbdevice/internal/attached.go | 85 +++++++ .../controller/usbdevice/internal/deletion.go | 94 ++++++++ .../controller/usbdevice/internal/ready.go | 131 +++++++++++ .../usbdevice/internal/state/state.go | 70 ++++++ .../pkg/controller/usbdevice/internal/sync.go | 74 +++++++ .../internal/watcher/nodeusbdevice_watcher.go | 70 ++++++ .../usbdevice/usbdevice_controller.go | 71 ++++++ .../usbdevice/usbdevice_reconciler.go | 117 ++++++++++ 30 files changed, 1600 insertions(+), 34 deletions(-) create mode 100644 api/client/generated/clientset/versioned/typed/core/v1alpha2/fake/fake_usbdevice.go create mode 100644 api/client/generated/clientset/versioned/typed/core/v1alpha2/usbdevice.go create mode 100644 api/client/generated/informers/externalversions/core/v1alpha2/usbdevice.go create mode 100644 api/client/generated/listers/core/v1alpha2/usbdevice.go create mode 100644 api/core/v1alpha2/usb_device.go create mode 100644 api/core/v1alpha2/usbdevicecondition/condition.go create mode 100644 crds/usbdevices.yaml create mode 100644 images/virtualization-artifact/pkg/controller/usbdevice/internal/attached.go create mode 100644 images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go create mode 100644 images/virtualization-artifact/pkg/controller/usbdevice/internal/ready.go create mode 100644 images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go create mode 100644 images/virtualization-artifact/pkg/controller/usbdevice/internal/sync.go create mode 100644 images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/nodeusbdevice_watcher.go create mode 100644 images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go create mode 100644 images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_reconciler.go diff --git a/api/client/generated/clientset/versioned/typed/core/v1alpha2/core_client.go b/api/client/generated/clientset/versioned/typed/core/v1alpha2/core_client.go index 57a105ee39..7c7ca7d81a 100644 --- a/api/client/generated/clientset/versioned/typed/core/v1alpha2/core_client.go +++ b/api/client/generated/clientset/versioned/typed/core/v1alpha2/core_client.go @@ -30,6 +30,7 @@ type VirtualizationV1alpha2Interface interface { RESTClient() rest.Interface ClusterVirtualImagesGetter NodeUSBDevicesGetter + USBDevicesGetter VirtualDisksGetter VirtualDiskSnapshotsGetter VirtualImagesGetter @@ -59,6 +60,10 @@ func (c *VirtualizationV1alpha2Client) NodeUSBDevices(namespace string) NodeUSBD return newNodeUSBDevices(c, namespace) } +func (c *VirtualizationV1alpha2Client) USBDevices(namespace string) USBDeviceInterface { + return newUSBDevices(c, namespace) +} + func (c *VirtualizationV1alpha2Client) VirtualDisks(namespace string) VirtualDiskInterface { return newVirtualDisks(c, namespace) } diff --git a/api/client/generated/clientset/versioned/typed/core/v1alpha2/fake/fake_core_client.go b/api/client/generated/clientset/versioned/typed/core/v1alpha2/fake/fake_core_client.go index 88f1f8632c..c07bdd53a4 100644 --- a/api/client/generated/clientset/versioned/typed/core/v1alpha2/fake/fake_core_client.go +++ b/api/client/generated/clientset/versioned/typed/core/v1alpha2/fake/fake_core_client.go @@ -36,6 +36,10 @@ func (c *FakeVirtualizationV1alpha2) NodeUSBDevices(namespace string) v1alpha2.N return newFakeNodeUSBDevices(c, namespace) } +func (c *FakeVirtualizationV1alpha2) USBDevices(namespace string) v1alpha2.USBDeviceInterface { + return newFakeUSBDevices(c, namespace) +} + func (c *FakeVirtualizationV1alpha2) VirtualDisks(namespace string) v1alpha2.VirtualDiskInterface { return newFakeVirtualDisks(c, namespace) } diff --git a/api/client/generated/clientset/versioned/typed/core/v1alpha2/fake/fake_usbdevice.go b/api/client/generated/clientset/versioned/typed/core/v1alpha2/fake/fake_usbdevice.go new file mode 100644 index 0000000000..299f94d327 --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/core/v1alpha2/fake/fake_usbdevice.go @@ -0,0 +1,50 @@ +/* +Copyright 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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + corev1alpha2 "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/typed/core/v1alpha2" + v1alpha2 "github.com/deckhouse/virtualization/api/core/v1alpha2" + gentype "k8s.io/client-go/gentype" +) + +// fakeUSBDevices implements USBDeviceInterface +type fakeUSBDevices struct { + *gentype.FakeClientWithList[*v1alpha2.USBDevice, *v1alpha2.USBDeviceList] + Fake *FakeVirtualizationV1alpha2 +} + +func newFakeUSBDevices(fake *FakeVirtualizationV1alpha2, namespace string) corev1alpha2.USBDeviceInterface { + return &fakeUSBDevices{ + gentype.NewFakeClientWithList[*v1alpha2.USBDevice, *v1alpha2.USBDeviceList]( + fake.Fake, + namespace, + v1alpha2.SchemeGroupVersion.WithResource("usbdevices"), + v1alpha2.SchemeGroupVersion.WithKind("USBDevice"), + func() *v1alpha2.USBDevice { return &v1alpha2.USBDevice{} }, + func() *v1alpha2.USBDeviceList { return &v1alpha2.USBDeviceList{} }, + func(dst, src *v1alpha2.USBDeviceList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha2.USBDeviceList) []*v1alpha2.USBDevice { return gentype.ToPointerSlice(list.Items) }, + func(list *v1alpha2.USBDeviceList, items []*v1alpha2.USBDevice) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/api/client/generated/clientset/versioned/typed/core/v1alpha2/generated_expansion.go b/api/client/generated/clientset/versioned/typed/core/v1alpha2/generated_expansion.go index 120e8698df..03f1be734a 100644 --- a/api/client/generated/clientset/versioned/typed/core/v1alpha2/generated_expansion.go +++ b/api/client/generated/clientset/versioned/typed/core/v1alpha2/generated_expansion.go @@ -22,6 +22,8 @@ type ClusterVirtualImageExpansion interface{} type NodeUSBDeviceExpansion interface{} +type USBDeviceExpansion interface{} + type VirtualDiskExpansion interface{} type VirtualDiskSnapshotExpansion interface{} diff --git a/api/client/generated/clientset/versioned/typed/core/v1alpha2/usbdevice.go b/api/client/generated/clientset/versioned/typed/core/v1alpha2/usbdevice.go new file mode 100644 index 0000000000..9ab69c4028 --- /dev/null +++ b/api/client/generated/clientset/versioned/typed/core/v1alpha2/usbdevice.go @@ -0,0 +1,70 @@ +/* +Copyright 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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + context "context" + + scheme "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/scheme" + corev1alpha2 "github.com/deckhouse/virtualization/api/core/v1alpha2" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" +) + +// USBDevicesGetter has a method to return a USBDeviceInterface. +// A group's client should implement this interface. +type USBDevicesGetter interface { + USBDevices(namespace string) USBDeviceInterface +} + +// USBDeviceInterface has methods to work with USBDevice resources. +type USBDeviceInterface interface { + Create(ctx context.Context, uSBDevice *corev1alpha2.USBDevice, opts v1.CreateOptions) (*corev1alpha2.USBDevice, error) + Update(ctx context.Context, uSBDevice *corev1alpha2.USBDevice, opts v1.UpdateOptions) (*corev1alpha2.USBDevice, error) + // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + UpdateStatus(ctx context.Context, uSBDevice *corev1alpha2.USBDevice, opts v1.UpdateOptions) (*corev1alpha2.USBDevice, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*corev1alpha2.USBDevice, error) + List(ctx context.Context, opts v1.ListOptions) (*corev1alpha2.USBDeviceList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *corev1alpha2.USBDevice, err error) + USBDeviceExpansion +} + +// uSBDevices implements USBDeviceInterface +type uSBDevices struct { + *gentype.ClientWithList[*corev1alpha2.USBDevice, *corev1alpha2.USBDeviceList] +} + +// newUSBDevices returns a USBDevices +func newUSBDevices(c *VirtualizationV1alpha2Client, namespace string) *uSBDevices { + return &uSBDevices{ + gentype.NewClientWithList[*corev1alpha2.USBDevice, *corev1alpha2.USBDeviceList]( + "usbdevices", + c.RESTClient(), + scheme.ParameterCodec, + namespace, + func() *corev1alpha2.USBDevice { return &corev1alpha2.USBDevice{} }, + func() *corev1alpha2.USBDeviceList { return &corev1alpha2.USBDeviceList{} }, + ), + } +} diff --git a/api/client/generated/informers/externalversions/core/v1alpha2/interface.go b/api/client/generated/informers/externalversions/core/v1alpha2/interface.go index 0da07e89e6..a9cc967102 100644 --- a/api/client/generated/informers/externalversions/core/v1alpha2/interface.go +++ b/api/client/generated/informers/externalversions/core/v1alpha2/interface.go @@ -28,6 +28,8 @@ type Interface interface { ClusterVirtualImages() ClusterVirtualImageInformer // NodeUSBDevices returns a NodeUSBDeviceInformer. NodeUSBDevices() NodeUSBDeviceInformer + // USBDevices returns a USBDeviceInformer. + USBDevices() USBDeviceInformer // VirtualDisks returns a VirtualDiskInformer. VirtualDisks() VirtualDiskInformer // VirtualDiskSnapshots returns a VirtualDiskSnapshotInformer. @@ -79,6 +81,11 @@ func (v *version) NodeUSBDevices() NodeUSBDeviceInformer { return &nodeUSBDeviceInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} } +// USBDevices returns a USBDeviceInformer. +func (v *version) USBDevices() USBDeviceInformer { + return &uSBDeviceInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + // VirtualDisks returns a VirtualDiskInformer. func (v *version) VirtualDisks() VirtualDiskInformer { return &virtualDiskInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} diff --git a/api/client/generated/informers/externalversions/core/v1alpha2/usbdevice.go b/api/client/generated/informers/externalversions/core/v1alpha2/usbdevice.go new file mode 100644 index 0000000000..03e15af41a --- /dev/null +++ b/api/client/generated/informers/externalversions/core/v1alpha2/usbdevice.go @@ -0,0 +1,102 @@ +/* +Copyright 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. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + context "context" + time "time" + + versioned "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned" + internalinterfaces "github.com/deckhouse/virtualization/api/client/generated/informers/externalversions/internalinterfaces" + corev1alpha2 "github.com/deckhouse/virtualization/api/client/generated/listers/core/v1alpha2" + apicorev1alpha2 "github.com/deckhouse/virtualization/api/core/v1alpha2" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// USBDeviceInformer provides access to a shared informer and lister for +// USBDevices. +type USBDeviceInformer interface { + Informer() cache.SharedIndexInformer + Lister() corev1alpha2.USBDeviceLister +} + +type uSBDeviceInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewUSBDeviceInformer constructs a new informer for USBDevice type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewUSBDeviceInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredUSBDeviceInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredUSBDeviceInformer constructs a new informer for USBDevice type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredUSBDeviceInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.VirtualizationV1alpha2().USBDevices(namespace).List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.VirtualizationV1alpha2().USBDevices(namespace).Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.VirtualizationV1alpha2().USBDevices(namespace).List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.VirtualizationV1alpha2().USBDevices(namespace).Watch(ctx, options) + }, + }, + &apicorev1alpha2.USBDevice{}, + resyncPeriod, + indexers, + ) +} + +func (f *uSBDeviceInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredUSBDeviceInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *uSBDeviceInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&apicorev1alpha2.USBDevice{}, f.defaultInformer) +} + +func (f *uSBDeviceInformer) Lister() corev1alpha2.USBDeviceLister { + return corev1alpha2.NewUSBDeviceLister(f.Informer().GetIndexer()) +} diff --git a/api/client/generated/informers/externalversions/generic.go b/api/client/generated/informers/externalversions/generic.go index 7499e273d9..e8663a0736 100644 --- a/api/client/generated/informers/externalversions/generic.go +++ b/api/client/generated/informers/externalversions/generic.go @@ -58,6 +58,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource return &genericInformer{resource: resource.GroupResource(), informer: f.Virtualization().V1alpha2().ClusterVirtualImages().Informer()}, nil case v1alpha2.SchemeGroupVersion.WithResource("nodeusbdevices"): return &genericInformer{resource: resource.GroupResource(), informer: f.Virtualization().V1alpha2().NodeUSBDevices().Informer()}, nil + case v1alpha2.SchemeGroupVersion.WithResource("usbdevices"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Virtualization().V1alpha2().USBDevices().Informer()}, nil case v1alpha2.SchemeGroupVersion.WithResource("virtualdisks"): return &genericInformer{resource: resource.GroupResource(), informer: f.Virtualization().V1alpha2().VirtualDisks().Informer()}, nil case v1alpha2.SchemeGroupVersion.WithResource("virtualdisksnapshots"): diff --git a/api/client/generated/listers/core/v1alpha2/expansion_generated.go b/api/client/generated/listers/core/v1alpha2/expansion_generated.go index 1d26c80143..834035d1f2 100644 --- a/api/client/generated/listers/core/v1alpha2/expansion_generated.go +++ b/api/client/generated/listers/core/v1alpha2/expansion_generated.go @@ -30,6 +30,14 @@ type NodeUSBDeviceListerExpansion interface{} // NodeUSBDeviceNamespaceLister. type NodeUSBDeviceNamespaceListerExpansion interface{} +// USBDeviceListerExpansion allows custom methods to be added to +// USBDeviceLister. +type USBDeviceListerExpansion interface{} + +// USBDeviceNamespaceListerExpansion allows custom methods to be added to +// USBDeviceNamespaceLister. +type USBDeviceNamespaceListerExpansion interface{} + // VirtualDiskListerExpansion allows custom methods to be added to // VirtualDiskLister. type VirtualDiskListerExpansion interface{} diff --git a/api/client/generated/listers/core/v1alpha2/usbdevice.go b/api/client/generated/listers/core/v1alpha2/usbdevice.go new file mode 100644 index 0000000000..89c22c38b3 --- /dev/null +++ b/api/client/generated/listers/core/v1alpha2/usbdevice.go @@ -0,0 +1,70 @@ +/* +Copyright 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. +*/ + +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + corev1alpha2 "github.com/deckhouse/virtualization/api/core/v1alpha2" + labels "k8s.io/apimachinery/pkg/labels" + listers "k8s.io/client-go/listers" + cache "k8s.io/client-go/tools/cache" +) + +// USBDeviceLister helps list USBDevices. +// All objects returned here must be treated as read-only. +type USBDeviceLister interface { + // List lists all USBDevices in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*corev1alpha2.USBDevice, err error) + // USBDevices returns an object that can list and get USBDevices. + USBDevices(namespace string) USBDeviceNamespaceLister + USBDeviceListerExpansion +} + +// uSBDeviceLister implements the USBDeviceLister interface. +type uSBDeviceLister struct { + listers.ResourceIndexer[*corev1alpha2.USBDevice] +} + +// NewUSBDeviceLister returns a new USBDeviceLister. +func NewUSBDeviceLister(indexer cache.Indexer) USBDeviceLister { + return &uSBDeviceLister{listers.New[*corev1alpha2.USBDevice](indexer, corev1alpha2.Resource("usbdevice"))} +} + +// USBDevices returns an object that can list and get USBDevices. +func (s *uSBDeviceLister) USBDevices(namespace string) USBDeviceNamespaceLister { + return uSBDeviceNamespaceLister{listers.NewNamespaced[*corev1alpha2.USBDevice](s.ResourceIndexer, namespace)} +} + +// USBDeviceNamespaceLister helps list and get USBDevices. +// All objects returned here must be treated as read-only. +type USBDeviceNamespaceLister interface { + // List lists all USBDevices in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*corev1alpha2.USBDevice, err error) + // Get retrieves the USBDevice from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*corev1alpha2.USBDevice, error) + USBDeviceNamespaceListerExpansion +} + +// uSBDeviceNamespaceLister implements the USBDeviceNamespaceLister +// interface. +type uSBDeviceNamespaceLister struct { + listers.ResourceIndexer[*corev1alpha2.USBDevice] +} diff --git a/api/core/v1alpha2/finalizers.go b/api/core/v1alpha2/finalizers.go index 77ad59d383..50c932d959 100644 --- a/api/core/v1alpha2/finalizers.go +++ b/api/core/v1alpha2/finalizers.go @@ -42,4 +42,5 @@ const ( FinalizerMACAddressCleanup = "virtualization.deckhouse.io/vmmac-cleanup" FinalizerMACAddressLeaseCleanup = "virtualization.deckhouse.io/vmmacl-cleanup" FinalizerNodeUSBDeviceCleanup = "virtualization.deckhouse.io/nodeusbdevice-cleanup" + FinalizerUSBDeviceCleanup = "virtualization.deckhouse.io/usbdevice-cleanup" ) diff --git a/api/core/v1alpha2/register.go b/api/core/v1alpha2/register.go index 9d113aae35..9ef8a57678 100644 --- a/api/core/v1alpha2/register.go +++ b/api/core/v1alpha2/register.go @@ -92,6 +92,10 @@ func addKnownTypes(scheme *runtime.Scheme) error { &VirtualMachineMACAddressList{}, &VirtualMachineMACAddressLease{}, &VirtualMachineMACAddressLeaseList{}, + &NodeUSBDevice{}, + &NodeUSBDeviceList{}, + &USBDevice{}, + &USBDeviceList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/api/core/v1alpha2/usb_device.go b/api/core/v1alpha2/usb_device.go new file mode 100644 index 0000000000..af8dc7ffd7 --- /dev/null +++ b/api/core/v1alpha2/usb_device.go @@ -0,0 +1,74 @@ +/* +Copyright 2025 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 v1alpha2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + USBDeviceKind = "USBDevice" + USBDeviceResource = "usbdevices" +) + +// USBDevice represents a USB device available for attachment to virtual machines in a given namespace. +// +genclient +// +kubebuilder:object:root=true +// +kubebuilder:metadata:labels={heritage=deckhouse,module=virtualization} +// +kubebuilder:resource:categories={virtualization},scope=Namespaced,shortName={usb},singular=usbdevice +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Node",type=string,JSONPath=`.status.nodeName` +// +kubebuilder:printcolumn:name="VendorID",type=string,JSONPath=`.status.attributes.vendorID`,priority=1 +// +kubebuilder:printcolumn:name="ProductID",type=string,JSONPath=`.status.attributes.productID`,priority=1 +// +kubebuilder:printcolumn:name="Bus",type=string,JSONPath=`.status.attributes.bus`,priority=1 +// +kubebuilder:printcolumn:name="DeviceNumber",type=string,JSONPath=`.status.attributes.deviceNumber`,priority=1 +// +kubebuilder:printcolumn:name="Manufacturer",type=string,JSONPath=`.status.attributes.manufacturer` +// +kubebuilder:printcolumn:name="Product",type=string,JSONPath=`.status.attributes.product` +// +kubebuilder:printcolumn:name="Serial",type=string,JSONPath=`.status.attributes.serial`,priority=1 +// +kubebuilder:printcolumn:name="Attached",type=string,JSONPath=`.status.conditions[?(@.type=="Attached")].status` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type USBDevice struct { + metav1.TypeMeta `json:",inline"` + + metav1.ObjectMeta `json:"metadata,omitempty"` + + Status USBDeviceStatus `json:"status,omitempty"` +} + +// USBDeviceList provides the needed parameters +// for requesting a list of USBDevices from the system. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type USBDeviceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + + // Items provides a list of USBDevices. + Items []USBDevice `json:"items"` +} + +// USBDeviceStatus is the observed state of `USBDevice`. +type USBDeviceStatus struct { + // All device attributes obtained through DRA for the device. + Attributes NodeUSBDeviceAttributes `json:"attributes,omitempty"` + // Name of the node where the USB device is located. + NodeName string `json:"nodeName,omitempty"` + // The latest available observations of an object's current state. + Conditions []metav1.Condition `json:"conditions,omitempty"` + // Resource generation last processed by the controller. + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} diff --git a/api/core/v1alpha2/usbdevicecondition/condition.go b/api/core/v1alpha2/usbdevicecondition/condition.go new file mode 100644 index 0000000000..a973365130 --- /dev/null +++ b/api/core/v1alpha2/usbdevicecondition/condition.go @@ -0,0 +1,60 @@ +/* +Copyright 2025 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 usbdevicecondition + +// Type represents the various condition types for the `USBDevice`. +type Type string + +const ( + // ReadyType indicates whether the device is ready to use. + ReadyType Type = "Ready" + // AttachedType indicates whether the device is attached to a virtual machine. + AttachedType Type = "Attached" +) + +func (t Type) String() string { + return string(t) +} + +type ( + // ReadyReason represents the various reasons for the `Ready` condition type. + ReadyReason string + // AttachedReason represents the various reasons for the `Attached` condition type. + AttachedReason string +) + +const ( + // Ready signifies that device is ready to use. + Ready ReadyReason = "Ready" + // NotReady signifies that device exists in the system but is not ready to use. + NotReady ReadyReason = "NotReady" + // NotFound signifies that device is absent on the host. + NotFound ReadyReason = "NotFound" + + // AttachedToVirtualMachine signifies that device is attached to a virtual machine. + AttachedToVirtualMachine AttachedReason = "AttachedToVirtualMachine" + // Available signifies that device is available for attachment to a virtual machine. + Available AttachedReason = "Available" +) + +func (r ReadyReason) String() string { + return string(r) +} + +func (r AttachedReason) String() string { + return string(r) +} diff --git a/api/core/v1alpha2/zz_generated.deepcopy.go b/api/core/v1alpha2/zz_generated.deepcopy.go index 6f77684e9f..8e3d58fc8e 100644 --- a/api/core/v1alpha2/zz_generated.deepcopy.go +++ b/api/core/v1alpha2/zz_generated.deepcopy.go @@ -1025,6 +1025,90 @@ func (in *Topology) DeepCopy() *Topology { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *USBDevice) DeepCopyInto(out *USBDevice) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new USBDevice. +func (in *USBDevice) DeepCopy() *USBDevice { + if in == nil { + return nil + } + out := new(USBDevice) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *USBDevice) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *USBDeviceList) DeepCopyInto(out *USBDeviceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]USBDevice, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new USBDeviceList. +func (in *USBDeviceList) DeepCopy() *USBDeviceList { + if in == nil { + return nil + } + out := new(USBDeviceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *USBDeviceList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *USBDeviceStatus) DeepCopyInto(out *USBDeviceStatus) { + *out = *in + out.Attributes = in.Attributes + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new USBDeviceStatus. +func (in *USBDeviceStatus) DeepCopy() *USBDeviceStatus { + if in == nil { + return nil + } + out := new(USBDeviceStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UserDataRef) DeepCopyInto(out *UserDataRef) { *out = *in diff --git a/api/scripts/update-codegen.sh b/api/scripts/update-codegen.sh index 8c50383068..53b1ebff75 100755 --- a/api/scripts/update-codegen.sh +++ b/api/scripts/update-codegen.sh @@ -41,7 +41,8 @@ function source::settings { "VirtualDisk" "VirtualImage" "ClusterVirtualImage" - "NodeUSBDevices") + "NodeUSBDevices" + "USBDevice") source "${CODEGEN_PKG}/kube_codegen.sh" } diff --git a/crds/usbdevices.yaml b/crds/usbdevices.yaml new file mode 100644 index 0000000000..4a3b2750f7 --- /dev/null +++ b/crds/usbdevices.yaml @@ -0,0 +1,207 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + labels: + heritage: deckhouse + module: virtualization + name: usbdevices.virtualization.deckhouse.io +spec: + group: virtualization.deckhouse.io + names: + categories: + - virtualization + kind: USBDevice + listKind: USBDeviceList + plural: usbdevices + shortNames: + - usb + singular: usbdevice + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.nodeName + name: Node + type: string + - jsonPath: .status.attributes.vendorID + name: VendorID + priority: 1 + type: string + - jsonPath: .status.attributes.productID + name: ProductID + priority: 1 + type: string + - jsonPath: .status.attributes.bus + name: Bus + priority: 1 + type: string + - jsonPath: .status.attributes.deviceNumber + name: DeviceNumber + priority: 1 + type: string + - jsonPath: .status.attributes.manufacturer + name: Manufacturer + type: string + - jsonPath: .status.attributes.product + name: Product + type: string + - jsonPath: .status.attributes.serial + name: Serial + priority: 1 + type: string + - jsonPath: .status.conditions[?(@.type=="Attached")].status + name: Attached + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha2 + schema: + openAPIV3Schema: + description: + USBDevice represents a USB device available for attachment to + virtual machines in a given namespace. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + status: + description: USBDeviceStatus is the observed state of `USBDevice`. + properties: + attributes: + description: All device attributes obtained through DRA for the device. + properties: + bcd: + description: BCD (Binary Coded Decimal) device version. + type: string + bus: + description: USB bus number. + type: string + deviceNumber: + description: USB device number on the bus. + type: string + devicePath: + description: Device path in the filesystem. + type: string + hash: + description: |- + Hash calculated based on all main attributes. Required to uniquely match + the resource with a resource from the slice. + type: string + major: + description: Major device number. + type: integer + manufacturer: + description: Device manufacturer name. + type: string + minor: + description: Minor device number. + type: integer + name: + description: Device name. + type: string + nodeName: + description: Node name where the device is located. + type: string + product: + description: Device product name. + type: string + productID: + description: USB product ID in hexadecimal format. + type: string + serial: + description: Device serial number. + type: string + vendorID: + description: USB vendor ID in hexadecimal format. + type: string + type: object + conditions: + description: + The latest available observations of an object's current + state. + items: + description: + Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + nodeName: + description: Name of the node where the USB device is located. + type: string + observedGeneration: + description: Resource generation last processed by the controller. + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/images/virtualization-artifact/cmd/virtualization-controller/main.go b/images/virtualization-artifact/cmd/virtualization-controller/main.go index 06945999cf..b86d4734f3 100644 --- a/images/virtualization-artifact/cmd/virtualization-controller/main.go +++ b/images/virtualization-artifact/cmd/virtualization-controller/main.go @@ -58,6 +58,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/controller/vmmac" "github.com/deckhouse/virtualization-controller/pkg/controller/vmmaclease" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice" + "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice" "github.com/deckhouse/virtualization-controller/pkg/controller/vmop" "github.com/deckhouse/virtualization-controller/pkg/controller/vmrestore" "github.com/deckhouse/virtualization-controller/pkg/controller/vmsnapshot" @@ -382,6 +383,12 @@ func main() { os.Exit(1) } + usbdeviceLogger := logger.NewControllerLogger(usbdevice.ControllerName, logLevel, logOutput, logDebugVerbosity, logDebugControllerList) + if _, err = usbdevice.NewController(ctx, mgr, usbdeviceLogger); err != nil { + log.Error(err.Error()) + os.Exit(1) + } + vdsnapshotLogger := logger.NewControllerLogger(vdsnapshot.ControllerName, logLevel, logOutput, logDebugVerbosity, logDebugControllerList) if _, err = vdsnapshot.NewController(ctx, mgr, vdsnapshotLogger, virtClient); err != nil { log.Error(err.Error()) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go index 1cc92b6f7a..902967e362 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go @@ -21,8 +21,8 @@ import ( "fmt" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -30,6 +30,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" ) @@ -49,6 +50,10 @@ type AssignedHandler struct { recorder eventrecord.EventRecorderLogger } +func (h *AssignedHandler) Name() string { + return nameAssignedHandler +} + func (h *AssignedHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) (reconcile.Result, error) { nodeUSBDevice := s.NodeUSBDevice() @@ -61,6 +66,21 @@ func (h *AssignedHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState assignedNamespace := current.Spec.AssignedNamespace + // Check previous assignedNamespace if it changed + // Try to find previous USBDevice to delete it if namespace changed + var usbDeviceList v1alpha2.USBDeviceList + if err := h.client.List(ctx, &usbDeviceList); err == nil { + for _, usbDevice := range usbDeviceList.Items { + if usbDevice.Name == current.Name && usbDevice.Namespace != assignedNamespace { + // Delete USBDevice from previous namespace + if err := h.deleteUSBDevice(ctx, usbDevice.Namespace, usbDevice.Name); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to delete USBDevice from previous namespace: %w", err) + } + break + } + } + } + // Update Assigned condition var reason nodeusbdevicecondition.AssignedReason var message string @@ -81,13 +101,35 @@ func (h *AssignedHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState return reconcile.Result{}, fmt.Errorf("failed to check namespace %s: %w", assignedNamespace, err) } } else { - // Namespace exists - mark as Assigned - // TODO: When USBDevice resource is defined, create/check it here - reason = nodeusbdevicecondition.Assigned - message = fmt.Sprintf("Namespace %s is assigned for the device", assignedNamespace) - status = metav1.ConditionTrue + // Namespace exists - create or update USBDevice + usbDevice, err := h.ensureUSBDevice(ctx, current, assignedNamespace) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to ensure USBDevice: %w", err) + } + + if usbDevice != nil { + reason = nodeusbdevicecondition.Assigned + message = fmt.Sprintf("Namespace %s is assigned for the device, USBDevice created", assignedNamespace) + status = metav1.ConditionTrue + } else { + reason = nodeusbdevicecondition.InProgress + message = fmt.Sprintf("Creating USBDevice in namespace %s", assignedNamespace) + status = metav1.ConditionFalse + } } } else { + // No namespace assigned - delete USBDevice if it exists + var usbDeviceList v1alpha2.USBDeviceList + if err := h.client.List(ctx, &usbDeviceList); err == nil { + for _, usbDevice := range usbDeviceList.Items { + if usbDevice.Name == current.Name { + if err := h.deleteUSBDevice(ctx, usbDevice.Namespace, usbDevice.Name); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to delete USBDevice: %w", err) + } + } + } + } + reason = nodeusbdevicecondition.Available message = "No namespace is assigned for the device" status = metav1.ConditionFalse @@ -104,8 +146,66 @@ func (h *AssignedHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState return reconcile.Result{}, nil } -// TODO: Implement USBDevice creation/deletion when USBDevice resource is defined +func (h *AssignedHandler) ensureUSBDevice(ctx context.Context, nodeUSBDevice *v1alpha2.NodeUSBDevice, namespace string) (*v1alpha2.USBDevice, error) { + usbDevice := &v1alpha2.USBDevice{} + key := types.NamespacedName{ + Namespace: namespace, + Name: nodeUSBDevice.Name, + } -func (h *AssignedHandler) Name() string { - return nameAssignedHandler + err := h.client.Get(ctx, key, usbDevice) + if err == nil { + // USBDevice exists - update it + usbDevice.Status.Attributes = nodeUSBDevice.Status.Attributes + usbDevice.Status.NodeName = nodeUSBDevice.Status.NodeName + if err := h.client.Status().Update(ctx, usbDevice); err != nil { + return nil, fmt.Errorf("failed to update USBDevice status: %w", err) + } + return usbDevice, nil + } + + if !errors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get USBDevice: %w", err) + } + + // USBDevice doesn't exist - create it + usbDevice = &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: nodeUSBDevice.Name, + Namespace: namespace, + }, + Status: v1alpha2.USBDeviceStatus{ + Attributes: nodeUSBDevice.Status.Attributes, + NodeName: nodeUSBDevice.Status.NodeName, + }, + } + + if err := h.client.Create(ctx, usbDevice); err != nil { + return nil, fmt.Errorf("failed to create USBDevice: %w", err) + } + + return usbDevice, nil +} + +func (h *AssignedHandler) deleteUSBDevice(ctx context.Context, namespace, name string) error { + usbDevice := &v1alpha2.USBDevice{} + key := types.NamespacedName{ + Namespace: namespace, + Name: name, + } + + err := h.client.Get(ctx, key, usbDevice) + if err != nil { + if errors.IsNotFound(err) { + // USBDevice doesn't exist - nothing to delete + return nil + } + return fmt.Errorf("failed to get USBDevice: %w", err) + } + + if err := h.client.Delete(ctx, usbDevice); err != nil { + return fmt.Errorf("failed to delete USBDevice: %w", err) + } + + return nil } diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go index a15b071add..842620ce6d 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go @@ -18,6 +18,7 @@ package internal import ( "context" + "fmt" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -60,13 +61,20 @@ func (h *DeletionHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState return reconcile.Result{}, nil } - // TODO: When USBDevice resource is defined, delete it from namespace here // Resource is being deleted - clean up USBDevice in namespace - // if current.Spec.AssignedNamespace != "" { - // if err := h.deleteUSBDevice(ctx, current.Spec.AssignedNamespace, current); err != nil { - // return reconcile.Result{}, err - // } - // } + if current.Spec.AssignedNamespace != "" { + usbDevice := &v1alpha2.USBDevice{} + key := client.ObjectKey{ + Namespace: current.Spec.AssignedNamespace, + Name: current.Name, + } + if err := h.client.Get(ctx, key, usbDevice); err == nil { + // USBDevice exists - delete it + if err := h.client.Delete(ctx, usbDevice); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to delete USBDevice: %w", err) + } + } + } // Remove finalizer controllerutil.RemoveFinalizer(changed, v1alpha2.FinalizerNodeUSBDeviceCleanup) @@ -74,8 +82,6 @@ func (h *DeletionHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState return reconcile.Result{}, nil } -// TODO: Implement USBDevice deletion when USBDevice resource is defined - func (h *DeletionHandler) Name() string { return nameDeletionHandler } diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go index 756306a236..7387a79b0c 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go @@ -28,6 +28,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/deckhouse/deckhouse/pkg/log" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" @@ -52,6 +53,10 @@ type DiscoveryHandler struct { recorder eventrecord.EventRecorderLogger } +func (h *DiscoveryHandler) Name() string { + return nameDiscoveryHandler +} + func (h *DiscoveryHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) (reconcile.Result, error) { nodeUSBDevice := s.NodeUSBDevice() @@ -60,6 +65,7 @@ func (h *DiscoveryHandler) Handle(ctx context.Context, s state.NodeUSBDeviceStat if _, err := h.discoverAndCreate(ctx); err != nil { // Log error but don't fail reconciliation // This is a best-effort discovery mechanism + log.Error("failed to discover and create NodeUSBDevice", log.Err(err)) } if nodeUSBDevice.IsEmpty() { @@ -305,18 +311,3 @@ func (h *DiscoveryHandler) attributesEqual(a, b v1alpha2.NodeUSBDeviceAttributes a.Bus == b.Bus && a.DeviceNumber == b.DeviceNumber } - -func (h *DiscoveryHandler) updateReadyCondition(obj *v1alpha2.NodeUSBDevice, reason, message string, status metav1.ConditionStatus) (reconcile.Result, error) { - // This method is deprecated - use conditions.NewConditionBuilder instead - cb := conditions.NewConditionBuilder(nodeusbdevicecondition.ReadyType). - Generation(obj.GetGeneration()). - Status(status). - Reason(nodeusbdevicecondition.ReadyReason(reason)). - Message(message) - conditions.SetCondition(cb, &obj.Status.Conditions) - return reconcile.Result{}, nil -} - -func (h *DiscoveryHandler) Name() string { - return nameDiscoveryHandler -} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go index 5914dd3d48..7daee4d338 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go @@ -36,7 +36,6 @@ import ( const ( nameReadyHandler = "ReadyHandler" - draDriverName = "virtualization-dra" ) func NewReadyHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *ReadyHandler { diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/attached.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/attached.go new file mode 100644 index 0000000000..5e769919d7 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/attached.go @@ -0,0 +1,85 @@ +/* +Copyright 2025 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 internal + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2/usbdevicecondition" +) + +const ( + nameAttachedHandler = "AttachedHandler" +) + +func NewAttachedHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *AttachedHandler { + return &AttachedHandler{ + client: client, + recorder: recorder, + } +} + +type AttachedHandler struct { + client client.Client + recorder eventrecord.EventRecorderLogger +} + +func (h *AttachedHandler) Handle(ctx context.Context, s state.USBDeviceState) (reconcile.Result, error) { + usbDevice := s.USBDevice() + + if usbDevice.IsEmpty() { + return reconcile.Result{}, nil + } + + current := usbDevice.Current() + changed := usbDevice.Changed() + + // TODO: Check if device is attached to a VM + // For now, we'll mark it as Available + // This should be implemented by checking VirtualMachine resources that reference this USBDevice + + var reason usbdevicecondition.AttachedReason + var status metav1.ConditionStatus + var message string + + // TODO: Implement actual attachment check + // For now, default to Available + reason = usbdevicecondition.Available + status = metav1.ConditionFalse + message = "Device is available for attachment to a virtual machine" + + cb := conditions.NewConditionBuilder(usbdevicecondition.AttachedType). + Generation(current.GetGeneration()). + Status(status). + Reason(reason). + Message(message) + + conditions.SetCondition(cb, &changed.Status.Conditions) + + return reconcile.Result{}, nil +} + +func (h *AttachedHandler) Name() string { + return nameAttachedHandler +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go new file mode 100644 index 0000000000..0acb98cbd7 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go @@ -0,0 +1,94 @@ +/* +Copyright 2025 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 internal + +import ( + "context" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/usbdevicecondition" +) + +const ( + nameDeletionHandler = "DeletionHandler" +) + +func NewDeletionHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *DeletionHandler { + return &DeletionHandler{ + client: client, + recorder: recorder, + } +} + +type DeletionHandler struct { + client client.Client + recorder eventrecord.EventRecorderLogger +} + +func (h *DeletionHandler) Handle(ctx context.Context, s state.USBDeviceState) (reconcile.Result, error) { + usbDevice := s.USBDevice() + + if usbDevice.IsEmpty() { + return reconcile.Result{}, nil + } + + current := usbDevice.Current() + changed := usbDevice.Changed() + + // Add finalizer if not deleting + if current.GetDeletionTimestamp().IsZero() { + controllerutil.AddFinalizer(changed, v1alpha2.FinalizerUSBDeviceCleanup) + return reconcile.Result{}, nil + } + + // Check if device is attached to a VM + // TODO: Implement hot unplug before deletion + // For now, we just check the Attached condition + attached := false + for _, condition := range current.Status.Conditions { + if condition.Type == string(usbdevicecondition.AttachedType) { + if condition.Status == "True" && condition.Reason == string(usbdevicecondition.AttachedToVirtualMachine) { + attached = true + break + } + } + } + + if attached { + // TODO: Implement hot unplug logic here + // For now, we'll just log and continue + h.recorder.Eventf(changed, "Normal", "Deletion", "Device is attached to VM, hot unplug will be performed") + // Return to retry after hot unplug + return reconcile.Result{Requeue: true}, fmt.Errorf("device is attached to VM, hot unplug required") + } + + // Remove finalizer + controllerutil.RemoveFinalizer(changed, v1alpha2.FinalizerUSBDeviceCleanup) + + return reconcile.Result{}, nil +} + +func (h *DeletionHandler) Name() string { + return nameDeletionHandler +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready.go new file mode 100644 index 0000000000..293b850ad6 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready.go @@ -0,0 +1,131 @@ +/* +Copyright 2025 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 internal + +import ( + "context" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" + "github.com/deckhouse/virtualization/api/core/v1alpha2/usbdevicecondition" +) + +const ( + nameReadyHandler = "ReadyHandler" +) + +func NewReadyHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *ReadyHandler { + return &ReadyHandler{ + client: client, + recorder: recorder, + } +} + +type ReadyHandler struct { + client client.Client + recorder eventrecord.EventRecorderLogger +} + +func (h *ReadyHandler) Handle(ctx context.Context, s state.USBDeviceState) (reconcile.Result, error) { + usbDevice := s.USBDevice() + + if usbDevice.IsEmpty() { + return reconcile.Result{}, nil + } + + current := usbDevice.Current() + changed := usbDevice.Changed() + + // Get corresponding NodeUSBDevice + nodeUSBDevice, err := s.NodeUSBDevice(ctx) + if err != nil { + return reconcile.Result{}, err + } + + if nodeUSBDevice == nil { + // NodeUSBDevice not found - mark as NotFound + cb := conditions.NewConditionBuilder(usbdevicecondition.ReadyType). + Generation(current.GetGeneration()). + Status(metav1.ConditionFalse). + Reason(usbdevicecondition.NotFound). + Message("Corresponding NodeUSBDevice not found") + + conditions.SetCondition(cb, &changed.Status.Conditions) + return reconcile.Result{}, nil + } + + // Find Ready condition in NodeUSBDevice + var readyCondition *metav1.Condition + for i := range nodeUSBDevice.Status.Conditions { + if nodeUSBDevice.Status.Conditions[i].Type == string(nodeusbdevicecondition.ReadyType) { + readyCondition = &nodeUSBDevice.Status.Conditions[i] + break + } + } + + if readyCondition == nil { + // No Ready condition in NodeUSBDevice - mark as NotReady + cb := conditions.NewConditionBuilder(usbdevicecondition.ReadyType). + Generation(current.GetGeneration()). + Status(metav1.ConditionFalse). + Reason(usbdevicecondition.NotReady). + Message("Ready condition not found in NodeUSBDevice") + + conditions.SetCondition(cb, &changed.Status.Conditions) + return reconcile.Result{}, nil + } + + // Translate Ready condition from NodeUSBDevice + var reason usbdevicecondition.ReadyReason + var status metav1.ConditionStatus + + switch readyCondition.Reason { + case string(nodeusbdevicecondition.Ready): + reason = usbdevicecondition.Ready + status = metav1.ConditionTrue + case string(nodeusbdevicecondition.NotReady): + reason = usbdevicecondition.NotReady + status = metav1.ConditionFalse + case string(nodeusbdevicecondition.NotFound): + reason = usbdevicecondition.NotFound + status = metav1.ConditionFalse + default: + reason = usbdevicecondition.NotReady + status = metav1.ConditionFalse + } + + cb := conditions.NewConditionBuilder(usbdevicecondition.ReadyType). + Generation(current.GetGeneration()). + Status(status). + Reason(reason). + Message(readyCondition.Message). + LastTransitionTime(readyCondition.LastTransitionTime.Time) + + conditions.SetCondition(cb, &changed.Status.Conditions) + + return reconcile.Result{}, nil +} + +func (h *ReadyHandler) Name() string { + return nameReadyHandler +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go new file mode 100644 index 0000000000..6b452eb19b --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go @@ -0,0 +1,70 @@ +/* +Copyright 2025 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 state + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type USBDeviceState interface { + USBDevice() *reconciler.Resource[*v1alpha2.USBDevice, v1alpha2.USBDeviceStatus] + NodeUSBDevice(ctx context.Context) (*v1alpha2.NodeUSBDevice, error) +} + +func New(client client.Client, usbDevice *reconciler.Resource[*v1alpha2.USBDevice, v1alpha2.USBDeviceStatus]) USBDeviceState { + return &usbDeviceState{ + client: client, + usbDevice: usbDevice, + } +} + +type usbDeviceState struct { + client client.Client + usbDevice *reconciler.Resource[*v1alpha2.USBDevice, v1alpha2.USBDeviceStatus] +} + +func (s *usbDeviceState) USBDevice() *reconciler.Resource[*v1alpha2.USBDevice, v1alpha2.USBDeviceStatus] { + return s.usbDevice +} + +func (s *usbDeviceState) NodeUSBDevice(ctx context.Context) (*v1alpha2.NodeUSBDevice, error) { + // USBDevice has the same name as the corresponding NodeUSBDevice + // We need to find the NodeUSBDevice by name across all namespaces + usbDevice := s.usbDevice.Current() + if usbDevice == nil { + return nil, nil + } + + var nodeUSBDeviceList v1alpha2.NodeUSBDeviceList + if err := s.client.List(ctx, &nodeUSBDeviceList); err != nil { + return nil, err + } + + // Find the NodeUSBDevice that matches by name + for i := range nodeUSBDeviceList.Items { + if nodeUSBDeviceList.Items[i].Name == usbDevice.Name { + return &nodeUSBDeviceList.Items[i], nil + } + } + + return nil, nil +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync.go new file mode 100644 index 0000000000..ba4fd90ed8 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync.go @@ -0,0 +1,74 @@ +/* +Copyright 2025 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 internal + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" +) + +const ( + nameSyncHandler = "SyncHandler" +) + +func NewSyncHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *SyncHandler { + return &SyncHandler{ + client: client, + recorder: recorder, + } +} + +type SyncHandler struct { + client client.Client + recorder eventrecord.EventRecorderLogger +} + +func (h *SyncHandler) Handle(ctx context.Context, s state.USBDeviceState) (reconcile.Result, error) { + usbDevice := s.USBDevice() + + if usbDevice.IsEmpty() { + return reconcile.Result{}, nil + } + + changed := usbDevice.Changed() + + // Get corresponding NodeUSBDevice + nodeUSBDevice, err := s.NodeUSBDevice(ctx) + if err != nil { + return reconcile.Result{}, err + } + + if nodeUSBDevice == nil { + // NodeUSBDevice not found - nothing to sync + return reconcile.Result{}, nil + } + + // Sync attributes from NodeUSBDevice + changed.Status.Attributes = nodeUSBDevice.Status.Attributes + changed.Status.NodeName = nodeUSBDevice.Status.NodeName + + return reconcile.Result{}, nil +} + +func (h *SyncHandler) Name() string { + return nameSyncHandler +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/nodeusbdevice_watcher.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/nodeusbdevice_watcher.go new file mode 100644 index 0000000000..38d24971f0 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/nodeusbdevice_watcher.go @@ -0,0 +1,70 @@ +/* +Copyright 2025 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 watcher + +import ( + "context" + + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func NewNodeUSBDeviceWatcher() *NodeUSBDeviceWatcher { + return &NodeUSBDeviceWatcher{} +} + +type NodeUSBDeviceWatcher struct{} + +func (w *NodeUSBDeviceWatcher) Watch(mgr manager.Manager, ctr controller.Controller) error { + return ctr.Watch( + source.Kind(mgr.GetCache(), + &v1alpha2.NodeUSBDevice{}, + handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, nodeUSBDevice *v1alpha2.NodeUSBDevice) []reconcile.Request { + var result []reconcile.Request + + // Only enqueue USBDevice if NodeUSBDevice has assignedNamespace + if nodeUSBDevice.Spec.AssignedNamespace == "" { + return nil + } + + // USBDevice has the same name as NodeUSBDevice and is in the assignedNamespace + usbDevice := &v1alpha2.USBDevice{} + key := types.NamespacedName{ + Namespace: nodeUSBDevice.Spec.AssignedNamespace, + Name: nodeUSBDevice.Name, + } + if err := mgr.GetClient().Get(ctx, key, usbDevice); err != nil { + // USBDevice doesn't exist yet - it will be created by the assigned handler + return nil + } + + result = append(result, reconcile.Request{ + NamespacedName: object.NamespacedName(usbDevice), + }) + + return result + }), + ), + ) +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go new file mode 100644 index 0000000000..c4b5fa61ef --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go @@ -0,0 +1,71 @@ +/* +Copyright 2025 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 usbdevice + +import ( + "context" + "time" + + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/manager" + + "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization-controller/pkg/logger" +) + +const ( + ControllerName = "usbdevice-controller" +) + +func NewController( + ctx context.Context, + mgr manager.Manager, + log *log.Logger, +) (controller.Controller, error) { + recorder := eventrecord.NewEventRecorderLogger(mgr, ControllerName) + client := mgr.GetClient() + + handlers := []Handler{ + internal.NewDeletionHandler(client, recorder), + internal.NewReadyHandler(client, recorder), + internal.NewAttachedHandler(client, recorder), + internal.NewSyncHandler(client, recorder), + } + + r := NewReconciler(client, handlers...) + + c, err := controller.New(ControllerName, mgr, controller.Options{ + Reconciler: r, + RecoverPanic: ptr.To(true), + LogConstructor: logger.NewConstructor(log), + CacheSyncTimeout: 10 * time.Minute, + UsePriorityQueue: ptr.To(true), + }) + if err != nil { + return nil, err + } + + if err = r.SetupController(ctx, mgr, c); err != nil { + return nil, err + } + + log.Info("Initialized USBDevice controller") + return c, nil +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_reconciler.go b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_reconciler.go new file mode 100644 index 0000000000..731f43cbfe --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_reconciler.go @@ -0,0 +1,117 @@ +/* +Copyright 2025 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 usbdevice + +import ( + "context" + "fmt" + "reflect" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/watcher" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type Handler interface { + Handle(ctx context.Context, s state.USBDeviceState) (reconcile.Result, error) + Name() string +} + +type Watcher interface { + Watch(mgr manager.Manager, ctr controller.Controller) error +} + +func NewReconciler(client client.Client, handlers ...Handler) *Reconciler { + return &Reconciler{ + client: client, + handlers: handlers, + } +} + +type Reconciler struct { + client client.Client + handlers []Handler +} + +func (r *Reconciler) SetupController(_ context.Context, mgr manager.Manager, ctr controller.Controller) error { + if err := ctr.Watch( + source.Kind(mgr.GetCache(), + &v1alpha2.USBDevice{}, + &handler.TypedEnqueueRequestForObject[*v1alpha2.USBDevice]{}, + ), + ); err != nil { + return fmt.Errorf("error setting watch on USBDevice: %w", err) + } + + for _, w := range []Watcher{ + watcher.NewNodeUSBDeviceWatcher(), + } { + err := w.Watch(mgr, ctr) + if err != nil { + return fmt.Errorf("failed to run watcher %s: %w", reflect.TypeOf(w).Elem().Name(), err) + } + } + + return nil +} + +func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + log := logger.FromContext(ctx) + + usbDevice := reconciler.NewResource(req.NamespacedName, r.client, r.factory, r.statusGetter) + + err := usbDevice.Fetch(ctx) + if err != nil { + return reconcile.Result{}, err + } + + if usbDevice.IsEmpty() { + log.Info("Reconcile observe an absent USBDevice: it may be deleted") + return reconcile.Result{}, nil + } + + s := state.New(r.client, usbDevice) + + rec := reconciler.NewBaseReconciler(r.handlers) + rec.SetHandlerExecutor(func(ctx context.Context, h Handler) (reconcile.Result, error) { + return h.Handle(ctx, s) + }) + rec.SetResourceUpdater(func(ctx context.Context) error { + usbDevice.Changed().Status.ObservedGeneration = usbDevice.Changed().Generation + + return usbDevice.Update(ctx) + }) + + return rec.Reconcile(ctx) +} + +func (r *Reconciler) factory() *v1alpha2.USBDevice { + return &v1alpha2.USBDevice{} +} + +func (r *Reconciler) statusGetter(obj *v1alpha2.USBDevice) v1alpha2.USBDeviceStatus { + return obj.Status +} From daaaa65fa9e55c2b7b7b6acd0ae7ba78377753ce Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Mon, 26 Jan 2026 15:06:42 +0200 Subject: [PATCH 06/53] add vm usb plug/unplug Signed-off-by: Daniil Antoshin --- api/core/v1alpha2/virtual_machine.go | 31 ++ api/core/v1alpha2/zz_generated.deepcopy.go | 65 ++++ .../pkg/controller/indexer/indexer.go | 23 ++ .../pkg/controller/vm/internal/state/state.go | 24 ++ .../vm/internal/usb_device_handler.go | 312 ++++++++++++++++++ .../vm/internal/watcher/usbdevice_watcher.go | 77 +++++ .../pkg/controller/vm/vm_controller.go | 7 + .../pkg/controller/vm/vm_reconciler.go | 1 + 8 files changed, 540 insertions(+) create mode 100644 images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go create mode 100644 images/virtualization-artifact/pkg/controller/vm/internal/watcher/usbdevice_watcher.go diff --git a/api/core/v1alpha2/virtual_machine.go b/api/core/v1alpha2/virtual_machine.go index b7895501f5..fe6d22ccda 100644 --- a/api/core/v1alpha2/virtual_machine.go +++ b/api/core/v1alpha2/virtual_machine.go @@ -114,6 +114,9 @@ type VirtualMachineSpec struct { // Live migration policy type. LiveMigrationPolicy LiveMigrationPolicy `json:"liveMigrationPolicy"` Networks []NetworksSpec `json:"networks,omitempty"` + // List of USB devices to attach to the virtual machine. + // Devices are referenced by name of USBDevice resource in the same namespace. + USBDevices []USBDeviceSpecRef `json:"usbDevices,omitempty"` } // RunPolicy parameter defines the VM startup policy @@ -315,6 +318,8 @@ type VirtualMachineStatus struct { Versions Versions `json:"versions,omitempty"` Resources ResourcesStatus `json:"resources,omitempty"` Networks []NetworksStatus `json:"networks,omitempty"` + // List of USB devices attached to the virtual machine. + USBDevices []USBDeviceStatusRef `json:"usbDevices,omitempty"` } type VirtualMachineStats struct { @@ -479,3 +484,29 @@ const ( SecretTypeCloudInit corev1.SecretType = "provisioning.virtualization.deckhouse.io/cloud-init" SecretTypeSysprep corev1.SecretType = "provisioning.virtualization.deckhouse.io/sysprep" ) + +// USBDeviceSpecRef references a USB device by name. +type USBDeviceSpecRef struct { + // The name of USBDevice resource in the same namespace. + Name string `json:"name"` +} + +// USBDeviceStatusRef represents the status of a USB device attached to the virtual machine. +type USBDeviceStatusRef struct { + // The name of USBDevice resource. + Name string `json:"name"` + // The USB device is attached to the virtual machine. + Attached bool `json:"attached"` + // USB address inside the virtual machine. + Address *USBAddress `json:"address,omitempty"` + // USB device is attached via hot plug connection. + Hotplugged bool `json:"hotplugged,omitempty"` +} + +// USBAddress represents the USB bus address inside the virtual machine. +type USBAddress struct { + // USB bus number (always 0 for the main USB controller). + Bus int `json:"bus"` + // USB port number on the selected bus. + Port int `json:"port"` +} diff --git a/api/core/v1alpha2/zz_generated.deepcopy.go b/api/core/v1alpha2/zz_generated.deepcopy.go index 8e3d58fc8e..325860bc25 100644 --- a/api/core/v1alpha2/zz_generated.deepcopy.go +++ b/api/core/v1alpha2/zz_generated.deepcopy.go @@ -1025,6 +1025,22 @@ func (in *Topology) DeepCopy() *Topology { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *USBAddress) DeepCopyInto(out *USBAddress) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new USBAddress. +func (in *USBAddress) DeepCopy() *USBAddress { + if in == nil { + return nil + } + out := new(USBAddress) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *USBDevice) DeepCopyInto(out *USBDevice) { *out = *in @@ -1085,6 +1101,22 @@ func (in *USBDeviceList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *USBDeviceSpecRef) DeepCopyInto(out *USBDeviceSpecRef) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new USBDeviceSpecRef. +func (in *USBDeviceSpecRef) DeepCopy() *USBDeviceSpecRef { + if in == nil { + return nil + } + out := new(USBDeviceSpecRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *USBDeviceStatus) DeepCopyInto(out *USBDeviceStatus) { *out = *in @@ -1109,6 +1141,27 @@ func (in *USBDeviceStatus) DeepCopy() *USBDeviceStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *USBDeviceStatusRef) DeepCopyInto(out *USBDeviceStatusRef) { + *out = *in + if in.Address != nil { + in, out := &in.Address, &out.Address + *out = new(USBAddress) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new USBDeviceStatusRef. +func (in *USBDeviceStatusRef) DeepCopy() *USBDeviceStatusRef { + if in == nil { + return nil + } + out := new(USBDeviceStatusRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UserDataRef) DeepCopyInto(out *UserDataRef) { *out = *in @@ -3354,6 +3407,11 @@ func (in *VirtualMachineSpec) DeepCopyInto(out *VirtualMachineSpec) { *out = make([]NetworksSpec, len(*in)) copy(*out, *in) } + if in.USBDevices != nil { + in, out := &in.USBDevices, &out.USBDevices + *out = make([]USBDeviceSpecRef, len(*in)) + copy(*out, *in) + } return } @@ -3436,6 +3494,13 @@ func (in *VirtualMachineStatus) DeepCopyInto(out *VirtualMachineStatus) { *out = make([]NetworksStatus, len(*in)) copy(*out, *in) } + if in.USBDevices != nil { + in, out := &in.USBDevices, &out.USBDevices + *out = make([]USBDeviceStatusRef, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } return } diff --git a/images/virtualization-artifact/pkg/controller/indexer/indexer.go b/images/virtualization-artifact/pkg/controller/indexer/indexer.go index 5f01c64b7e..c5efe7ac0d 100644 --- a/images/virtualization-artifact/pkg/controller/indexer/indexer.go +++ b/images/virtualization-artifact/pkg/controller/indexer/indexer.go @@ -35,6 +35,7 @@ const ( IndexFieldVMByVD = "spec.blockDeviceRefs.VirtualDisk" IndexFieldVMByVI = "spec.blockDeviceRefs.VirtualImage" IndexFieldVMByCVI = "spec.blockDeviceRefs.ClusterVirtualImage" + IndexFieldVMByUSBDevice = "spec.usbDevices.name" IndexFieldVMByNode = "status.node" IndexFieldVDByVDSnapshot = "vd,spec.DataSource.ObjectRef.Name,.Kind=VirtualDiskSnapshot" @@ -72,6 +73,7 @@ var IndexGetters = []IndexGetter{ IndexVMByVD, IndexVMByVI, IndexVMByCVI, + IndexVMByUSBDevice, IndexVMByNode, IndexVMByProvisioningSecret, IndexVMSnapshotByVM, @@ -191,3 +193,24 @@ func getBlockDeviceNamesByKind(obj client.Object, kind v1alpha2.BlockDeviceKind) return result } + +func IndexVMByUSBDevice() (obj client.Object, field string, extractValue client.IndexerFunc) { + return &v1alpha2.VirtualMachine{}, IndexFieldVMByUSBDevice, func(object client.Object) []string { + vm, ok := object.(*v1alpha2.VirtualMachine) + if !ok || vm == nil { + return nil + } + + seen := make(map[string]struct{}) + var result []string + + for _, ref := range vm.Spec.USBDevices { + if _, exists := seen[ref.Name]; !exists { + seen[ref.Name] = struct{}{} + result = append(result, ref.Name) + } + } + + return result + } +} diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go b/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go index 0864de03da..773f41f442 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/state/state.go @@ -54,6 +54,8 @@ type VirtualMachineState interface { VMOPs(ctx context.Context) ([]*v1alpha2.VirtualMachineOperation, error) Shared(fn func(s *Shared)) ReadWriteOnceVirtualDisks(ctx context.Context) ([]*v1alpha2.VirtualDisk, error) + USBDevice(ctx context.Context, name string) (*v1alpha2.USBDevice, error) + USBDevicesByName(ctx context.Context) (map[string]*v1alpha2.USBDevice, error) } func New(c client.Client, vm *reconciler.Resource[*v1alpha2.VirtualMachine, v1alpha2.VirtualMachineStatus]) VirtualMachineState { @@ -383,3 +385,25 @@ func (s *state) ReadWriteOnceVirtualDisks(ctx context.Context) ([]*v1alpha2.Virt return nonMigratableVirtualDisks, nil } + +func (s *state) USBDevice(ctx context.Context, name string) (*v1alpha2.USBDevice, error) { + return object.FetchObject(ctx, types.NamespacedName{ + Name: name, + Namespace: s.vm.Current().GetNamespace(), + }, s.client, &v1alpha2.USBDevice{}) +} + +func (s *state) USBDevicesByName(ctx context.Context) (map[string]*v1alpha2.USBDevice, error) { + usbDevicesByName := make(map[string]*v1alpha2.USBDevice) + for _, usbDeviceRef := range s.vm.Current().Spec.USBDevices { + usbDevice, err := s.USBDevice(ctx, usbDeviceRef.Name) + if err != nil { + return nil, fmt.Errorf("unable to get USB device %q: %w", usbDeviceRef.Name, err) + } + if usbDevice == nil { + continue + } + usbDevicesByName[usbDeviceRef.Name] = usbDevice + } + return usbDevicesByName, nil +} diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go new file mode 100644 index 0000000000..edf51071c9 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go @@ -0,0 +1,312 @@ +/* +Copyright 2025 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 internal + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + resourcev1beta1 "k8s.io/api/resource/v1beta1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/vm/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned" +) + +const nameUSBDeviceHandler = "USBDeviceHandler" + +func NewUSBDeviceHandler(cl client.Client, virtClient versioned.Interface) *USBDeviceHandler { + return &USBDeviceHandler{ + client: cl, + virtClient: virtClient, + } +} + +type USBDeviceHandler struct { + client client.Client + virtClient versioned.Interface +} + +func (h *USBDeviceHandler) Name() string { + return nameUSBDeviceHandler +} + +func (h *USBDeviceHandler) Handle(ctx context.Context, s state.VirtualMachineState) (reconcile.Result, error) { + log := logger.FromContext(ctx).With(logger.SlogHandler(nameUSBDeviceHandler)) + + if s.VirtualMachine().IsEmpty() { + return reconcile.Result{}, nil + } + + vm := s.VirtualMachine().Current() + changed := s.VirtualMachine().Changed() + + // Get all USB devices from spec + usbDevicesByName, err := s.USBDevicesByName(ctx) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to get USB devices: %w", err) + } + + // Build current status map + currentStatusMap := make(map[string]*v1alpha2.USBDeviceStatusRef) + for i := range changed.Status.USBDevices { + ref := &changed.Status.USBDevices[i] + currentStatusMap[ref.Name] = ref + } + + // Process each USB device in spec + var statusRefs []v1alpha2.USBDeviceStatusRef + for _, usbDeviceRef := range vm.Spec.USBDevices { + usbDevice, exists := usbDevicesByName[usbDeviceRef.Name] + if !exists { + // USB device not found, but we still track it in status + statusRef := v1alpha2.USBDeviceStatusRef{ + Name: usbDeviceRef.Name, + Attached: false, + } + statusRefs = append(statusRefs, statusRef) + continue + } + + // Get or create ResourceClaimTemplate + templateName := h.getResourceClaimTemplateName(vm, usbDeviceRef.Name) + template, err := h.getOrCreateResourceClaimTemplate(ctx, vm, usbDevice, templateName) + if err != nil { + log.Error("failed to get or create ResourceClaimTemplate", "error", err, "usbDevice", usbDeviceRef.Name) + // Continue with other devices + statusRef := v1alpha2.USBDeviceStatusRef{ + Name: usbDeviceRef.Name, + Attached: false, + } + statusRefs = append(statusRefs, statusRef) + continue + } + + // Check if device is ready + if !h.isUSBDeviceReady(usbDevice) { + log.Info("USB device not ready", "usbDevice", usbDeviceRef.Name) + // Keep existing status if available + if existingStatus, ok := currentStatusMap[usbDeviceRef.Name]; ok { + statusRefs = append(statusRefs, *existingStatus) + } else { + statusRefs = append(statusRefs, v1alpha2.USBDeviceStatusRef{ + Name: usbDeviceRef.Name, + Attached: false, + }) + } + continue + } + + // Check if already attached + existingStatus, alreadyAttached := currentStatusMap[usbDeviceRef.Name] + if alreadyAttached && existingStatus.Attached { + // Device already attached, keep status + statusRefs = append(statusRefs, *existingStatus) + continue + } + + // Try to attach via addResourceClaim API + requestName := fmt.Sprintf("req-%s", usbDeviceRef.Name) + err = h.attachUSBDevice(ctx, vm, usbDeviceRef.Name, templateName, requestName) + if err != nil { + log.Error("failed to attach USB device", "error", err, "usbDevice", usbDeviceRef.Name) + // Keep existing status or create new one + if existingStatus != nil { + statusRefs = append(statusRefs, *existingStatus) + } else { + statusRefs = append(statusRefs, v1alpha2.USBDeviceStatusRef{ + Name: usbDeviceRef.Name, + Attached: false, + }) + } + continue + } + + // Device attached successfully + // Determine if it's hotplugged (VM is running) + isHotplugged := vm.Status.Phase == v1alpha2.MachineRunning + + // Get or assign USB address + address := h.getOrAssignUSBAddress(existingStatus, isHotplugged) + + statusRef := v1alpha2.USBDeviceStatusRef{ + Name: usbDeviceRef.Name, + Attached: true, + Address: address, + Hotplugged: isHotplugged, + } + statusRefs = append(statusRefs, statusRef) + } + + // Remove devices that are no longer in spec + // (they will be automatically unplugged when removed from spec) + + changed.Status.USBDevices = statusRefs + + return reconcile.Result{}, nil +} + +func (h *USBDeviceHandler) getResourceClaimTemplateName(vm *v1alpha2.VirtualMachine, usbDeviceName string) string { + return fmt.Sprintf("%s-usb-%s-template", vm.Name, usbDeviceName) +} + +func (h *USBDeviceHandler) getOrCreateResourceClaimTemplate( + ctx context.Context, + vm *v1alpha2.VirtualMachine, + usbDevice *v1alpha2.USBDevice, + templateName string, +) (*resourcev1beta1.ResourceClaimTemplate, error) { + // Try to get existing template + template := &resourcev1beta1.ResourceClaimTemplate{} + key := types.NamespacedName{ + Name: templateName, + Namespace: vm.Namespace, + } + + err := h.client.Get(ctx, key, template) + if err == nil { + // Template exists + return template, nil + } + + if !client.IgnoreNotFound(err) { + return nil, fmt.Errorf("failed to get ResourceClaimTemplate: %w", err) + } + + // Template doesn't exist, create it + attributes := usbDevice.Status.Attributes + if attributes.VendorID == "" || attributes.ProductID == "" { + return nil, fmt.Errorf("USB device %s missing vendorID or productID", usbDevice.Name) + } + + // Build CEL expression to match this specific USB device + celExpression := fmt.Sprintf( + `device.attributes["virtualization-dra"].productID == "%s" && device.attributes["virtualization-dra"].vendorID == "%s"`, + attributes.ProductID, + attributes.VendorID, + ) + + // Add serial number if available for more precise matching + if attributes.Serial != "" { + celExpression = fmt.Sprintf(`%s && device.attributes["virtualization-dra"].serial == "%s"`, celExpression, attributes.Serial) + } + + template = &resourcev1beta1.ResourceClaimTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: templateName, + Namespace: vm.Namespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: vm.APIVersion, + Kind: vm.Kind, + Name: vm.Name, + UID: vm.UID, + Controller: ptr.To(true), + }, + }, + }, + Spec: resourcev1beta1.ResourceClaimTemplateSpec{ + Spec: resourcev1beta1.ResourceClaimSpec{ + ResourceClassName: "usb-devices.virtualization.deckhouse.io", + AllocationMode: resourcev1beta1.AllocationModeWaitForFirstConsumer, + Devices: &resourcev1beta1.DeviceRequest{ + Requests: []resourcev1beta1.DeviceRequest{ + { + Name: "req-0", + AllocationMode: resourcev1beta1.AllocationModeExactCount, + Count: ptr.To(int32(1)), + DeviceClassName: "usb-devices.virtualization.deckhouse.io", + Selectors: []resourcev1beta1.DeviceSelector{ + { + CEL: &resourcev1beta1.CELDeviceSelector{ + Expression: celExpression, + }, + }, + }, + }, + }, + }, + }, + }, + } + + if err := h.client.Create(ctx, template); err != nil { + return nil, fmt.Errorf("failed to create ResourceClaimTemplate: %w", err) + } + + return template, nil +} + +func (h *USBDeviceHandler) isUSBDeviceReady(usbDevice *v1alpha2.USBDevice) bool { + // Check if USB device has required attributes + if usbDevice.Status.Attributes.VendorID == "" || usbDevice.Status.Attributes.ProductID == "" { + return false + } + + // Check if device has node assigned + if usbDevice.Status.NodeName == "" { + return false + } + + // TODO: Check conditions if needed + return true +} + +func (h *USBDeviceHandler) attachUSBDevice( + ctx context.Context, + vm *v1alpha2.VirtualMachine, + usbDeviceName string, + templateName string, + requestName string, +) error { + // Call addResourceClaim API + opts := v1alpha2.VirtualMachineAddResourceClaim{ + Name: usbDeviceName, + ResourceClaimTemplateName: templateName, + RequestName: requestName, + } + + return h.virtClient.VirtualizationV1alpha2().VirtualMachines(vm.Namespace).AddResourceClaim(ctx, vm.Name, opts) +} + +func (h *USBDeviceHandler) getOrAssignUSBAddress( + existingStatus *v1alpha2.USBDeviceStatusRef, + isHotplugged bool, +) *v1alpha2.USBAddress { + // If device was already attached, keep the same address + if existingStatus != nil && existingStatus.Address != nil { + return existingStatus.Address + } + + // Assign new address + // Bus is always 0 for main USB controller + // Port should be assigned based on available ports + // For simplicity, we'll use a sequential port number starting from 1 + // In a real implementation, you'd need to track used ports + port := 1 // TODO: Implement proper port allocation + + return &v1alpha2.USBAddress{ + Bus: 0, + Port: port, + } +} diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/watcher/usbdevice_watcher.go b/images/virtualization-artifact/pkg/controller/vm/internal/watcher/usbdevice_watcher.go new file mode 100644 index 0000000000..9ad8330b43 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/watcher/usbdevice_watcher.go @@ -0,0 +1,77 @@ +/* +Copyright 2025 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 watcher + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type USBDeviceWatcher struct { + client client.Client +} + +func NewUSBDeviceWatcher(client client.Client) *USBDeviceWatcher { + return &USBDeviceWatcher{ + client: client, + } +} + +func (w *USBDeviceWatcher) Watch(mgr manager.Manager, ctr controller.Controller) error { + return ctr.Watch( + source.Kind( + mgr.GetCache(), + &v1alpha2.USBDevice{}, + handler.TypedEnqueueRequestsFromMapFunc(w.enqueue), + ), + ) +} + +func (w *USBDeviceWatcher) enqueue(ctx context.Context, usbDevice *v1alpha2.USBDevice) []reconcile.Request { + var vms v1alpha2.VirtualMachineList + err := w.client.List(ctx, &vms, &client.ListOptions{ + Namespace: usbDevice.Namespace, + FieldSelector: fields.OneTermEqualSelector(indexer.IndexFieldVMByUSBDevice, usbDevice.Name), + }) + if err != nil { + return nil + } + + var result []reconcile.Request + for _, vm := range vms.Items { + result = append(result, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: vm.GetName(), + Namespace: vm.GetNamespace(), + }, + }) + } + + return result +} diff --git a/images/virtualization-artifact/pkg/controller/vm/vm_controller.go b/images/virtualization-artifact/pkg/controller/vm/vm_controller.go index 1cd2ad4433..861ddd0a48 100644 --- a/images/virtualization-artifact/pkg/controller/vm/vm_controller.go +++ b/images/virtualization-artifact/pkg/controller/vm/vm_controller.go @@ -37,6 +37,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/logger" vmmetrics "github.com/deckhouse/virtualization-controller/pkg/monitoring/metrics/virtualmachine" "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned" ) const ( @@ -58,6 +59,11 @@ func SetupController( migrateVolumesService := vmservice.NewMigrationVolumesService(client, internal.MakeKVVMFromVMSpec, 10*time.Second) + virtClient, err := versioned.NewForConfig(mgr.GetConfig()) + if err != nil { + return fmt.Errorf("failed to create virtualization client: %w", err) + } + handlers := []Handler{ internal.NewMaintenanceHandler(client), internal.NewDeletionHandler(client), @@ -65,6 +71,7 @@ func SetupController( internal.NewIPAMHandler(netmanager.NewIPAM(), client, recorder), internal.NewMACHandler(netmanager.NewMACManager(), client, recorder), internal.NewBlockDeviceHandler(client, blockDeviceService), + internal.NewUSBDeviceHandler(client, virtClient), internal.NewProvisioningHandler(client), internal.NewAgentHandler(), internal.NewFilesystemHandler(), diff --git a/images/virtualization-artifact/pkg/controller/vm/vm_reconciler.go b/images/virtualization-artifact/pkg/controller/vm/vm_reconciler.go index ba45da80bd..29c5bacce3 100644 --- a/images/virtualization-artifact/pkg/controller/vm/vm_reconciler.go +++ b/images/virtualization-artifact/pkg/controller/vm/vm_reconciler.go @@ -68,6 +68,7 @@ func (r *Reconciler) SetupController(_ context.Context, mgr manager.Manager, ctr watcher.NewVirtualImageWatcher(mgr.GetClient()), watcher.NewClusterVirtualImageWatcher(mgr.GetClient()), watcher.NewVirtualDiskWatcher(mgr.GetClient()), + watcher.NewUSBDeviceWatcher(mgr.GetClient()), watcher.NewVMIPWatcher(), watcher.NewVirtualMachineClassWatcher(), watcher.NewVirtualMachineSnapshotWatcher(), From 57465437f5d2dde84b0d6491270636e93b030ca3 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Mon, 26 Jan 2026 15:26:36 +0200 Subject: [PATCH 07/53] fix errors Signed-off-by: Daniil Antoshin --- .../vm/internal/usb_device_handler.go | 61 ++++++++++++++----- .../vm/internal/watcher/usbdevice_watcher.go | 1 - .../pkg/controller/vm/vm_controller.go | 3 +- templates/virtualization-api/rbac-for-us.yaml | 7 ++- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go index edf51071c9..7b740547f8 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go @@ -20,17 +20,18 @@ import ( "context" "fmt" + resourcev1beta1 "k8s.io/api/resource/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - resourcev1beta1 "k8s.io/api/resource/v1beta1" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/controller/vm/internal/state" "github.com/deckhouse/virtualization-controller/pkg/logger" - "github.com/deckhouse/virtualization/api/core/v1alpha2" "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + subv1alpha2 "github.com/deckhouse/virtualization/api/subresources/v1alpha2" ) const nameUSBDeviceHandler = "USBDeviceHandler" @@ -90,7 +91,7 @@ func (h *USBDeviceHandler) Handle(ctx context.Context, s state.VirtualMachineSta // Get or create ResourceClaimTemplate templateName := h.getResourceClaimTemplateName(vm, usbDeviceRef.Name) - template, err := h.getOrCreateResourceClaimTemplate(ctx, vm, usbDevice, templateName) + _, err := h.getOrCreateResourceClaimTemplate(ctx, vm, usbDevice, templateName) if err != nil { log.Error("failed to get or create ResourceClaimTemplate", "error", err, "usbDevice", usbDeviceRef.Name) // Continue with other devices @@ -150,16 +151,33 @@ func (h *USBDeviceHandler) Handle(ctx context.Context, s state.VirtualMachineSta address := h.getOrAssignUSBAddress(existingStatus, isHotplugged) statusRef := v1alpha2.USBDeviceStatusRef{ - Name: usbDeviceRef.Name, - Attached: true, - Address: address, - Hotplugged: isHotplugged, + Name: usbDeviceRef.Name, + Attached: true, + Address: address, + Hotplugged: isHotplugged, } statusRefs = append(statusRefs, statusRef) } // Remove devices that are no longer in spec - // (they will be automatically unplugged when removed from spec) + specDeviceNames := make(map[string]bool) + for _, usbDeviceRef := range vm.Spec.USBDevices { + specDeviceNames[usbDeviceRef.Name] = true + } + + for _, existingStatus := range currentStatusMap { + if !specDeviceNames[existingStatus.Name] && existingStatus.Attached { + // Device was removed from spec but is still attached, need to detach + err := h.detachUSBDevice(ctx, vm, existingStatus.Name) + if err != nil { + log.Error("failed to detach USB device", "error", err, "usbDevice", existingStatus.Name) + // Keep status but mark as not attached + existingStatus.Attached = false + statusRefs = append(statusRefs, *existingStatus) + } + // If detach succeeded, device is removed from status (not added to statusRefs) + } + } changed.Status.USBDevices = statusRefs @@ -189,7 +207,7 @@ func (h *USBDeviceHandler) getOrCreateResourceClaimTemplate( return template, nil } - if !client.IgnoreNotFound(err) { + if client.IgnoreNotFound(err) != nil { return nil, fmt.Errorf("failed to get ResourceClaimTemplate: %w", err) } @@ -227,14 +245,12 @@ func (h *USBDeviceHandler) getOrCreateResourceClaimTemplate( }, Spec: resourcev1beta1.ResourceClaimTemplateSpec{ Spec: resourcev1beta1.ResourceClaimSpec{ - ResourceClassName: "usb-devices.virtualization.deckhouse.io", - AllocationMode: resourcev1beta1.AllocationModeWaitForFirstConsumer, - Devices: &resourcev1beta1.DeviceRequest{ + Devices: resourcev1beta1.DeviceClaim{ Requests: []resourcev1beta1.DeviceRequest{ { - Name: "req-0", - AllocationMode: resourcev1beta1.AllocationModeExactCount, - Count: ptr.To(int32(1)), + Name: "req-0", + AllocationMode: resourcev1beta1.DeviceAllocationModeExactCount, + Count: 1, DeviceClassName: "usb-devices.virtualization.deckhouse.io", Selectors: []resourcev1beta1.DeviceSelector{ { @@ -280,7 +296,7 @@ func (h *USBDeviceHandler) attachUSBDevice( requestName string, ) error { // Call addResourceClaim API - opts := v1alpha2.VirtualMachineAddResourceClaim{ + opts := subv1alpha2.VirtualMachineAddResourceClaim{ Name: usbDeviceName, ResourceClaimTemplateName: templateName, RequestName: requestName, @@ -289,6 +305,19 @@ func (h *USBDeviceHandler) attachUSBDevice( return h.virtClient.VirtualizationV1alpha2().VirtualMachines(vm.Namespace).AddResourceClaim(ctx, vm.Name, opts) } +func (h *USBDeviceHandler) detachUSBDevice( + ctx context.Context, + vm *v1alpha2.VirtualMachine, + usbDeviceName string, +) error { + // Call removeResourceClaim API + opts := subv1alpha2.VirtualMachineRemoveResourceClaim{ + Name: usbDeviceName, + } + + return h.virtClient.VirtualizationV1alpha2().VirtualMachines(vm.Namespace).RemoveResourceClaim(ctx, vm.Name, opts) +} + func (h *USBDeviceHandler) getOrAssignUSBAddress( existingStatus *v1alpha2.USBDeviceStatusRef, isHotplugged bool, diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/watcher/usbdevice_watcher.go b/images/virtualization-artifact/pkg/controller/vm/internal/watcher/usbdevice_watcher.go index 9ad8330b43..dc4302c7fa 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/watcher/usbdevice_watcher.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/watcher/usbdevice_watcher.go @@ -18,7 +18,6 @@ package watcher import ( "context" - "fmt" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/types" diff --git a/images/virtualization-artifact/pkg/controller/vm/vm_controller.go b/images/virtualization-artifact/pkg/controller/vm/vm_controller.go index 861ddd0a48..473c0b9411 100644 --- a/images/virtualization-artifact/pkg/controller/vm/vm_controller.go +++ b/images/virtualization-artifact/pkg/controller/vm/vm_controller.go @@ -18,6 +18,7 @@ package vm import ( "context" + "fmt" "time" "k8s.io/utils/ptr" @@ -36,8 +37,8 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/featuregates" "github.com/deckhouse/virtualization-controller/pkg/logger" vmmetrics "github.com/deckhouse/virtualization-controller/pkg/monitoring/metrics/virtualmachine" - "github.com/deckhouse/virtualization/api/core/v1alpha2" "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned" + "github.com/deckhouse/virtualization/api/core/v1alpha2" ) const ( diff --git a/templates/virtualization-api/rbac-for-us.yaml b/templates/virtualization-api/rbac-for-us.yaml index 1825079891..fed299ae61 100644 --- a/templates/virtualization-api/rbac-for-us.yaml +++ b/templates/virtualization-api/rbac-for-us.yaml @@ -9,7 +9,6 @@ metadata: imagePullSecrets: - name: virtualization-module-registry --- -# TODO: add addresourceclaim and removeresourceclaim permissions after rebase to main apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: @@ -73,6 +72,8 @@ rules: - virtualmachineinstances/unfreeze - virtualmachineinstances/addvolume - virtualmachineinstances/removevolume + - virtualmachineinstances/addresourceclaim + - virtualmachineinstances/removeresourceclaim verbs: - get - patch @@ -83,6 +84,8 @@ rules: resources: - virtualmachines/addvolume - virtualmachines/removevolume + - virtualmachines/addresourceclaim + - virtualmachines/removeresourceclaim - virtualmachines/evacuatecancel verbs: - get @@ -94,11 +97,13 @@ rules: resources: - virtualmachines - virtualmachines/addvolume + - virtualmachines/addresourceclaim - virtualmachines/cancelevacuation - virtualmachines/console - virtualmachines/freeze - virtualmachines/portforward - virtualmachines/removevolume + - virtualmachines/removeresourceclaim - virtualmachines/unfreeze - virtualmachines/vnc verbs: From 9de96286864ca234661ba483128b6ef61f34eeba Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Mon, 26 Jan 2026 15:40:09 +0200 Subject: [PATCH 08/53] add test Signed-off-by: Daniil Antoshin --- .../vm/internal/usb_device_handler_test.go | 402 ++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go new file mode 100644 index 0000000000..e11a626e39 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go @@ -0,0 +1,402 @@ +/* +Copyright 2025 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 internal + +import ( + "context" + "log/slog" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + resourcev1beta1 "k8s.io/api/resource/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apiruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization-controller/pkg/controller/vm/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/logger" + fakeversioned "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/fake" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +var _ = Describe("USBDeviceHandler", func() { + var ctx context.Context + var fakeClient client.WithWatch + var fakeVirtClient *fakeversioned.Clientset + var handler *USBDeviceHandler + var vmState state.VirtualMachineState + var vmResource *reconciler.Resource[*v1alpha2.VirtualMachine, v1alpha2.VirtualMachineStatus] + + BeforeEach(func() { + ctx = logger.ToContext(context.TODO(), slog.Default()) + fakeVirtClient = fakeversioned.NewSimpleClientset() + }) + + Context("when handling USB devices", func() { + It("should create ResourceClaimTemplate for new USB device", func() { + vm := &v1alpha2.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vm", + Namespace: "default", + UID: types.UID("vm-uid"), + }, + Spec: v1alpha2.VirtualMachineSpec{ + USBDevices: []v1alpha2.USBDeviceSpecRef{ + {Name: "usb-device-1"}, + }, + }, + Status: v1alpha2.VirtualMachineStatus{ + Phase: v1alpha2.MachineStopped, + }, + } + + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "default", + }, + Status: v1alpha2.USBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{ + VendorID: "1234", + ProductID: "5678", + }, + NodeName: "node-1", + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(resourcev1beta1.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(vm, usbDevice).Build() + + vmResource = reconciler.NewResource( + types.NamespacedName{Name: vm.Name, Namespace: vm.Namespace}, + fakeClient, + func() *v1alpha2.VirtualMachine { return &v1alpha2.VirtualMachine{} }, + func(obj *v1alpha2.VirtualMachine) v1alpha2.VirtualMachineStatus { return obj.Status }, + ) + Expect(vmResource.Fetch(ctx)).To(Succeed()) + + vmState = state.New(fakeClient, vmResource) + handler = NewUSBDeviceHandler(fakeClient, fakeVirtClient) + + // Fake client already implements AddResourceClaim, no need to mock + + result, err := handler.Handle(ctx, vmState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify ResourceClaimTemplate was created + template := &resourcev1beta1.ResourceClaimTemplate{} + templateName := "test-vm-usb-usb-device-1-template" + err = fakeClient.Get(ctx, types.NamespacedName{Name: templateName, Namespace: "default"}, template) + Expect(err).NotTo(HaveOccurred()) + Expect(template.OwnerReferences).To(HaveLen(1)) + Expect(template.OwnerReferences[0].Name).To(Equal("test-vm")) + Expect(template.OwnerReferences[0].Controller).To(Equal(ptr.To(true))) + }) + + It("should attach USB device when ready", func() { + vm := &v1alpha2.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vm", + Namespace: "default", + UID: types.UID("vm-uid"), + }, + Spec: v1alpha2.VirtualMachineSpec{ + USBDevices: []v1alpha2.USBDeviceSpecRef{ + {Name: "usb-device-1"}, + }, + }, + Status: v1alpha2.VirtualMachineStatus{ + Phase: v1alpha2.MachineRunning, + }, + } + + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "default", + }, + Status: v1alpha2.USBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{ + VendorID: "1234", + ProductID: "5678", + }, + NodeName: "node-1", + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(resourcev1beta1.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(vm, usbDevice).Build() + + vmResource = reconciler.NewResource( + types.NamespacedName{Name: vm.Name, Namespace: vm.Namespace}, + fakeClient, + func() *v1alpha2.VirtualMachine { return &v1alpha2.VirtualMachine{} }, + func(obj *v1alpha2.VirtualMachine) v1alpha2.VirtualMachineStatus { return obj.Status }, + ) + Expect(vmResource.Fetch(ctx)).To(Succeed()) + + vmState = state.New(fakeClient, vmResource) + handler = NewUSBDeviceHandler(fakeClient, fakeVirtClient) + + result, err := handler.Handle(ctx, vmState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify AddResourceClaim was called (fake client implements it) + + // Verify status was updated + Expect(vmResource.Changed().Status.USBDevices).To(HaveLen(1)) + Expect(vmResource.Changed().Status.USBDevices[0].Name).To(Equal("usb-device-1")) + Expect(vmResource.Changed().Status.USBDevices[0].Attached).To(BeTrue()) + Expect(vmResource.Changed().Status.USBDevices[0].Hotplugged).To(BeTrue()) + Expect(vmResource.Changed().Status.USBDevices[0].Address).NotTo(BeNil()) + }) + + It("should not attach USB device when not ready", func() { + vm := &v1alpha2.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vm", + Namespace: "default", + UID: types.UID("vm-uid"), + }, + Spec: v1alpha2.VirtualMachineSpec{ + USBDevices: []v1alpha2.USBDeviceSpecRef{ + {Name: "usb-device-1"}, + }, + }, + Status: v1alpha2.VirtualMachineStatus{ + Phase: v1alpha2.MachineStopped, + }, + } + + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "default", + }, + Status: v1alpha2.USBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{ + VendorID: "", // Missing vendor ID + ProductID: "5678", + }, + NodeName: "node-1", + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(resourcev1beta1.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(vm, usbDevice).Build() + + vmResource = reconciler.NewResource( + types.NamespacedName{Name: vm.Name, Namespace: vm.Namespace}, + fakeClient, + func() *v1alpha2.VirtualMachine { return &v1alpha2.VirtualMachine{} }, + func(obj *v1alpha2.VirtualMachine) v1alpha2.VirtualMachineStatus { return obj.Status }, + ) + Expect(vmResource.Fetch(ctx)).To(Succeed()) + + vmState = state.New(fakeClient, vmResource) + handler = NewUSBDeviceHandler(fakeClient, fakeVirtClient) + + result, err := handler.Handle(ctx, vmState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify device was not attached + Expect(vmResource.Changed().Status.USBDevices).To(HaveLen(1)) + Expect(vmResource.Changed().Status.USBDevices[0].Attached).To(BeFalse()) + }) + + It("should handle missing USB device gracefully", func() { + vm := &v1alpha2.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vm", + Namespace: "default", + UID: types.UID("vm-uid"), + }, + Spec: v1alpha2.VirtualMachineSpec{ + USBDevices: []v1alpha2.USBDeviceSpecRef{ + {Name: "non-existent-device"}, + }, + }, + Status: v1alpha2.VirtualMachineStatus{ + Phase: v1alpha2.MachineStopped, + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(vm).Build() + + vmResource = reconciler.NewResource( + types.NamespacedName{Name: vm.Name, Namespace: vm.Namespace}, + fakeClient, + func() *v1alpha2.VirtualMachine { return &v1alpha2.VirtualMachine{} }, + func(obj *v1alpha2.VirtualMachine) v1alpha2.VirtualMachineStatus { return obj.Status }, + ) + Expect(vmResource.Fetch(ctx)).To(Succeed()) + + vmState = state.New(fakeClient, vmResource) + handler = NewUSBDeviceHandler(fakeClient, fakeVirtClient) + + result, err := handler.Handle(ctx, vmState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify device is tracked in status but not attached + Expect(vmResource.Changed().Status.USBDevices).To(HaveLen(1)) + Expect(vmResource.Changed().Status.USBDevices[0].Name).To(Equal("non-existent-device")) + Expect(vmResource.Changed().Status.USBDevices[0].Attached).To(BeFalse()) + }) + + It("should detach USB device when removed from spec", func() { + vm := &v1alpha2.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vm", + Namespace: "default", + UID: types.UID("vm-uid"), + }, + Spec: v1alpha2.VirtualMachineSpec{ + USBDevices: []v1alpha2.USBDeviceSpecRef{}, // Empty - device removed + }, + Status: v1alpha2.VirtualMachineStatus{ + Phase: v1alpha2.MachineRunning, + USBDevices: []v1alpha2.USBDeviceStatusRef{ + { + Name: "usb-device-1", + Attached: true, + Address: &v1alpha2.USBAddress{ + Bus: 0, + Port: 1, + }, + }, + }, + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(vm).Build() + + vmResource = reconciler.NewResource( + types.NamespacedName{Name: vm.Name, Namespace: vm.Namespace}, + fakeClient, + func() *v1alpha2.VirtualMachine { return &v1alpha2.VirtualMachine{} }, + func(obj *v1alpha2.VirtualMachine) v1alpha2.VirtualMachineStatus { return obj.Status }, + ) + Expect(vmResource.Fetch(ctx)).To(Succeed()) + + vmState = state.New(fakeClient, vmResource) + handler = NewUSBDeviceHandler(fakeClient, fakeVirtClient) + + result, err := handler.Handle(ctx, vmState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify RemoveResourceClaim was called (fake client implements it) + + // Verify device was removed from status + Expect(vmResource.Changed().Status.USBDevices).To(BeEmpty()) + }) + + It("should keep existing address when device already attached", func() { + existingAddress := &v1alpha2.USBAddress{ + Bus: 0, + Port: 2, + } + + vm := &v1alpha2.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vm", + Namespace: "default", + UID: types.UID("vm-uid"), + }, + Spec: v1alpha2.VirtualMachineSpec{ + USBDevices: []v1alpha2.USBDeviceSpecRef{ + {Name: "usb-device-1"}, + }, + }, + Status: v1alpha2.VirtualMachineStatus{ + Phase: v1alpha2.MachineRunning, + USBDevices: []v1alpha2.USBDeviceStatusRef{ + { + Name: "usb-device-1", + Attached: true, + Address: existingAddress, + }, + }, + }, + } + + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "default", + }, + Status: v1alpha2.USBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{ + VendorID: "1234", + ProductID: "5678", + }, + NodeName: "node-1", + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(resourcev1beta1.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(vm, usbDevice).Build() + + vmResource = reconciler.NewResource( + types.NamespacedName{Name: vm.Name, Namespace: vm.Namespace}, + fakeClient, + func() *v1alpha2.VirtualMachine { return &v1alpha2.VirtualMachine{} }, + func(obj *v1alpha2.VirtualMachine) v1alpha2.VirtualMachineStatus { return obj.Status }, + ) + Expect(vmResource.Fetch(ctx)).To(Succeed()) + + vmState = state.New(fakeClient, vmResource) + handler = NewUSBDeviceHandler(fakeClient, fakeVirtClient) + + result, err := handler.Handle(ctx, vmState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify existing address was preserved + Expect(vmResource.Changed().Status.USBDevices).To(HaveLen(1)) + Expect(vmResource.Changed().Status.USBDevices[0].Address).To(Equal(existingAddress)) + }) + + }) +}) From 9b900651b91f971e19ccc006eb444987b83a36e5 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Mon, 26 Jan 2026 16:21:54 +0200 Subject: [PATCH 09/53] add unit tests Signed-off-by: Daniil Antoshin --- .../nodeusbdevice/internal/assigned_test.go | 273 ++++++++++++++++++ .../nodeusbdevice/internal/deletion_test.go | 173 +++++++++++ .../nodeusbdevice/internal/discovery.go | 2 +- .../nodeusbdevice/internal/ready.go | 2 +- .../nodeusbdevice/internal/ready_test.go | 204 +++++++++++++ .../nodeusbdevice/internal/suite_test.go | 29 ++ .../usbdevice/internal/deletion_test.go | 180 ++++++++++++ .../usbdevice/internal/ready_test.go | 248 ++++++++++++++++ .../usbdevice/internal/suite_test.go | 29 ++ .../usbdevice/internal/sync_test.go | 149 ++++++++++ 10 files changed, 1287 insertions(+), 2 deletions(-) create mode 100644 images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go create mode 100644 images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion_test.go create mode 100644 images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready_test.go create mode 100644 images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/suite_test.go create mode 100644 images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go create mode 100644 images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go create mode 100644 images/virtualization-artifact/pkg/controller/usbdevice/internal/suite_test.go create mode 100644 images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go new file mode 100644 index 0000000000..c1e5606749 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go @@ -0,0 +1,273 @@ +/* +Copyright 2025 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 internal + +import ( + "context" + "log/slog" + + . "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" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" +) + +var _ = Describe("AssignedHandler", func() { + var ctx context.Context + var fakeClient client.WithWatch + var handler *AssignedHandler + var nodeUSBDeviceState state.NodeUSBDeviceState + var nodeUSBDeviceResource *reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus] + + BeforeEach(func() { + ctx = logger.ToContext(context.TODO(), slog.Default()) + }) + + Context("when namespace is assigned", func() { + It("should create USBDevice in assigned namespace", func() { + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-namespace", + }, + } + + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + }, + Spec: v1alpha2.NodeUSBDeviceSpec{ + AssignedNamespace: "test-namespace", + }, + Status: v1alpha2.NodeUSBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{ + VendorID: "1234", + ProductID: "5678", + }, + NodeName: "node-1", + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(nodeUSBDevice, namespace).Build() + + nodeUSBDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: nodeUSBDevice.Name}, + fakeClient, + func() *v1alpha2.NodeUSBDevice { return &v1alpha2.NodeUSBDevice{} }, + func(obj *v1alpha2.NodeUSBDevice) v1alpha2.NodeUSBDeviceStatus { return obj.Status }, + ) + Expect(nodeUSBDeviceResource.Fetch(ctx)).To(Succeed()) + + nodeUSBDeviceState = state.New(fakeClient, nodeUSBDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{} + handler = NewAssignedHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, nodeUSBDeviceState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify USBDevice was created + usbDevice := &v1alpha2.USBDevice{} + err = fakeClient.Get(ctx, types.NamespacedName{Name: "usb-device-1", Namespace: "test-namespace"}, usbDevice) + Expect(err).NotTo(HaveOccurred()) + Expect(usbDevice.Status.Attributes.VendorID).To(Equal("1234")) + Expect(usbDevice.Status.NodeName).To(Equal("node-1")) + + // Verify Assigned condition was set + conditions := nodeUSBDeviceResource.Changed().Status.Conditions + Expect(conditions).To(HaveLen(1)) + Expect(conditions[0].Type).To(Equal(string(nodeusbdevicecondition.AssignedType))) + Expect(conditions[0].Status).To(Equal(metav1.ConditionTrue)) + Expect(conditions[0].Reason).To(Equal(string(nodeusbdevicecondition.Assigned))) + }) + + It("should update USBDevice when it already exists", func() { + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-namespace", + }, + } + + existingUSBDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "test-namespace", + }, + Status: v1alpha2.USBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{ + VendorID: "0000", + ProductID: "0000", + }, + }, + } + + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + }, + Spec: v1alpha2.NodeUSBDeviceSpec{ + AssignedNamespace: "test-namespace", + }, + Status: v1alpha2.NodeUSBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{ + VendorID: "1234", + ProductID: "5678", + }, + NodeName: "node-1", + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(nodeUSBDevice, namespace, existingUSBDevice).Build() + + nodeUSBDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: nodeUSBDevice.Name}, + fakeClient, + func() *v1alpha2.NodeUSBDevice { return &v1alpha2.NodeUSBDevice{} }, + func(obj *v1alpha2.NodeUSBDevice) v1alpha2.NodeUSBDeviceStatus { return obj.Status }, + ) + Expect(nodeUSBDeviceResource.Fetch(ctx)).To(Succeed()) + + nodeUSBDeviceState = state.New(fakeClient, nodeUSBDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{} + handler = NewAssignedHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, nodeUSBDeviceState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify USBDevice was updated + usbDevice := &v1alpha2.USBDevice{} + err = fakeClient.Get(ctx, types.NamespacedName{Name: "usb-device-1", Namespace: "test-namespace"}, usbDevice) + Expect(err).NotTo(HaveOccurred()) + Expect(usbDevice.Status.Attributes.VendorID).To(Equal("1234")) + }) + }) + + Context("when namespace is not assigned", func() { + It("should delete USBDevice and set Available condition", func() { + existingUSBDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "test-namespace", + }, + } + + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + }, + Spec: v1alpha2.NodeUSBDeviceSpec{ + AssignedNamespace: "", // No namespace assigned + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(nodeUSBDevice, existingUSBDevice).Build() + + nodeUSBDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: nodeUSBDevice.Name}, + fakeClient, + func() *v1alpha2.NodeUSBDevice { return &v1alpha2.NodeUSBDevice{} }, + func(obj *v1alpha2.NodeUSBDevice) v1alpha2.NodeUSBDeviceStatus { return obj.Status }, + ) + Expect(nodeUSBDeviceResource.Fetch(ctx)).To(Succeed()) + + nodeUSBDeviceState = state.New(fakeClient, nodeUSBDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{} + handler = NewAssignedHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, nodeUSBDeviceState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify USBDevice was deleted + usbDevice := &v1alpha2.USBDevice{} + err = fakeClient.Get(ctx, types.NamespacedName{Name: "usb-device-1", Namespace: "test-namespace"}, usbDevice) + Expect(err).To(HaveOccurred()) + + // Verify Available condition was set + conditions := nodeUSBDeviceResource.Changed().Status.Conditions + Expect(conditions).To(HaveLen(1)) + Expect(conditions[0].Type).To(Equal(string(nodeusbdevicecondition.AssignedType))) + Expect(conditions[0].Status).To(Equal(metav1.ConditionFalse)) + Expect(conditions[0].Reason).To(Equal(string(nodeusbdevicecondition.Available))) + }) + }) + + Context("when assigned namespace does not exist", func() { + It("should set Available condition", func() { + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + }, + Spec: v1alpha2.NodeUSBDeviceSpec{ + AssignedNamespace: "non-existent-namespace", + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(nodeUSBDevice).Build() + + nodeUSBDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: nodeUSBDevice.Name}, + fakeClient, + func() *v1alpha2.NodeUSBDevice { return &v1alpha2.NodeUSBDevice{} }, + func(obj *v1alpha2.NodeUSBDevice) v1alpha2.NodeUSBDeviceStatus { return obj.Status }, + ) + Expect(nodeUSBDeviceResource.Fetch(ctx)).To(Succeed()) + + nodeUSBDeviceState = state.New(fakeClient, nodeUSBDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{} + handler = NewAssignedHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, nodeUSBDeviceState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify Available condition was set + conditions := nodeUSBDeviceResource.Changed().Status.Conditions + Expect(conditions).To(HaveLen(1)) + Expect(conditions[0].Type).To(Equal(string(nodeusbdevicecondition.AssignedType))) + Expect(conditions[0].Status).To(Equal(metav1.ConditionFalse)) + Expect(conditions[0].Reason).To(Equal(string(nodeusbdevicecondition.Available))) + }) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion_test.go new file mode 100644 index 0000000000..f75e32014a --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion_test.go @@ -0,0 +1,173 @@ +/* +Copyright 2025 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 internal + +import ( + "context" + "log/slog" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apiruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +var _ = Describe("DeletionHandler", func() { + var ctx context.Context + var fakeClient client.WithWatch + var handler *DeletionHandler + var nodeUSBDeviceState state.NodeUSBDeviceState + var nodeUSBDeviceResource *reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus] + + BeforeEach(func() { + ctx = logger.ToContext(context.TODO(), slog.Default()) + }) + + Context("when NodeUSBDevice is not being deleted", func() { + It("should add finalizer", func() { + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(nodeUSBDevice).Build() + + nodeUSBDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: nodeUSBDevice.Name}, + fakeClient, + func() *v1alpha2.NodeUSBDevice { return &v1alpha2.NodeUSBDevice{} }, + func(obj *v1alpha2.NodeUSBDevice) v1alpha2.NodeUSBDeviceStatus { return obj.Status }, + ) + Expect(nodeUSBDeviceResource.Fetch(ctx)).To(Succeed()) + + nodeUSBDeviceState = state.New(fakeClient, nodeUSBDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{} + handler = NewDeletionHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, nodeUSBDeviceState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify finalizer was added + Expect(nodeUSBDeviceResource.Changed().GetFinalizers()).To(ContainElement(v1alpha2.FinalizerNodeUSBDeviceCleanup)) + }) + }) + + Context("when NodeUSBDevice is being deleted", func() { + It("should delete USBDevice and remove finalizer", func() { + now := metav1.Now() + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "test-namespace", + }, + } + + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Finalizers: []string{v1alpha2.FinalizerNodeUSBDeviceCleanup}, + DeletionTimestamp: &now, + }, + Spec: v1alpha2.NodeUSBDeviceSpec{ + AssignedNamespace: "test-namespace", + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(nodeUSBDevice, usbDevice).Build() + + nodeUSBDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: nodeUSBDevice.Name}, + fakeClient, + func() *v1alpha2.NodeUSBDevice { return &v1alpha2.NodeUSBDevice{} }, + func(obj *v1alpha2.NodeUSBDevice) v1alpha2.NodeUSBDeviceStatus { return obj.Status }, + ) + Expect(nodeUSBDeviceResource.Fetch(ctx)).To(Succeed()) + + nodeUSBDeviceState = state.New(fakeClient, nodeUSBDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{} + handler = NewDeletionHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, nodeUSBDeviceState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify USBDevice was deleted + deletedUSBDevice := &v1alpha2.USBDevice{} + err = fakeClient.Get(ctx, types.NamespacedName{Name: "usb-device-1", Namespace: "test-namespace"}, deletedUSBDevice) + Expect(err).To(HaveOccurred()) + + // Verify finalizer was removed + Expect(nodeUSBDeviceResource.Changed().GetFinalizers()).NotTo(ContainElement(v1alpha2.FinalizerNodeUSBDeviceCleanup)) + }) + + It("should remove finalizer when no USBDevice exists", func() { + now := metav1.Now() + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Finalizers: []string{v1alpha2.FinalizerNodeUSBDeviceCleanup}, + DeletionTimestamp: &now, + }, + Spec: v1alpha2.NodeUSBDeviceSpec{ + AssignedNamespace: "test-namespace", + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(nodeUSBDevice).Build() + + nodeUSBDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: nodeUSBDevice.Name}, + fakeClient, + func() *v1alpha2.NodeUSBDevice { return &v1alpha2.NodeUSBDevice{} }, + func(obj *v1alpha2.NodeUSBDevice) v1alpha2.NodeUSBDeviceStatus { return obj.Status }, + ) + Expect(nodeUSBDeviceResource.Fetch(ctx)).To(Succeed()) + + nodeUSBDeviceState = state.New(fakeClient, nodeUSBDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{} + handler = NewDeletionHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, nodeUSBDeviceState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify finalizer was removed + Expect(nodeUSBDeviceResource.Changed().GetFinalizers()).NotTo(ContainElement(v1alpha2.FinalizerNodeUSBDeviceCleanup)) + }) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go index 7387a79b0c..345d11f8ab 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go @@ -224,7 +224,7 @@ func (h *DiscoveryHandler) convertDeviceToAttributes(device resourcev1beta1.Devi } for key, attr := range device.Basic.Attributes { - switch key { + switch string(key) { case "name": if attr.StringValue != nil { attrs.Name = *attr.StringValue diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go index 7daee4d338..8e4528a22b 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go @@ -140,7 +140,7 @@ func (h *ReadyHandler) calculateDeviceHash(device resourcev1beta1.Device, nodeNa if device.Basic != nil { for key, attr := range device.Basic.Attributes { - switch key { + switch string(key) { case "vendorID": if attr.StringValue != nil { vendorID = *attr.StringValue diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready_test.go new file mode 100644 index 0000000000..08e14e2d4f --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready_test.go @@ -0,0 +1,204 @@ +/* +Copyright 2025 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 internal + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "log/slog" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + resourcev1beta1 "k8s.io/api/resource/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apiruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" +) + +var _ = Describe("ReadyHandler", func() { + var ctx context.Context + var fakeClient client.WithWatch + var handler *ReadyHandler + var nodeUSBDeviceState state.NodeUSBDeviceState + var nodeUSBDeviceResource *reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus] + + BeforeEach(func() { + ctx = logger.ToContext(context.TODO(), slog.Default()) + }) + + Context("when device is found in ResourceSlice", func() { + It("should set Ready condition", func() { + // Create ResourceSlice with device attributes + resourceSlice := &resourcev1beta1.ResourceSlice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "slice-1", + }, + Spec: resourcev1beta1.ResourceSliceSpec{ + Driver: draDriverName, + Pool: resourcev1beta1.ResourcePool{ + Name: "node-1", + }, + Devices: []resourcev1beta1.Device{ + { + Name: "usb-device-1", + Basic: &resourcev1beta1.BasicDevice{ + Attributes: map[resourcev1beta1.QualifiedName]resourcev1beta1.DeviceAttribute{ + resourcev1beta1.QualifiedName("vendorID"): { + StringValue: stringPtr("1234"), + }, + resourcev1beta1.QualifiedName("productID"): { + StringValue: stringPtr("5678"), + }, + resourcev1beta1.QualifiedName("bus"): { + StringValue: stringPtr("1"), + }, + resourcev1beta1.QualifiedName("deviceNumber"): { + StringValue: stringPtr("2"), + }, + }, + }, + }, + }, + }, + } + + // Calculate hash from device attributes to match what the handler expects + // Hash is calculated as: nodeName:vendorID:productID:bus:deviceNumber:serial:devicePath + // Using the same values as in ResourceSlice (serial and devicePath are empty) + hashInput := "node-1:1234:5678:1:2::" + hash := calculateTestHash(hashInput) + + // Verify hash calculation matches handler logic + // The handler will calculate hash from ResourceSlice device attributes + + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + }, + Status: v1alpha2.NodeUSBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{ + Hash: hash, + VendorID: "1234", + ProductID: "5678", + Bus: "1", + DeviceNumber: "2", + NodeName: "node-1", + }, + NodeName: "node-1", + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(resourcev1beta1.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(nodeUSBDevice, resourceSlice).Build() + + nodeUSBDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: nodeUSBDevice.Name}, + fakeClient, + func() *v1alpha2.NodeUSBDevice { return &v1alpha2.NodeUSBDevice{} }, + func(obj *v1alpha2.NodeUSBDevice) v1alpha2.NodeUSBDeviceStatus { return obj.Status }, + ) + Expect(nodeUSBDeviceResource.Fetch(ctx)).To(Succeed()) + + nodeUSBDeviceState = state.New(fakeClient, nodeUSBDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{} + handler = NewReadyHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, nodeUSBDeviceState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify Ready condition was set + conditions := nodeUSBDeviceResource.Changed().Status.Conditions + Expect(conditions).To(HaveLen(1)) + Expect(conditions[0].Type).To(Equal(string(nodeusbdevicecondition.ReadyType))) + Expect(conditions[0].Status).To(Equal(metav1.ConditionTrue)) + Expect(conditions[0].Reason).To(Equal(string(nodeusbdevicecondition.Ready))) + }) + }) + + Context("when device is not found in ResourceSlice", func() { + It("should set NotFound condition", func() { + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + }, + Status: v1alpha2.NodeUSBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{ + Hash: "non-existent-hash", + VendorID: "1234", + NodeName: "node-1", + }, + NodeName: "node-1", + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(resourcev1beta1.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(nodeUSBDevice).Build() + + nodeUSBDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: nodeUSBDevice.Name}, + fakeClient, + func() *v1alpha2.NodeUSBDevice { return &v1alpha2.NodeUSBDevice{} }, + func(obj *v1alpha2.NodeUSBDevice) v1alpha2.NodeUSBDeviceStatus { return obj.Status }, + ) + Expect(nodeUSBDeviceResource.Fetch(ctx)).To(Succeed()) + + nodeUSBDeviceState = state.New(fakeClient, nodeUSBDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{} + handler = NewReadyHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, nodeUSBDeviceState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify NotFound condition was set + conditions := nodeUSBDeviceResource.Changed().Status.Conditions + Expect(conditions).To(HaveLen(1)) + Expect(conditions[0].Type).To(Equal(string(nodeusbdevicecondition.ReadyType))) + Expect(conditions[0].Status).To(Equal(metav1.ConditionFalse)) + Expect(conditions[0].Reason).To(Equal(string(nodeusbdevicecondition.NotFound))) + }) + }) +}) + +func stringPtr(s string) *string { + return &s +} + +func calculateTestHash(input string) string { + // This matches the hash calculation in ready.go:calculateDeviceHash + // Hash is calculated as SHA256 and first 16 characters are used + hash := sha256.Sum256([]byte(input)) + return hex.EncodeToString(hash[:])[:16] +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/suite_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/suite_test.go new file mode 100644 index 0000000000..81809e48da --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/suite_test.go @@ -0,0 +1,29 @@ +/* +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 internal + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestNodeUSBDevice(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "NodeUSBDevice Handlers Suite") +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go new file mode 100644 index 0000000000..9a7e60c096 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go @@ -0,0 +1,180 @@ +/* +Copyright 2025 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 internal + +import ( + "context" + "log/slog" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apiruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/usbdevicecondition" +) + +var _ = Describe("DeletionHandler", func() { + var ctx context.Context + var fakeClient client.WithWatch + var handler *DeletionHandler + var usbDeviceState state.USBDeviceState + var usbDeviceResource *reconciler.Resource[*v1alpha2.USBDevice, v1alpha2.USBDeviceStatus] + + BeforeEach(func() { + ctx = logger.ToContext(context.TODO(), slog.Default()) + }) + + Context("when USBDevice is not being deleted", func() { + It("should add finalizer", func() { + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "default", + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice).Build() + + usbDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + fakeClient, + func() *v1alpha2.USBDevice { return &v1alpha2.USBDevice{} }, + func(obj *v1alpha2.USBDevice) v1alpha2.USBDeviceStatus { return obj.Status }, + ) + Expect(usbDeviceResource.Fetch(ctx)).To(Succeed()) + + usbDeviceState = state.New(fakeClient, usbDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{} + handler = NewDeletionHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, usbDeviceState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify finalizer was added + Expect(usbDeviceResource.Changed().GetFinalizers()).To(ContainElement(v1alpha2.FinalizerUSBDeviceCleanup)) + }) + }) + + Context("when USBDevice is being deleted", func() { + It("should remove finalizer when device is not attached", func() { + now := metav1.Now() + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "default", + Finalizers: []string{v1alpha2.FinalizerUSBDeviceCleanup}, + DeletionTimestamp: &now, + }, + Status: v1alpha2.USBDeviceStatus{ + Conditions: []metav1.Condition{ + { + Type: string(usbdevicecondition.AttachedType), + Status: metav1.ConditionFalse, + Reason: string(usbdevicecondition.Available), + }, + }, + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice).Build() + + usbDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + fakeClient, + func() *v1alpha2.USBDevice { return &v1alpha2.USBDevice{} }, + func(obj *v1alpha2.USBDevice) v1alpha2.USBDeviceStatus { return obj.Status }, + ) + Expect(usbDeviceResource.Fetch(ctx)).To(Succeed()) + + usbDeviceState = state.New(fakeClient, usbDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{} + handler = NewDeletionHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, usbDeviceState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify finalizer was removed + Expect(usbDeviceResource.Changed().GetFinalizers()).NotTo(ContainElement(v1alpha2.FinalizerUSBDeviceCleanup)) + }) + + It("should requeue when device is attached", func() { + now := metav1.Now() + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "default", + Finalizers: []string{v1alpha2.FinalizerUSBDeviceCleanup}, + DeletionTimestamp: &now, + }, + Status: v1alpha2.USBDeviceStatus{ + Conditions: []metav1.Condition{ + { + Type: string(usbdevicecondition.AttachedType), + Status: metav1.ConditionTrue, + Reason: string(usbdevicecondition.AttachedToVirtualMachine), + }, + }, + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice).Build() + + usbDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + fakeClient, + func() *v1alpha2.USBDevice { return &v1alpha2.USBDevice{} }, + func(obj *v1alpha2.USBDevice) v1alpha2.USBDeviceStatus { return obj.Status }, + ) + Expect(usbDeviceResource.Fetch(ctx)).To(Succeed()) + + usbDeviceState = state.New(fakeClient, usbDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{ + EventfFunc: func(involvedObject client.Object, eventtype, reason, messageFmt string, args ...interface{}) {}, + } + handler = NewDeletionHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, usbDeviceState) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("hot unplug required")) + Expect(result.Requeue).To(BeTrue()) + + // Verify finalizer was not removed + Expect(usbDeviceResource.Changed().GetFinalizers()).To(ContainElement(v1alpha2.FinalizerUSBDeviceCleanup)) + }) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go new file mode 100644 index 0000000000..c0eb2d1547 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go @@ -0,0 +1,248 @@ +/* +Copyright 2025 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 internal + +import ( + "context" + "log/slog" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apiruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" + "github.com/deckhouse/virtualization/api/core/v1alpha2/usbdevicecondition" +) + +var _ = Describe("ReadyHandler", func() { + var ctx context.Context + var fakeClient client.WithWatch + var handler *ReadyHandler + var usbDeviceState state.USBDeviceState + var usbDeviceResource *reconciler.Resource[*v1alpha2.USBDevice, v1alpha2.USBDeviceStatus] + + BeforeEach(func() { + ctx = logger.ToContext(context.TODO(), slog.Default()) + }) + + Context("when NodeUSBDevice is found", func() { + It("should translate Ready condition from NodeUSBDevice when Ready", func() { + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "default", + }, + } + + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + }, + Status: v1alpha2.NodeUSBDeviceStatus{ + Conditions: []metav1.Condition{ + { + Type: string(nodeusbdevicecondition.ReadyType), + Status: metav1.ConditionTrue, + Reason: string(nodeusbdevicecondition.Ready), + Message: "Device is ready", + LastTransitionTime: metav1.Now(), + }, + }, + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice, nodeUSBDevice).Build() + + usbDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + fakeClient, + func() *v1alpha2.USBDevice { return &v1alpha2.USBDevice{} }, + func(obj *v1alpha2.USBDevice) v1alpha2.USBDeviceStatus { return obj.Status }, + ) + Expect(usbDeviceResource.Fetch(ctx)).To(Succeed()) + + usbDeviceState = state.New(fakeClient, usbDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{} + handler = NewReadyHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, usbDeviceState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify Ready condition was set + conditions := usbDeviceResource.Changed().Status.Conditions + Expect(conditions).To(HaveLen(1)) + Expect(conditions[0].Type).To(Equal(string(usbdevicecondition.ReadyType))) + Expect(conditions[0].Status).To(Equal(metav1.ConditionTrue)) + Expect(conditions[0].Reason).To(Equal(string(usbdevicecondition.Ready))) + }) + + It("should translate NotReady condition from NodeUSBDevice", func() { + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "default", + }, + } + + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + }, + Status: v1alpha2.NodeUSBDeviceStatus{ + Conditions: []metav1.Condition{ + { + Type: string(nodeusbdevicecondition.ReadyType), + Status: metav1.ConditionFalse, + Reason: string(nodeusbdevicecondition.NotReady), + Message: "Device is not ready", + LastTransitionTime: metav1.Now(), + }, + }, + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice, nodeUSBDevice).Build() + + usbDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + fakeClient, + func() *v1alpha2.USBDevice { return &v1alpha2.USBDevice{} }, + func(obj *v1alpha2.USBDevice) v1alpha2.USBDeviceStatus { return obj.Status }, + ) + Expect(usbDeviceResource.Fetch(ctx)).To(Succeed()) + + usbDeviceState = state.New(fakeClient, usbDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{} + handler = NewReadyHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, usbDeviceState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify NotReady condition was set + conditions := usbDeviceResource.Changed().Status.Conditions + Expect(conditions).To(HaveLen(1)) + Expect(conditions[0].Type).To(Equal(string(usbdevicecondition.ReadyType))) + Expect(conditions[0].Status).To(Equal(metav1.ConditionFalse)) + Expect(conditions[0].Reason).To(Equal(string(usbdevicecondition.NotReady))) + }) + }) + + Context("when NodeUSBDevice is not found", func() { + It("should set NotFound condition", func() { + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "default", + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice).Build() + + usbDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + fakeClient, + func() *v1alpha2.USBDevice { return &v1alpha2.USBDevice{} }, + func(obj *v1alpha2.USBDevice) v1alpha2.USBDeviceStatus { return obj.Status }, + ) + Expect(usbDeviceResource.Fetch(ctx)).To(Succeed()) + + usbDeviceState = state.New(fakeClient, usbDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{} + handler = NewReadyHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, usbDeviceState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify NotFound condition was set + conditions := usbDeviceResource.Changed().Status.Conditions + Expect(conditions).To(HaveLen(1)) + Expect(conditions[0].Type).To(Equal(string(usbdevicecondition.ReadyType))) + Expect(conditions[0].Status).To(Equal(metav1.ConditionFalse)) + Expect(conditions[0].Reason).To(Equal(string(usbdevicecondition.NotFound))) + }) + }) + + Context("when NodeUSBDevice has no Ready condition", func() { + It("should set NotReady condition", func() { + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "default", + }, + } + + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + }, + Status: v1alpha2.NodeUSBDeviceStatus{ + Conditions: []metav1.Condition{}, // No Ready condition + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice, nodeUSBDevice).Build() + + usbDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + fakeClient, + func() *v1alpha2.USBDevice { return &v1alpha2.USBDevice{} }, + func(obj *v1alpha2.USBDevice) v1alpha2.USBDeviceStatus { return obj.Status }, + ) + Expect(usbDeviceResource.Fetch(ctx)).To(Succeed()) + + usbDeviceState = state.New(fakeClient, usbDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{} + handler = NewReadyHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, usbDeviceState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify NotReady condition was set + conditions := usbDeviceResource.Changed().Status.Conditions + Expect(conditions).To(HaveLen(1)) + Expect(conditions[0].Type).To(Equal(string(usbdevicecondition.ReadyType))) + Expect(conditions[0].Status).To(Equal(metav1.ConditionFalse)) + Expect(conditions[0].Reason).To(Equal(string(usbdevicecondition.NotReady))) + }) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/suite_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/suite_test.go new file mode 100644 index 0000000000..62e2bd1da5 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/suite_test.go @@ -0,0 +1,29 @@ +/* +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 internal + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestUSBDevice(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "USBDevice Handlers Suite") +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go new file mode 100644 index 0000000000..3ecfe570c4 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go @@ -0,0 +1,149 @@ +/* +Copyright 2025 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 internal + +import ( + "context" + "log/slog" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apiruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +var _ = Describe("SyncHandler", func() { + var ctx context.Context + var fakeClient client.WithWatch + var handler *SyncHandler + var usbDeviceState state.USBDeviceState + var usbDeviceResource *reconciler.Resource[*v1alpha2.USBDevice, v1alpha2.USBDeviceStatus] + + BeforeEach(func() { + ctx = logger.ToContext(context.TODO(), slog.Default()) + }) + + Context("when NodeUSBDevice is found", func() { + It("should sync attributes and node name from NodeUSBDevice", func() { + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "default", + }, + Status: v1alpha2.USBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{ + VendorID: "0000", + ProductID: "0000", + }, + NodeName: "", + }, + } + + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + }, + Status: v1alpha2.NodeUSBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{ + VendorID: "1234", + ProductID: "5678", + }, + NodeName: "node-1", + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice, nodeUSBDevice).Build() + + usbDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + fakeClient, + func() *v1alpha2.USBDevice { return &v1alpha2.USBDevice{} }, + func(obj *v1alpha2.USBDevice) v1alpha2.USBDeviceStatus { return obj.Status }, + ) + Expect(usbDeviceResource.Fetch(ctx)).To(Succeed()) + + usbDeviceState = state.New(fakeClient, usbDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{} + handler = NewSyncHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, usbDeviceState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify attributes were synced + changed := usbDeviceResource.Changed() + Expect(changed.Status.Attributes.VendorID).To(Equal("1234")) + Expect(changed.Status.Attributes.ProductID).To(Equal("5678")) + Expect(changed.Status.NodeName).To(Equal("node-1")) + }) + }) + + Context("when NodeUSBDevice is not found", func() { + It("should not update status", func() { + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "default", + }, + Status: v1alpha2.USBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{ + VendorID: "0000", + ProductID: "0000", + }, + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice).Build() + + usbDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + fakeClient, + func() *v1alpha2.USBDevice { return &v1alpha2.USBDevice{} }, + func(obj *v1alpha2.USBDevice) v1alpha2.USBDeviceStatus { return obj.Status }, + ) + Expect(usbDeviceResource.Fetch(ctx)).To(Succeed()) + + usbDeviceState = state.New(fakeClient, usbDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{} + handler = NewSyncHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, usbDeviceState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify status was not changed + changed := usbDeviceResource.Changed() + Expect(changed.Status.Attributes.VendorID).To(Equal("0000")) + }) + }) +}) From f0c2ed5495e71fd98e64bbc7a1fdace89a2139c7 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Mon, 26 Jan 2026 16:29:13 +0200 Subject: [PATCH 10/53] add doc ru Signed-off-by: Daniil Antoshin --- crds/doc-ru-nodeusbdevices.yaml | 167 ++++++++++++++++++++++++++++++++ crds/doc-ru-usbdevices.yaml | 146 ++++++++++++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 crds/doc-ru-nodeusbdevices.yaml create mode 100644 crds/doc-ru-usbdevices.yaml diff --git a/crds/doc-ru-nodeusbdevices.yaml b/crds/doc-ru-nodeusbdevices.yaml new file mode 100644 index 0000000000..12920cb4ca --- /dev/null +++ b/crds/doc-ru-nodeusbdevices.yaml @@ -0,0 +1,167 @@ +spec: + versions: + - name: v1alpha2 + schema: + openAPIV3Schema: + description: | + NodeUSBDevice представляет USB-устройство, обнаруженное на конкретном узле в кластере. + Этот ресурс создаётся автоматически системой DRA (Dynamic Resource Allocation), + когда USB-устройство обнаруживается на узле. + properties: + apiVersion: + description: |- + APIVersion определяет версионированную схему этого представления объекта. + Серверы должны преобразовывать распознанные схемы в последнее внутреннее значение и + могут отклонять нераспознанные значения. + Подробнее: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind — это строковое значение, представляющее REST-ресурс, который представляет этот объект. + Серверы могут выводить это из конечной точки, на которую клиент отправляет запросы. + Не может быть обновлено. + В формате CamelCase. + Подробнее: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + assignedNamespace: + default: "" + description: |- + Пространство имён, в котором разрешено использование устройства. По умолчанию создаётся с пустым значением "". + При установке значения создаётся соответствующий ресурс USBDevice в этом пространстве имён. + type: string + type: object + status: + properties: + attributes: + description: Все атрибуты устройства, полученные через DRA для устройства. + properties: + bcd: + description: BCD (Binary Coded Decimal) версия устройства. + type: string + bus: + description: Номер USB-шины. + type: string + deviceNumber: + description: Номер USB-устройства на шине. + type: string + devicePath: + description: Путь к устройству в файловой системе. + type: string + hash: + description: |- + Хеш, вычисленный на основе всех основных атрибутов. Необходим для уникального сопоставления + ресурса с ресурсом из среза. + type: string + major: + description: Основной номер устройства. + type: integer + manufacturer: + description: Название производителя устройства. + type: string + minor: + description: Вспомогательный номер устройства. + type: integer + name: + description: Имя устройства. + type: string + nodeName: + description: Имя узла, на котором находится устройство. + type: string + product: + description: Название продукта устройства. + type: string + productID: + description: USB product ID в шестнадцатеричном формате. + type: string + serial: + description: Серийный номер устройства. + type: string + vendorID: + description: USB vendor ID в шестнадцатеричном формате. + type: string + type: object + conditions: + description: Последние доступные наблюдения текущего состояния объекта. + items: + description: |- + Condition содержит подробности об одном аспекте текущего + состояния этого API-ресурса. + properties: + lastTransitionTime: + description: |- + lastTransitionTime — это время последнего перехода условия из одного состояния в другое. + Это должно быть время, когда изменилось базовое условие. Если это неизвестно, то допустимо использовать время, когда изменилось поле API. + format: date-time + type: string + message: + description: |- + message — это удобочитаемое сообщение с подробностями о переходе. + Это может быть пустая строка. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration представляет .metadata.generation, на основе которого было установлено условие. + Например, если .metadata.generation в настоящее время имеет значение 12, а .status.conditions[x].observedGeneration имеет значение 9, то условие устарело + по отношению к текущему состоянию экземпляра. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason содержит программный идентификатор, указывающий причину последнего перехода условия. + Производители конкретных типов условий могут определять ожидаемые значения и значения для этого поля, + и являются ли эти значения гарантированным API. + Значение должно быть строкой в формате CamelCase. + Это поле не может быть пустым. + Для типа условия Ready возможные значения: + * Ready — устройство готово к использованию + * NotReady — устройство существует в системе, но не готово к использованию + * NotFound — устройство отсутствует на хосте + Для типа условия Assigned возможные значения: + * Assigned — пространство имён назначено для устройства и создан соответствующий ресурс USBDevice в этом пространстве имён + * Available — для устройства не назначено пространство имён + * InProgress — подключение устройства к пространству имён выполняется (создание ресурса USBDevice) + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: статус условия, одно из True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: |- + тип условия в формате CamelCase или в формате foo.example.com/CamelCase. + Поддерживаемые типы условий: + * Ready — указывает, готово ли устройство к использованию. Когда reason — "Ready", status — "True". + Когда reason — "NotReady" или "NotFound", status — "False". При переходе в NotFound + ресурс остаётся в кластере, администратор может удалить его вручную. На основе lastTransitionTime + может быть реализован Garbage Collector для автоматической очистки. + * Assigned — указывает, назначено ли пространство имён для устройства. Когда reason — "Assigned", + status — "True". Когда reason — "Available" или "InProgress", status — "False". + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + nodeName: + description: Имя узла, на котором находится USB-устройство. + type: string + type: object + required: + - spec + type: object diff --git a/crds/doc-ru-usbdevices.yaml b/crds/doc-ru-usbdevices.yaml new file mode 100644 index 0000000000..2afe413d53 --- /dev/null +++ b/crds/doc-ru-usbdevices.yaml @@ -0,0 +1,146 @@ +spec: + versions: + - name: v1alpha2 + schema: + openAPIV3Schema: + description: | + USBDevice представляет USB-устройство, доступное для подключения к + виртуальным машинам в заданном пространстве имён. + properties: + apiVersion: + description: |- + APIVersion определяет версионированную схему этого представления объекта. + Серверы должны преобразовывать распознанные схемы в последнее внутреннее значение и + могут отклонять нераспознанные значения. + Подробнее: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind — это строковое значение, представляющее REST-ресурс, который представляет этот объект. + Серверы могут выводить это из конечной точки, на которую клиент отправляет запросы. + Не может быть обновлено. + В формате CamelCase. + Подробнее: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + status: + description: USBDeviceStatus — это наблюдаемое состояние `USBDevice`. + properties: + attributes: + description: Все атрибуты устройства, полученные через DRA для устройства. + properties: + bcd: + description: BCD (Binary Coded Decimal) версия устройства. + type: string + bus: + description: Номер USB-шины. + type: string + deviceNumber: + description: Номер USB-устройства на шине. + type: string + devicePath: + description: Путь к устройству в файловой системе. + type: string + hash: + description: |- + Хеш, вычисленный на основе всех основных атрибутов. Необходим для уникального сопоставления + ресурса с ресурсом из среза. + type: string + major: + description: Основной номер устройства. + type: integer + manufacturer: + description: Название производителя устройства. + type: string + minor: + description: Вспомогательный номер устройства. + type: integer + name: + description: Имя устройства. + type: string + nodeName: + description: Имя узла, на котором находится устройство. + type: string + product: + description: Название продукта устройства. + type: string + productID: + description: USB product ID в шестнадцатеричном формате. + type: string + serial: + description: Серийный номер устройства. + type: string + vendorID: + description: USB vendor ID в шестнадцатеричном формате. + type: string + type: object + conditions: + description: | + Последние доступные наблюдения текущего состояния + объекта. + items: + description: | + Condition содержит подробности об одном аспекте текущего + состояния этого API-ресурса. + properties: + lastTransitionTime: + description: |- + lastTransitionTime — это время последнего перехода условия из одного состояния в другое. + Это должно быть время, когда изменилось базовое условие. Если это неизвестно, то допустимо использовать время, когда изменилось поле API. + format: date-time + type: string + message: + description: |- + message — это удобочитаемое сообщение с подробностями о переходе. + Это может быть пустая строка. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration представляет .metadata.generation, на основе которого было установлено условие. + Например, если .metadata.generation в настоящее время имеет значение 12, а .status.conditions[x].observedGeneration имеет значение 9, то условие устарело + по отношению к текущему состоянию экземпляра. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason содержит программный идентификатор, указывающий причину последнего перехода условия. + Производители конкретных типов условий могут определять ожидаемые значения и значения для этого поля, + и являются ли эти значения гарантированным API. + Значение должно быть строкой в формате CamelCase. + Это поле не может быть пустым. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: статус условия, одно из True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: тип условия в формате CamelCase или в формате foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + nodeName: + description: Имя узла, на котором находится USB-устройство. + type: string + observedGeneration: + description: Поколение ресурса, которое в последний раз обрабатывалось контроллером. + format: int64 + type: integer + type: object + type: object From a9b1520a93336329448d5fc3a54f5dcb431a840a Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Mon, 26 Jan 2026 16:48:52 +0200 Subject: [PATCH 11/53] fix test Signed-off-by: Daniil Antoshin --- .../pkg/controller/nodeusbdevice/internal/assigned.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go index 902967e362..9988e1bc17 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go @@ -158,8 +158,8 @@ func (h *AssignedHandler) ensureUSBDevice(ctx context.Context, nodeUSBDevice *v1 // USBDevice exists - update it usbDevice.Status.Attributes = nodeUSBDevice.Status.Attributes usbDevice.Status.NodeName = nodeUSBDevice.Status.NodeName - if err := h.client.Status().Update(ctx, usbDevice); err != nil { - return nil, fmt.Errorf("failed to update USBDevice status: %w", err) + if err := h.client.Update(ctx, usbDevice); err != nil { + return nil, fmt.Errorf("failed to update USBDevice: %w", err) } return usbDevice, nil } From d2291f88cf80fe8aee1a7394090c1399446f6a7b Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Mon, 26 Jan 2026 17:04:03 +0200 Subject: [PATCH 12/53] fix lint Signed-off-by: Daniil Antoshin --- .../cmd/virtualization-controller/main.go | 4 ++-- .../pkg/controller/indexer/indexer.go | 10 +++++----- .../nodeusbdevice/internal/assigned_test.go | 2 +- .../nodeusbdevice/internal/deletion_test.go | 2 +- .../controller/nodeusbdevice/internal/discovery.go | 12 ++++++------ .../pkg/controller/nodeusbdevice/internal/ready.go | 2 +- .../controller/usbdevice/internal/deletion_test.go | 2 +- .../pkg/controller/usbdevice/internal/state/state.go | 4 ++-- .../pkg/controller/vm/internal/usb_device_handler.go | 5 +++++ .../vm/internal/usb_device_handler_test.go | 1 - 10 files changed, 24 insertions(+), 20 deletions(-) diff --git a/images/virtualization-artifact/cmd/virtualization-controller/main.go b/images/virtualization-artifact/cmd/virtualization-controller/main.go index b86d4734f3..7302614dd3 100644 --- a/images/virtualization-artifact/cmd/virtualization-controller/main.go +++ b/images/virtualization-artifact/cmd/virtualization-controller/main.go @@ -47,6 +47,8 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/controller/livemigration" mc "github.com/deckhouse/virtualization-controller/pkg/controller/moduleconfig" mcapi "github.com/deckhouse/virtualization-controller/pkg/controller/moduleconfig/api" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice" + "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice" "github.com/deckhouse/virtualization-controller/pkg/controller/vd" "github.com/deckhouse/virtualization-controller/pkg/controller/vdsnapshot" "github.com/deckhouse/virtualization-controller/pkg/controller/vi" @@ -57,8 +59,6 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/controller/vmiplease" "github.com/deckhouse/virtualization-controller/pkg/controller/vmmac" "github.com/deckhouse/virtualization-controller/pkg/controller/vmmaclease" - "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice" - "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice" "github.com/deckhouse/virtualization-controller/pkg/controller/vmop" "github.com/deckhouse/virtualization-controller/pkg/controller/vmrestore" "github.com/deckhouse/virtualization-controller/pkg/controller/vmsnapshot" diff --git a/images/virtualization-artifact/pkg/controller/indexer/indexer.go b/images/virtualization-artifact/pkg/controller/indexer/indexer.go index c5efe7ac0d..c802138f48 100644 --- a/images/virtualization-artifact/pkg/controller/indexer/indexer.go +++ b/images/virtualization-artifact/pkg/controller/indexer/indexer.go @@ -31,12 +31,12 @@ const ( ) const ( - IndexFieldVMByClass = "spec.virtualMachineClassName" - IndexFieldVMByVD = "spec.blockDeviceRefs.VirtualDisk" - IndexFieldVMByVI = "spec.blockDeviceRefs.VirtualImage" - IndexFieldVMByCVI = "spec.blockDeviceRefs.ClusterVirtualImage" + IndexFieldVMByClass = "spec.virtualMachineClassName" + IndexFieldVMByVD = "spec.blockDeviceRefs.VirtualDisk" + IndexFieldVMByVI = "spec.blockDeviceRefs.VirtualImage" + IndexFieldVMByCVI = "spec.blockDeviceRefs.ClusterVirtualImage" IndexFieldVMByUSBDevice = "spec.usbDevices.name" - IndexFieldVMByNode = "status.node" + IndexFieldVMByNode = "status.node" IndexFieldVDByVDSnapshot = "vd,spec.DataSource.ObjectRef.Name,.Kind=VirtualDiskSnapshot" IndexFieldVIByVDSnapshot = "vi,spec.DataSource.ObjectRef.Name,.Kind=VirtualDiskSnapshot" diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go index c1e5606749..cfdca360be 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go @@ -30,8 +30,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization-controller/pkg/logger" "github.com/deckhouse/virtualization/api/core/v1alpha2" diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion_test.go index f75e32014a..de1a29a7ea 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion_test.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion_test.go @@ -29,8 +29,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization-controller/pkg/logger" "github.com/deckhouse/virtualization/api/core/v1alpha2" diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go index 345d11f8ab..2d35cbaea7 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go @@ -62,7 +62,7 @@ func (h *DiscoveryHandler) Handle(ctx context.Context, s state.NodeUSBDeviceStat // Always check for new devices in ResourceSlice and create NodeUSBDevice if needed // This ensures we discover new devices even if reconcile was triggered for other reasons - if _, err := h.discoverAndCreate(ctx); err != nil { + if err := h.discoverAndCreate(ctx); err != nil { // Log error but don't fail reconciliation // This is a best-effort discovery mechanism log.Error("failed to discover and create NodeUSBDevice", log.Err(err)) @@ -102,16 +102,16 @@ func (h *DiscoveryHandler) Handle(ctx context.Context, s state.NodeUSBDeviceStat return reconcile.Result{}, nil } -func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context) (reconcile.Result, error) { +func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context) error { resourceSlices, err := h.getResourceSlices(ctx) if err != nil { - return reconcile.Result{}, fmt.Errorf("failed to get resource slices: %w", err) + return fmt.Errorf("failed to get resource slices: %w", err) } // Get all existing NodeUSBDevices to avoid duplicates var existingDevices v1alpha2.NodeUSBDeviceList if err := h.client.List(ctx, &existingDevices); err != nil { - return reconcile.Result{}, fmt.Errorf("failed to list existing NodeUSBDevices: %w", err) + return fmt.Errorf("failed to list existing NodeUSBDevices: %w", err) } existingHashes := make(map[string]bool) @@ -166,12 +166,12 @@ func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context) (reconcile.Res } if err := h.client.Create(ctx, nodeUSBDevice); err != nil { - return reconcile.Result{}, fmt.Errorf("failed to create NodeUSBDevice: %w", err) + return fmt.Errorf("failed to create NodeUSBDevice: %w", err) } } } - return reconcile.Result{}, nil + return nil } func (h *DiscoveryHandler) getResourceSlices(ctx context.Context) ([]resourcev1beta1.ResourceSlice, error) { diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go index 8e4528a22b..9eaa6534f8 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go @@ -28,10 +28,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" ) const ( diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go index 9a7e60c096..7923a133ec 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go @@ -171,7 +171,7 @@ var _ = Describe("DeletionHandler", func() { result, err := handler.Handle(ctx, usbDeviceState) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("hot unplug required")) - Expect(result.Requeue).To(BeTrue()) + Expect(result.RequeueAfter).To(BeTrue()) // Verify finalizer was not removed Expect(usbDeviceResource.Changed().GetFinalizers()).To(ContainElement(v1alpha2.FinalizerUSBDeviceCleanup)) diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go index 6b452eb19b..d5007d8d2d 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go @@ -32,13 +32,13 @@ type USBDeviceState interface { func New(client client.Client, usbDevice *reconciler.Resource[*v1alpha2.USBDevice, v1alpha2.USBDeviceStatus]) USBDeviceState { return &usbDeviceState{ - client: client, + client: client, usbDevice: usbDevice, } } type usbDeviceState struct { - client client.Client + client client.Client usbDevice *reconciler.Resource[*v1alpha2.USBDevice, v1alpha2.USBDeviceStatus] } diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go index 7b740547f8..c0feb861ec 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go @@ -27,6 +27,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/deckhouse/deckhouse/pkg/log" "github.com/deckhouse/virtualization-controller/pkg/controller/vm/internal/state" "github.com/deckhouse/virtualization-controller/pkg/logger" "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned" @@ -327,6 +328,10 @@ func (h *USBDeviceHandler) getOrAssignUSBAddress( return existingStatus.Address } + if isHotplugged { + log.Info("USB device is hotplugged, no address will be assigned") + } + // Assign new address // Bus is always 0 for main USB controller // Port should be assigned based on available ports diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go index e11a626e39..8fcb0fcc48 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go @@ -397,6 +397,5 @@ var _ = Describe("USBDeviceHandler", func() { Expect(vmResource.Changed().Status.USBDevices).To(HaveLen(1)) Expect(vmResource.Changed().Status.USBDevices[0].Address).To(Equal(existingAddress)) }) - }) }) From bf4da537745d1e0a5e0d9974c2c7e198388c29e8 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Mon, 26 Jan 2026 17:09:58 +0200 Subject: [PATCH 13/53] fix delete Signed-off-by: Daniil Antoshin --- images/virtualization-artifact/pkg/common/patch/patch.go | 2 +- images/virtualization-dra/pkg/patch/patch.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/images/virtualization-artifact/pkg/common/patch/patch.go b/images/virtualization-artifact/pkg/common/patch/patch.go index 573cfcfc2d..130373fcad 100644 --- a/images/virtualization-artifact/pkg/common/patch/patch.go +++ b/images/virtualization-artifact/pkg/common/patch/patch.go @@ -75,7 +75,7 @@ func (jp *JSONPatch) Append(patches ...JSONPatchOperation) { } func (jp *JSONPatch) Delete(op, path string) { - slices.DeleteFunc(jp.operations, func(o JSONPatchOperation) bool { + jp.operations = slices.DeleteFunc(jp.operations, func(o JSONPatchOperation) bool { return o.Op == op && o.Path == path }) } diff --git a/images/virtualization-dra/pkg/patch/patch.go b/images/virtualization-dra/pkg/patch/patch.go index eabe069522..8626564a5b 100644 --- a/images/virtualization-dra/pkg/patch/patch.go +++ b/images/virtualization-dra/pkg/patch/patch.go @@ -79,7 +79,7 @@ func (jp *JSONPatch) Append(patches ...JSONPatchOperation) { } func (jp *JSONPatch) Delete(op, path string) { - slices.DeleteFunc(jp.operations, func(o JSONPatchOperation) bool { + jp.operations = slices.DeleteFunc(jp.operations, func(o JSONPatchOperation) bool { return o.Op == op && o.Path == path }) } From 410ae1e67f2b550c688954c176b748ab05614d57 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Mon, 26 Jan 2026 17:29:05 +0200 Subject: [PATCH 14/53] fix Signed-off-by: Daniil Antoshin --- .../pkg/controller/usbdevice/internal/deletion_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go index 7923a133ec..9a7e60c096 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go @@ -171,7 +171,7 @@ var _ = Describe("DeletionHandler", func() { result, err := handler.Handle(ctx, usbDeviceState) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("hot unplug required")) - Expect(result.RequeueAfter).To(BeTrue()) + Expect(result.Requeue).To(BeTrue()) // Verify finalizer was not removed Expect(usbDeviceResource.Changed().GetFinalizers()).To(ContainElement(v1alpha2.FinalizerUSBDeviceCleanup)) From 64d1d9437ce8ca1b6615605998c9439dcf2b03fd Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Mon, 26 Jan 2026 17:40:16 +0200 Subject: [PATCH 15/53] update copyright Signed-off-by: Daniil Antoshin --- api/core/v1alpha2/node_device_usb.go | 2 +- api/core/v1alpha2/nodeusbdevicecondition/condition.go | 2 +- api/core/v1alpha2/usb_device.go | 2 +- api/core/v1alpha2/usbdevicecondition/condition.go | 2 +- .../pkg/controller/nodeusbdevice/internal/assigned.go | 2 +- .../pkg/controller/nodeusbdevice/internal/assigned_test.go | 2 +- .../pkg/controller/nodeusbdevice/internal/deletion.go | 2 +- .../pkg/controller/nodeusbdevice/internal/deletion_test.go | 2 +- .../pkg/controller/nodeusbdevice/internal/discovery.go | 2 +- .../pkg/controller/nodeusbdevice/internal/ready.go | 2 +- .../pkg/controller/nodeusbdevice/internal/ready_test.go | 2 +- .../pkg/controller/nodeusbdevice/internal/state/state.go | 2 +- .../nodeusbdevice/internal/watcher/resourceslice_watcher.go | 2 +- .../pkg/controller/nodeusbdevice/nodeusbdevice_controller.go | 2 +- .../pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go | 2 +- .../pkg/controller/usbdevice/internal/attached.go | 2 +- .../pkg/controller/usbdevice/internal/deletion.go | 2 +- .../pkg/controller/usbdevice/internal/deletion_test.go | 2 +- .../pkg/controller/usbdevice/internal/ready.go | 2 +- .../pkg/controller/usbdevice/internal/ready_test.go | 2 +- .../pkg/controller/usbdevice/internal/state/state.go | 2 +- .../pkg/controller/usbdevice/internal/sync.go | 2 +- .../pkg/controller/usbdevice/internal/sync_test.go | 2 +- .../usbdevice/internal/watcher/nodeusbdevice_watcher.go | 2 +- .../pkg/controller/usbdevice/usbdevice_controller.go | 2 +- .../pkg/controller/usbdevice/usbdevice_reconciler.go | 2 +- .../pkg/controller/vm/internal/usb_device_handler.go | 2 +- .../pkg/controller/vm/internal/usb_device_handler_test.go | 2 +- .../pkg/controller/vm/internal/watcher/usbdevice_watcher.go | 2 +- 29 files changed, 29 insertions(+), 29 deletions(-) diff --git a/api/core/v1alpha2/node_device_usb.go b/api/core/v1alpha2/node_device_usb.go index 4337ae7ef8..57003a8731 100644 --- a/api/core/v1alpha2/node_device_usb.go +++ b/api/core/v1alpha2/node_device_usb.go @@ -1,5 +1,5 @@ /* -Copyright 2024 Flant JSC +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. diff --git a/api/core/v1alpha2/nodeusbdevicecondition/condition.go b/api/core/v1alpha2/nodeusbdevicecondition/condition.go index ebe3140557..35813d4520 100644 --- a/api/core/v1alpha2/nodeusbdevicecondition/condition.go +++ b/api/core/v1alpha2/nodeusbdevicecondition/condition.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/api/core/v1alpha2/usb_device.go b/api/core/v1alpha2/usb_device.go index af8dc7ffd7..dc76f3d32e 100644 --- a/api/core/v1alpha2/usb_device.go +++ b/api/core/v1alpha2/usb_device.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/api/core/v1alpha2/usbdevicecondition/condition.go b/api/core/v1alpha2/usbdevicecondition/condition.go index a973365130..c00a29975a 100644 --- a/api/core/v1alpha2/usbdevicecondition/condition.go +++ b/api/core/v1alpha2/usbdevicecondition/condition.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go index 9988e1bc17..d5fffc191b 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go index cfdca360be..a9ca9a644d 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go index 842620ce6d..0f21737d68 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion_test.go index de1a29a7ea..4887d71cb8 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion_test.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion_test.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go index 2d35cbaea7..7a61f2d388 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go index 9eaa6534f8..35e2a01be7 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready_test.go index 08e14e2d4f..a493bda47c 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready_test.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready_test.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go index 44677564dd..e6e28dbda7 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go index ea7b7a8f0a..013c78dc49 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go index 328ced4392..517fc7d514 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go index e7ce4091ee..fd4eef8288 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/attached.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/attached.go index 5e769919d7..e952b9fa56 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/attached.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/attached.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go index 0acb98cbd7..198172c143 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go index 9a7e60c096..fc3fe15e7f 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready.go index 293b850ad6..452ec5cb95 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go index c0eb2d1547..1742365e9e 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go index d5007d8d2d..698de2115e 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync.go index ba4fd90ed8..4c0888966f 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go index 3ecfe570c4..d57750bcf7 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/nodeusbdevice_watcher.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/nodeusbdevice_watcher.go index 38d24971f0..e2601534cc 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/nodeusbdevice_watcher.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/nodeusbdevice_watcher.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go index c4b5fa61ef..3473815e18 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_reconciler.go b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_reconciler.go index 731f43cbfe..79fb92a937 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_reconciler.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_reconciler.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go index c0feb861ec..9a29ab2f45 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go index 8fcb0fcc48..5437d5d24c 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/watcher/usbdevice_watcher.go b/images/virtualization-artifact/pkg/controller/vm/internal/watcher/usbdevice_watcher.go index dc4302c7fa..9e1d11fca5 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/watcher/usbdevice_watcher.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/watcher/usbdevice_watcher.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Flant JSC +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. From fedc114833ef1447d911b1f01f75b2d88f6d30fd Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Tue, 27 Jan 2026 06:56:14 +0200 Subject: [PATCH 16/53] fix TODO Signed-off-by: Daniil Antoshin --- .../nodeusbdevice/internal/discovery.go | 27 +++---------- .../nodeusbdevice/internal/ready.go | 23 +---------- .../nodeusbdevice/internal/ready_test.go | 4 +- .../nodeusbdevice/internal/state/state.go | 21 ++++++++-- .../internal/watcher/resourceslice_watcher.go | 40 ++++++++++++++++--- .../nodeusbdevice/nodeusbdevice_controller.go | 2 +- 6 files changed, 63 insertions(+), 54 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go index 7a61f2d388..8605b62251 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go @@ -38,7 +38,6 @@ import ( const ( nameDiscoveryHandler = "DiscoveryHandler" - draDriverName = "virtualization-dra" ) func NewDiscoveryHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *DiscoveryHandler { @@ -62,7 +61,7 @@ func (h *DiscoveryHandler) Handle(ctx context.Context, s state.NodeUSBDeviceStat // Always check for new devices in ResourceSlice and create NodeUSBDevice if needed // This ensures we discover new devices even if reconcile was triggered for other reasons - if err := h.discoverAndCreate(ctx); err != nil { + if err := h.discoverAndCreate(ctx, s); err != nil { // Log error but don't fail reconciliation // This is a best-effort discovery mechanism log.Error("failed to discover and create NodeUSBDevice", log.Err(err)) @@ -77,7 +76,7 @@ func (h *DiscoveryHandler) Handle(ctx context.Context, s state.NodeUSBDeviceStat changed := nodeUSBDevice.Changed() // Update attributes from ResourceSlice if needed - resourceSlices, err := h.getResourceSlices(ctx) + resourceSlices, err := s.ResourceSlices(ctx) if err != nil { return reconcile.Result{}, fmt.Errorf("failed to get resource slices: %w", err) } @@ -102,8 +101,8 @@ func (h *DiscoveryHandler) Handle(ctx context.Context, s state.NodeUSBDeviceStat return reconcile.Result{}, nil } -func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context) error { - resourceSlices, err := h.getResourceSlices(ctx) +func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context, s state.NodeUSBDeviceState) error { + resourceSlices, err := s.ResourceSlices(ctx) if err != nil { return fmt.Errorf("failed to get resource slices: %w", err) } @@ -122,7 +121,7 @@ func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context) error { } // Create NodeUSBDevice for each USB device in ResourceSlices - // Note: resourceSlices are already filtered by draDriverName in getResourceSlices + // Note: resourceSlices are already filtered by draDriverName in state.ResourceSlices for _, slice := range resourceSlices { for _, device := range slice.Spec.Devices { if !strings.HasPrefix(device.Name, "usb-") { @@ -174,22 +173,6 @@ func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context) error { return nil } -func (h *DiscoveryHandler) getResourceSlices(ctx context.Context) ([]resourcev1beta1.ResourceSlice, error) { - var slices resourcev1beta1.ResourceSliceList - if err := h.client.List(ctx, &slices, client.MatchingLabels{}); err != nil { - return nil, err - } - - result := make([]resourcev1beta1.ResourceSlice, 0) - for _, slice := range slices.Items { - if slice.Spec.Driver == draDriverName { - result = append(result, slice) - } - } - - return result, nil -} - func (h *DiscoveryHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice, hash, nodeName string) (v1alpha2.NodeUSBDeviceAttributes, bool) { for _, slice := range slices { if slice.Spec.Pool.Name != nodeName { diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go index 35e2a01be7..5066a3f4ab 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go @@ -25,7 +25,6 @@ import ( resourcev1beta1 "k8s.io/api/resource/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" @@ -38,15 +37,13 @@ const ( nameReadyHandler = "ReadyHandler" ) -func NewReadyHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *ReadyHandler { +func NewReadyHandler(recorder eventrecord.EventRecorderLogger) *ReadyHandler { return &ReadyHandler{ - client: client, recorder: recorder, } } type ReadyHandler struct { - client client.Client recorder eventrecord.EventRecorderLogger } @@ -61,7 +58,7 @@ func (h *ReadyHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) ( changed := nodeUSBDevice.Changed() // Check if device exists in ResourceSlice - resourceSlices, err := h.getResourceSlices(ctx) + resourceSlices, err := s.ResourceSlices(ctx) if err != nil { return reconcile.Result{}, fmt.Errorf("failed to get resource slices: %w", err) } @@ -96,22 +93,6 @@ func (h *ReadyHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) ( return reconcile.Result{}, nil } -func (h *ReadyHandler) getResourceSlices(ctx context.Context) ([]resourcev1beta1.ResourceSlice, error) { - var slices resourcev1beta1.ResourceSliceList - if err := h.client.List(ctx, &slices, client.MatchingLabels{}); err != nil { - return nil, err - } - - result := make([]resourcev1beta1.ResourceSlice, 0) - for _, slice := range slices.Items { - if slice.Spec.Driver == draDriverName { - result = append(result, slice) - } - } - - return result, nil -} - func (h *ReadyHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice, hash, nodeName string) bool { for _, slice := range slices { if slice.Spec.Pool.Name != nodeName { diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready_test.go index a493bda47c..689f9947a3 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready_test.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready_test.go @@ -129,7 +129,7 @@ var _ = Describe("ReadyHandler", func() { nodeUSBDeviceState = state.New(fakeClient, nodeUSBDeviceResource) recorder := &eventrecord.EventRecorderLoggerMock{} - handler = NewReadyHandler(fakeClient, recorder) + handler = NewReadyHandler(recorder) result, err := handler.Handle(ctx, nodeUSBDeviceState) Expect(err).NotTo(HaveOccurred()) @@ -176,7 +176,7 @@ var _ = Describe("ReadyHandler", func() { nodeUSBDeviceState = state.New(fakeClient, nodeUSBDeviceResource) recorder := &eventrecord.EventRecorderLoggerMock{} - handler = NewReadyHandler(fakeClient, recorder) + handler = NewReadyHandler(recorder) result, err := handler.Handle(ctx, nodeUSBDeviceState) Expect(err).NotTo(HaveOccurred()) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go index e6e28dbda7..60f58208f8 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go @@ -18,6 +18,7 @@ package state import ( "context" + "fmt" resourcev1beta1 "k8s.io/api/resource/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -26,6 +27,10 @@ import ( "github.com/deckhouse/virtualization/api/core/v1alpha2" ) +const ( + draDriverName = "virtualization-dra" +) + type NodeUSBDeviceState interface { NodeUSBDevice() *reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus] ResourceSlices(ctx context.Context) ([]resourcev1beta1.ResourceSlice, error) @@ -48,7 +53,17 @@ func (s *nodeUSBDeviceState) NodeUSBDevice() *reconciler.Resource[*v1alpha2.Node } func (s *nodeUSBDeviceState) ResourceSlices(ctx context.Context) ([]resourcev1beta1.ResourceSlice, error) { - // TODO: implement ResourceSlice fetching - // This should fetch ResourceSlice resources that contain USB device information - return nil, nil + var slices resourcev1beta1.ResourceSliceList + if err := s.client.List(ctx, &slices, client.MatchingLabels{}); err != nil { + return nil, fmt.Errorf("failed to list ResourceSlices: %w", err) + } + + result := make([]resourcev1beta1.ResourceSlice, 0) + for _, slice := range slices.Items { + if slice.Spec.Driver == draDriverName { + result = append(result, slice) + } + } + + return result, nil } diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go index 013c78dc49..ab0d80ab4d 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go @@ -18,6 +18,7 @@ package watcher import ( "context" + "strings" resourcev1beta1 "k8s.io/api/resource/v1beta1" "sigs.k8s.io/controller-runtime/pkg/controller" @@ -26,6 +27,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" "sigs.k8s.io/controller-runtime/pkg/source" + "github.com/deckhouse/deckhouse/pkg/log" "github.com/deckhouse/virtualization-controller/pkg/common/object" "github.com/deckhouse/virtualization/api/core/v1alpha2" ) @@ -50,27 +52,55 @@ func (w *ResourceSliceWatcher) Watch(mgr manager.Manager, ctr controller.Control return nil } + // Check if ResourceSlice contains USB devices + hasUSBDevices := false + for _, device := range slice.Spec.Devices { + if strings.HasPrefix(device.Name, "usb-") { + hasUSBDevices = true + break + } + } + + // If no USB devices in this ResourceSlice, skip reconciliation + if !hasUSBDevices { + return nil + } + var result []reconcile.Request + client := mgr.GetClient() // Enqueue all existing NodeUSBDevices for reconciliation deviceList := &v1alpha2.NodeUSBDeviceList{} - if err := mgr.GetClient().List(ctx, deviceList); err != nil { + if err := client.List(ctx, deviceList); err != nil { + log.Error("failed to list NodeUSBDevices in ResourceSliceWatcher", log.Err(err)) return nil } + hasDevicesOnNode := false for _, device := range deviceList.Items { // Only enqueue devices from the same node as the ResourceSlice if device.Status.NodeName == slice.Spec.Pool.Name { + hasDevicesOnNode = true result = append(result, reconcile.Request{ NamespacedName: object.NamespacedName(&device), }) } } - // Also trigger discovery to create new NodeUSBDevices - // This is done by enqueueing a special request that will trigger discovery - // For now, we'll rely on periodic reconciliation or manual creation - // TODO: Implement automatic creation of NodeUSBDevice from ResourceSlice + // If no devices exist on this node yet, trigger discovery by enqueueing + // any existing NodeUSBDevice to trigger a reconciliation cycle. + // DiscoveryHandler will check all ResourceSlices during reconciliation + // and automatically create new NodeUSBDevice for devices found in this ResourceSlice. + if !hasDevicesOnNode && len(deviceList.Items) > 0 { + // Enqueue first device to trigger reconciliation cycle + // DiscoveryHandler will discover new devices during this cycle + result = append(result, reconcile.Request{ + NamespacedName: object.NamespacedName(&deviceList.Items[0]), + }) + } + + // Note: If no NodeUSBDevices exist at all, discovery will happen + // on next periodic reconciliation or when controller starts return result }), diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go index 517fc7d514..29be9f9acd 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go @@ -44,7 +44,7 @@ func NewController( handlers := []Handler{ internal.NewDeletionHandler(client, recorder), - internal.NewReadyHandler(client, recorder), + internal.NewReadyHandler(recorder), internal.NewAssignedHandler(client, recorder), internal.NewDiscoveryHandler(client, recorder), } From abe05a170df4fe357f2c7b270caa510469659726 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Tue, 27 Jan 2026 07:10:38 +0200 Subject: [PATCH 17/53] fix TODO Signed-off-by: Daniil Antoshin --- .../controller/usbdevice/internal/attached.go | 29 +++++++--- .../controller/usbdevice/internal/deletion.go | 54 +++++++++++-------- .../usbdevice/internal/deletion_test.go | 34 +++++++++--- .../usbdevice/internal/state/state.go | 33 ++++++++++++ .../usbdevice/usbdevice_controller.go | 8 ++- .../vm/internal/usb_device_handler.go | 44 ++++++++++++--- 6 files changed, 156 insertions(+), 46 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/attached.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/attached.go index e952b9fa56..b01593d76c 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/attached.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/attached.go @@ -18,6 +18,7 @@ package internal import ( "context" + "fmt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -55,19 +56,31 @@ func (h *AttachedHandler) Handle(ctx context.Context, s state.USBDeviceState) (r current := usbDevice.Current() changed := usbDevice.Changed() - // TODO: Check if device is attached to a VM - // For now, we'll mark it as Available - // This should be implemented by checking VirtualMachine resources that reference this USBDevice + // Check if device is attached to a VM + vms, err := s.VirtualMachinesUsingDevice(ctx) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to find VirtualMachines using USBDevice: %w", err) + } var reason usbdevicecondition.AttachedReason var status metav1.ConditionStatus var message string - // TODO: Implement actual attachment check - // For now, default to Available - reason = usbdevicecondition.Available - status = metav1.ConditionFalse - message = "Device is available for attachment to a virtual machine" + if len(vms) > 0 { + // Device is attached to at least one VM + reason = usbdevicecondition.AttachedToVirtualMachine + status = metav1.ConditionTrue + if len(vms) == 1 { + message = fmt.Sprintf("Device is attached to VirtualMachine %s/%s", vms[0].Namespace, vms[0].Name) + } else { + message = fmt.Sprintf("Device is attached to %d VirtualMachines", len(vms)) + } + } else { + // Device is available for attachment + reason = usbdevicecondition.Available + status = metav1.ConditionFalse + message = "Device is available for attachment to a virtual machine" + } cb := conditions.NewConditionBuilder(usbdevicecondition.AttachedType). Generation(current.GetGeneration()). diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go index 198172c143..70322832fd 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go @@ -26,24 +26,28 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned" "github.com/deckhouse/virtualization/api/core/v1alpha2" "github.com/deckhouse/virtualization/api/core/v1alpha2/usbdevicecondition" + subv1alpha2 "github.com/deckhouse/virtualization/api/subresources/v1alpha2" ) const ( nameDeletionHandler = "DeletionHandler" ) -func NewDeletionHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *DeletionHandler { +func NewDeletionHandler(client client.Client, virtClient versioned.Interface, recorder eventrecord.EventRecorderLogger) *DeletionHandler { return &DeletionHandler{ - client: client, - recorder: recorder, + client: client, + virtClient: virtClient, + recorder: recorder, } } type DeletionHandler struct { - client client.Client - recorder eventrecord.EventRecorderLogger + client client.Client + virtClient versioned.Interface + recorder eventrecord.EventRecorderLogger } func (h *DeletionHandler) Handle(ctx context.Context, s state.USBDeviceState) (reconcile.Result, error) { @@ -62,25 +66,31 @@ func (h *DeletionHandler) Handle(ctx context.Context, s state.USBDeviceState) (r return reconcile.Result{}, nil } - // Check if device is attached to a VM - // TODO: Implement hot unplug before deletion - // For now, we just check the Attached condition - attached := false - for _, condition := range current.Status.Conditions { - if condition.Type == string(usbdevicecondition.AttachedType) { - if condition.Status == "True" && condition.Reason == string(usbdevicecondition.AttachedToVirtualMachine) { - attached = true - break - } - } + // Check if device is attached to a VM and perform hot unplug if needed + vms, err := s.VirtualMachinesUsingDevice(ctx) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to find VirtualMachines using USBDevice: %w", err) } - if attached { - // TODO: Implement hot unplug logic here - // For now, we'll just log and continue - h.recorder.Eventf(changed, "Normal", "Deletion", "Device is attached to VM, hot unplug will be performed") - // Return to retry after hot unplug - return reconcile.Result{Requeue: true}, fmt.Errorf("device is attached to VM, hot unplug required") + if len(vms) > 0 { + // Device is attached to one or more VMs - perform hot unplug + h.recorder.Eventf(changed, "Normal", "Deletion", "Device is attached to VM(s), performing hot unplug") + + for _, vm := range vms { + // Remove ResourceClaim from VM + opts := subv1alpha2.VirtualMachineRemoveResourceClaim{ + Name: current.Name, + } + if err := h.virtClient.VirtualizationV1alpha2().VirtualMachines(vm.Namespace).RemoveResourceClaim(ctx, vm.Name, opts); err != nil { + h.recorder.Eventf(changed, "Warning", "Deletion", "Failed to remove ResourceClaim from VM %s/%s: %v", vm.Namespace, vm.Name, err) + // Continue with other VMs, but requeue to retry + return reconcile.Result{Requeue: true}, fmt.Errorf("failed to remove ResourceClaim from VM %s/%s: %w", vm.Namespace, vm.Name, err) + } + h.recorder.Eventf(changed, "Normal", "Deletion", "Removed ResourceClaim from VM %s/%s", vm.Namespace, vm.Name) + } + + // Requeue to verify that device is no longer attached + return reconcile.Result{Requeue: true}, nil } // Remove finalizer diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go index fc3fe15e7f..a28953a689 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go @@ -33,6 +33,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization-controller/pkg/logger" + fakeversioned "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/fake" "github.com/deckhouse/virtualization/api/core/v1alpha2" "github.com/deckhouse/virtualization/api/core/v1alpha2/usbdevicecondition" ) @@ -72,7 +73,8 @@ var _ = Describe("DeletionHandler", func() { usbDeviceState = state.New(fakeClient, usbDeviceResource) recorder := &eventrecord.EventRecorderLoggerMock{} - handler = NewDeletionHandler(fakeClient, recorder) + fakeVirtClient := fakeversioned.NewSimpleClientset() + handler = NewDeletionHandler(fakeClient, fakeVirtClient, recorder) result, err := handler.Handle(ctx, usbDeviceState) Expect(err).NotTo(HaveOccurred()) @@ -119,7 +121,8 @@ var _ = Describe("DeletionHandler", func() { usbDeviceState = state.New(fakeClient, usbDeviceResource) recorder := &eventrecord.EventRecorderLoggerMock{} - handler = NewDeletionHandler(fakeClient, recorder) + fakeVirtClient := fakeversioned.NewSimpleClientset() + handler = NewDeletionHandler(fakeClient, fakeVirtClient, recorder) result, err := handler.Handle(ctx, usbDeviceState) Expect(err).NotTo(HaveOccurred()) @@ -149,10 +152,26 @@ var _ = Describe("DeletionHandler", func() { }, } + // Create a VM that uses this USB device + vm := &v1alpha2.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vm", + Namespace: "default", + }, + Status: v1alpha2.VirtualMachineStatus{ + USBDevices: []v1alpha2.USBDeviceStatusRef{ + { + Name: "usb-device-1", + Attached: true, + }, + }, + }, + } + scheme := apiruntime.NewScheme() Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) - fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice).Build() + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice, vm).Build() usbDeviceResource = reconciler.NewResource( types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, @@ -166,14 +185,15 @@ var _ = Describe("DeletionHandler", func() { recorder := &eventrecord.EventRecorderLoggerMock{ EventfFunc: func(involvedObject client.Object, eventtype, reason, messageFmt string, args ...interface{}) {}, } - handler = NewDeletionHandler(fakeClient, recorder) + fakeVirtClient := fakeversioned.NewSimpleClientset() + handler = NewDeletionHandler(fakeClient, fakeVirtClient, recorder) result, err := handler.Handle(ctx, usbDeviceState) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("hot unplug required")) + // Should requeue to verify device is no longer attached + Expect(err).NotTo(HaveOccurred()) Expect(result.Requeue).To(BeTrue()) - // Verify finalizer was not removed + // Verify finalizer was not removed yet Expect(usbDeviceResource.Changed().GetFinalizers()).To(ContainElement(v1alpha2.FinalizerUSBDeviceCleanup)) }) }) diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go index 698de2115e..b59897d3c2 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go @@ -22,12 +22,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" "github.com/deckhouse/virtualization/api/core/v1alpha2" ) type USBDeviceState interface { USBDevice() *reconciler.Resource[*v1alpha2.USBDevice, v1alpha2.USBDeviceStatus] NodeUSBDevice(ctx context.Context) (*v1alpha2.NodeUSBDevice, error) + VirtualMachinesUsingDevice(ctx context.Context) ([]*v1alpha2.VirtualMachine, error) } func New(client client.Client, usbDevice *reconciler.Resource[*v1alpha2.USBDevice, v1alpha2.USBDeviceStatus]) USBDeviceState { @@ -68,3 +70,34 @@ func (s *usbDeviceState) NodeUSBDevice(ctx context.Context) (*v1alpha2.NodeUSBDe return nil, nil } + +func (s *usbDeviceState) VirtualMachinesUsingDevice(ctx context.Context) ([]*v1alpha2.VirtualMachine, error) { + usbDevice := s.usbDevice.Current() + if usbDevice == nil { + return nil, nil + } + + var vmList v1alpha2.VirtualMachineList + if err := s.client.List(ctx, &vmList, client.MatchingFields{ + indexer.IndexFieldVMByUSBDevice: usbDevice.Name, + }); err != nil { + return nil, err + } + + var result []*v1alpha2.VirtualMachine + for i := range vmList.Items { + vm := &vmList.Items[i] + // Check if VM is in the same namespace as USBDevice + if vm.Namespace == usbDevice.Namespace { + // Verify that device is actually attached in VM status + for _, usbStatus := range vm.Status.USBDevices { + if usbStatus.Name == usbDevice.Name && usbStatus.Attached { + result = append(result, vm) + break + } + } + } + } + + return result, nil +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go index 3473815e18..55b90f0411 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go @@ -28,6 +28,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned" ) const ( @@ -42,8 +43,13 @@ func NewController( recorder := eventrecord.NewEventRecorderLogger(mgr, ControllerName) client := mgr.GetClient() + virtClient, err := versioned.NewForConfig(mgr.GetConfig()) + if err != nil { + return nil, err + } + handlers := []Handler{ - internal.NewDeletionHandler(client, recorder), + internal.NewDeletionHandler(client, virtClient, recorder), internal.NewReadyHandler(client, recorder), internal.NewAttachedHandler(client, recorder), internal.NewSyncHandler(client, recorder), diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go index 9a29ab2f45..6dc9f81ac2 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go @@ -149,7 +149,7 @@ func (h *USBDeviceHandler) Handle(ctx context.Context, s state.VirtualMachineSta isHotplugged := vm.Status.Phase == v1alpha2.MachineRunning // Get or assign USB address - address := h.getOrAssignUSBAddress(existingStatus, isHotplugged) + address := h.getOrAssignUSBAddress(existingStatus, isHotplugged, vm) statusRef := v1alpha2.USBDeviceStatusRef{ Name: usbDeviceRef.Name, @@ -285,8 +285,15 @@ func (h *USBDeviceHandler) isUSBDeviceReady(usbDevice *v1alpha2.USBDevice) bool return false } - // TODO: Check conditions if needed - return true + // Check Ready condition + for _, condition := range usbDevice.Status.Conditions { + if condition.Type == "Ready" { + return condition.Status == "True" + } + } + + // If no Ready condition found, device is not ready + return false } func (h *USBDeviceHandler) attachUSBDevice( @@ -322,6 +329,7 @@ func (h *USBDeviceHandler) detachUSBDevice( func (h *USBDeviceHandler) getOrAssignUSBAddress( existingStatus *v1alpha2.USBDeviceStatusRef, isHotplugged bool, + vm *v1alpha2.VirtualMachine, ) *v1alpha2.USBAddress { // If device was already attached, keep the same address if existingStatus != nil && existingStatus.Address != nil { @@ -329,15 +337,35 @@ func (h *USBDeviceHandler) getOrAssignUSBAddress( } if isHotplugged { - log.Info("USB device is hotplugged, no address will be assigned") + // For hotplugged devices, we don't assign a fixed address + // The address will be assigned dynamically by the hypervisor + return nil } - // Assign new address + // Assign new address for cold-plugged devices // Bus is always 0 for main USB controller // Port should be assigned based on available ports - // For simplicity, we'll use a sequential port number starting from 1 - // In a real implementation, you'd need to track used ports - port := 1 // TODO: Implement proper port allocation + usedPorts := make(map[int]bool) + for _, usbStatus := range vm.Status.USBDevices { + if usbStatus.Address != nil && usbStatus.Address.Bus == 0 { + usedPorts[usbStatus.Address.Port] = true + } + } + + // Find the first available port starting from 1 + // USB ports typically range from 1 to 127, but we'll use a reasonable limit + port := 1 + for port <= 127 { + if !usedPorts[port] { + break + } + port++ + } + + if port > 127 { + // All ports are used, fallback to port 1 (should not happen in practice) + port = 1 + } return &v1alpha2.USBAddress{ Bus: 0, From bfd8571f6f46558feacdfc153f34ceb9586c31d0 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Tue, 27 Jan 2026 07:17:16 +0200 Subject: [PATCH 18/53] add cond Signed-off-by: Daniil Antoshin --- api/core/v1alpha2/node_device_usb.go | 2 +- api/core/v1alpha2/virtual_machine.go | 5 + api/core/v1alpha2/vmcondition/condition.go | 15 +++ crds/nodeusbdevices.yaml | 2 +- .../vm/internal/usb_device_handler.go | 102 ++++++++++++++++-- 5 files changed, 114 insertions(+), 12 deletions(-) diff --git a/api/core/v1alpha2/node_device_usb.go b/api/core/v1alpha2/node_device_usb.go index 57003a8731..418380f551 100644 --- a/api/core/v1alpha2/node_device_usb.go +++ b/api/core/v1alpha2/node_device_usb.go @@ -26,7 +26,7 @@ import ( // +genclient // +kubebuilder:object:root=true // +kubebuilder:metadata:labels={heritage=deckhouse,module=virtualization} -// +kubebuilder:resource:categories={virtualization},scope=Namespaced,shortName={nusb},singular=nodeusbdevice +// +kubebuilder:resource:categories={virtualization},scope=Cluster,shortName={nusb},singular=nodeusbdevice // +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Node",type=string,JSONPath=`.status.nodeName` // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` diff --git a/api/core/v1alpha2/virtual_machine.go b/api/core/v1alpha2/virtual_machine.go index fe6d22ccda..6e24f88d06 100644 --- a/api/core/v1alpha2/virtual_machine.go +++ b/api/core/v1alpha2/virtual_machine.go @@ -116,6 +116,7 @@ type VirtualMachineSpec struct { Networks []NetworksSpec `json:"networks,omitempty"` // List of USB devices to attach to the virtual machine. // Devices are referenced by name of USBDevice resource in the same namespace. + // +kubebuilder:validation:MaxItems:=8 USBDevices []USBDeviceSpecRef `json:"usbDevices,omitempty"` } @@ -497,10 +498,14 @@ type USBDeviceStatusRef struct { Name string `json:"name"` // The USB device is attached to the virtual machine. Attached bool `json:"attached"` + // USB device is ready to use. + Ready bool `json:"ready"` // USB address inside the virtual machine. Address *USBAddress `json:"address,omitempty"` // USB device is attached via hot plug connection. Hotplugged bool `json:"hotplugged,omitempty"` + // Conditions for this USB device. + Conditions []metav1.Condition `json:"conditions,omitempty"` } // USBAddress represents the USB bus address inside the virtual machine. diff --git a/api/core/v1alpha2/vmcondition/condition.go b/api/core/v1alpha2/vmcondition/condition.go index a3f1352fcf..75361a3a31 100644 --- a/api/core/v1alpha2/vmcondition/condition.go +++ b/api/core/v1alpha2/vmcondition/condition.go @@ -46,6 +46,8 @@ const ( TypeNeedsEvict Type = "NeedsEvict" // TypeNetworkReady indicates the state of additional network interfaces inside the virtual machine pod TypeNetworkReady Type = "NetworkReady" + // TypeUSBDeviceReady indicates the state of USB devices attached to the virtual machine. + TypeUSBDeviceReady Type = "USBDeviceReady" // TypeMaintenance indicates that the VirtualMachine is in maintenance mode. // During this condition, the VM remains stopped and no changes are allowed. @@ -282,3 +284,16 @@ func (r MaintenanceReason) String() string { const ( ReasonMaintenanceRestore MaintenanceReason = "RestoreInProgress" ) + +type USBDeviceReadyReason string + +func (r USBDeviceReadyReason) String() string { + return string(r) +} + +const ( + // ReasonUSBDeviceReady indicates that all USB devices are ready. + ReasonUSBDeviceReady USBDeviceReadyReason = "USBDeviceReady" + // ReasonSomeDevicesNotReady indicates that some USB devices are not ready. + ReasonSomeDevicesNotReady USBDeviceReadyReason = "SomeDevicesNotReady" +) diff --git a/crds/nodeusbdevices.yaml b/crds/nodeusbdevices.yaml index 30c7a818a5..fb62f20e66 100644 --- a/crds/nodeusbdevices.yaml +++ b/crds/nodeusbdevices.yaml @@ -19,7 +19,7 @@ spec: shortNames: - nusb singular: nodeusbdevice - scope: Namespaced + scope: Cluster versions: - additionalPrinterColumns: - jsonPath: .status.nodeName diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go index 6dc9f81ac2..7c13b4ac77 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go @@ -28,10 +28,12 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/vm/internal/state" "github.com/deckhouse/virtualization-controller/pkg/logger" "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned" "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vmcondition" subv1alpha2 "github.com/deckhouse/virtualization/api/subresources/v1alpha2" ) @@ -85,11 +87,16 @@ func (h *USBDeviceHandler) Handle(ctx context.Context, s state.VirtualMachineSta statusRef := v1alpha2.USBDeviceStatusRef{ Name: usbDeviceRef.Name, Attached: false, + Ready: false, } statusRefs = append(statusRefs, statusRef) continue } + // Get device ready status and conditions + isReady := h.isUSBDeviceReady(usbDevice) + deviceConditions := h.getDeviceConditions(usbDevice) + // Get or create ResourceClaimTemplate templateName := h.getResourceClaimTemplateName(vm, usbDeviceRef.Name) _, err := h.getOrCreateResourceClaimTemplate(ctx, vm, usbDevice, templateName) @@ -97,23 +104,29 @@ func (h *USBDeviceHandler) Handle(ctx context.Context, s state.VirtualMachineSta log.Error("failed to get or create ResourceClaimTemplate", "error", err, "usbDevice", usbDeviceRef.Name) // Continue with other devices statusRef := v1alpha2.USBDeviceStatusRef{ - Name: usbDeviceRef.Name, - Attached: false, + Name: usbDeviceRef.Name, + Attached: false, + Ready: isReady, + Conditions: deviceConditions, } statusRefs = append(statusRefs, statusRef) continue } // Check if device is ready - if !h.isUSBDeviceReady(usbDevice) { + if !isReady { log.Info("USB device not ready", "usbDevice", usbDeviceRef.Name) - // Keep existing status if available + // Keep existing status if available, but update ready and conditions if existingStatus, ok := currentStatusMap[usbDeviceRef.Name]; ok { + existingStatus.Ready = isReady + existingStatus.Conditions = deviceConditions statusRefs = append(statusRefs, *existingStatus) } else { statusRefs = append(statusRefs, v1alpha2.USBDeviceStatusRef{ - Name: usbDeviceRef.Name, - Attached: false, + Name: usbDeviceRef.Name, + Attached: false, + Ready: isReady, + Conditions: deviceConditions, }) } continue @@ -122,7 +135,9 @@ func (h *USBDeviceHandler) Handle(ctx context.Context, s state.VirtualMachineSta // Check if already attached existingStatus, alreadyAttached := currentStatusMap[usbDeviceRef.Name] if alreadyAttached && existingStatus.Attached { - // Device already attached, keep status + // Device already attached, keep status but update ready and conditions + existingStatus.Ready = isReady + existingStatus.Conditions = deviceConditions statusRefs = append(statusRefs, *existingStatus) continue } @@ -132,13 +147,17 @@ func (h *USBDeviceHandler) Handle(ctx context.Context, s state.VirtualMachineSta err = h.attachUSBDevice(ctx, vm, usbDeviceRef.Name, templateName, requestName) if err != nil { log.Error("failed to attach USB device", "error", err, "usbDevice", usbDeviceRef.Name) - // Keep existing status or create new one + // Keep existing status or create new one, but update ready and conditions if existingStatus != nil { + existingStatus.Ready = isReady + existingStatus.Conditions = deviceConditions statusRefs = append(statusRefs, *existingStatus) } else { statusRefs = append(statusRefs, v1alpha2.USBDeviceStatusRef{ - Name: usbDeviceRef.Name, - Attached: false, + Name: usbDeviceRef.Name, + Attached: false, + Ready: isReady, + Conditions: deviceConditions, }) } continue @@ -154,8 +173,10 @@ func (h *USBDeviceHandler) Handle(ctx context.Context, s state.VirtualMachineSta statusRef := v1alpha2.USBDeviceStatusRef{ Name: usbDeviceRef.Name, Attached: true, + Ready: isReady, Address: address, Hotplugged: isHotplugged, + Conditions: deviceConditions, } statusRefs = append(statusRefs, statusRef) } @@ -182,6 +203,9 @@ func (h *USBDeviceHandler) Handle(ctx context.Context, s state.VirtualMachineSta changed.Status.USBDevices = statusRefs + // Update USBDeviceReady condition + h.updateUSBDeviceReadyCondition(vm, changed, statusRefs) + return reconcile.Result{}, nil } @@ -372,3 +396,61 @@ func (h *USBDeviceHandler) getOrAssignUSBAddress( Port: port, } } + +func (h *USBDeviceHandler) getDeviceConditions(usbDevice *v1alpha2.USBDevice) []metav1.Condition { + // Copy conditions from USBDevice + conditions := make([]metav1.Condition, 0, len(usbDevice.Status.Conditions)) + for _, cond := range usbDevice.Status.Conditions { + conditions = append(conditions, *cond.DeepCopy()) + } + return conditions +} + +func (h *USBDeviceHandler) updateUSBDeviceReadyCondition( + vm *v1alpha2.VirtualMachine, + changed *v1alpha2.VirtualMachine, + statusRefs []v1alpha2.USBDeviceStatusRef, +) { + // Check if all USB devices are ready + allReady := true + var notReadyDevices []string + + for _, statusRef := range statusRefs { + if !statusRef.Ready { + allReady = false + notReadyDevices = append(notReadyDevices, statusRef.Name) + } + } + + var reason vmcondition.USBDeviceReadyReason + var status metav1.ConditionStatus + var message string + + if len(statusRefs) == 0 { + // No USB devices specified, remove condition + conditions.RemoveCondition(vmcondition.TypeUSBDeviceReady, &changed.Status.Conditions) + return + } + + if allReady { + reason = vmcondition.ReasonUSBDeviceReady + status = metav1.ConditionTrue + message = "All USB devices are ready" + } else { + reason = vmcondition.ReasonSomeDevicesNotReady + status = metav1.ConditionFalse + if len(notReadyDevices) == 1 { + message = fmt.Sprintf("USB device '%s' is not ready", notReadyDevices[0]) + } else { + message = fmt.Sprintf("USB devices '%v' are not ready", notReadyDevices) + } + } + + cb := conditions.NewConditionBuilder(vmcondition.TypeUSBDeviceReady). + Generation(vm.GetGeneration()). + Status(status). + Reason(reason). + Message(message) + + conditions.SetCondition(cb, &changed.Status.Conditions) +} From d2356d8ee0ba4096a1798d3b8c699d8babe7a3e9 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Tue, 27 Jan 2026 07:22:57 +0200 Subject: [PATCH 19/53] fix rbac Signed-off-by: Daniil Antoshin --- .../nodeusbdevice/internal/assigned.go | 26 ++++- .../nodeusbdevice/nodeusbdevice_controller.go | 9 ++ .../nodeusbdevice/nodeusbdevice_webhook.go | 98 +++++++++++++++++++ .../usbdevice/usbdevice_controller.go | 9 ++ .../controller/usbdevice/usbdevice_webhook.go | 89 +++++++++++++++++ .../validation-webhook.yaml | 34 +++++++ 6 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go create mode 100644 images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_webhook.go diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go index d5fffc191b..3f7aeafe5f 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go @@ -19,11 +19,13 @@ package internal import ( "context" "fmt" + "reflect" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -155,11 +157,16 @@ func (h *AssignedHandler) ensureUSBDevice(ctx context.Context, nodeUSBDevice *v1 err := h.client.Get(ctx, key, usbDevice) if err == nil { - // USBDevice exists - update it - usbDevice.Status.Attributes = nodeUSBDevice.Status.Attributes - usbDevice.Status.NodeName = nodeUSBDevice.Status.NodeName - if err := h.client.Update(ctx, usbDevice); err != nil { - return nil, fmt.Errorf("failed to update USBDevice: %w", err) + // USBDevice exists - check if update is needed + needsUpdate := !reflect.DeepEqual(usbDevice.Status.Attributes, nodeUSBDevice.Status.Attributes) || + usbDevice.Status.NodeName != nodeUSBDevice.Status.NodeName + + if needsUpdate { + usbDevice.Status.Attributes = nodeUSBDevice.Status.Attributes + usbDevice.Status.NodeName = nodeUSBDevice.Status.NodeName + if err := h.client.Status().Update(ctx, usbDevice); err != nil { + return nil, fmt.Errorf("failed to update USBDevice status: %w", err) + } } return usbDevice, nil } @@ -173,6 +180,15 @@ func (h *AssignedHandler) ensureUSBDevice(ctx context.Context, nodeUSBDevice *v1 ObjectMeta: metav1.ObjectMeta{ Name: nodeUSBDevice.Name, Namespace: namespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: nodeUSBDevice.APIVersion, + Kind: nodeUSBDevice.Kind, + Name: nodeUSBDevice.Name, + UID: nodeUSBDevice.UID, + Controller: ptr.To(true), + }, + }, }, Status: v1alpha2.USBDeviceStatus{ Attributes: nodeUSBDevice.Status.Attributes, diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go index 29be9f9acd..8bb43916d3 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go @@ -21,6 +21,7 @@ import ( "time" "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/manager" @@ -28,6 +29,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" ) const ( @@ -66,6 +68,13 @@ func NewController( return nil, err } + if err = builder.WebhookManagedBy(mgr). + For(&v1alpha2.NodeUSBDevice{}). + WithValidator(NewValidator(log)). + Complete(); err != nil { + return nil, err + } + log.Info("Initialized NodeUSBDevice controller") return c, nil } diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go new file mode 100644 index 0000000000..2ec01e7142 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go @@ -0,0 +1,98 @@ +/* +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 nodeusbdevice + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func NewValidator(log *log.Logger) *Validator { + return &Validator{ + log: log.With("webhook", "validation"), + } +} + +type Validator struct { + log *log.Logger +} + +// ValidateCreate validates NodeUSBDevice creation. +// NodeUSBDevice resources are created automatically by the controller when devices are discovered. +func (v *Validator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + nodeUSBDevice, ok := obj.(*v1alpha2.NodeUSBDevice) + if !ok { + return nil, fmt.Errorf("expected a new NodeUSBDevice but got a %T", obj) + } + + v.log.Info("Validate NodeUSBDevice creating", "name", nodeUSBDevice.Name) + + // NodeUSBDevice resources are created automatically by the controller + // Manual creation is allowed for administrative purposes (e.g., testing) + // but spec.assignedNamespace can be set by administrators + return nil, nil +} + +// ValidateUpdate validates NodeUSBDevice updates. +// Only spec.assignedNamespace can be changed by administrators. +// Status updates are performed by the controller. +func (v *Validator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + oldNodeUSBDevice, ok := oldObj.(*v1alpha2.NodeUSBDevice) + if !ok { + return nil, fmt.Errorf("expected an old NodeUSBDevice but got a %T", oldObj) + } + + newNodeUSBDevice, ok := newObj.(*v1alpha2.NodeUSBDevice) + if !ok { + return nil, fmt.Errorf("expected a new NodeUSBDevice but got a %T", newObj) + } + + v.log.Info("Validate NodeUSBDevice updating", "name", newNodeUSBDevice.Name) + + // Only spec.assignedNamespace can be changed by administrators + // Status is managed by the controller + // If spec changed in a way other than assignedNamespace, reject + if oldNodeUSBDevice.Spec.AssignedNamespace != newNodeUSBDevice.Spec.AssignedNamespace { + // This is allowed - administrators can assign/unassign namespaces + return nil, nil + } + + // Status changes are allowed (performed by the controller) + // Spec changes other than assignedNamespace are not allowed + return nil, nil +} + +// ValidateDelete validates NodeUSBDevice deletion. +// NodeUSBDevice resources can be deleted by administrators. +func (v *Validator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + nodeUSBDevice, ok := obj.(*v1alpha2.NodeUSBDevice) + if !ok { + return nil, fmt.Errorf("expected a NodeUSBDevice but got a %T", obj) + } + + v.log.Info("Validate NodeUSBDevice deleting", "name", nodeUSBDevice.Name) + + // NodeUSBDevice can be deleted by administrators + // The controller will clean up associated USBDevice resources via finalizer + return nil, nil +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go index 55b90f0411..07ddec329b 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go @@ -21,6 +21,7 @@ import ( "time" "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/manager" @@ -29,6 +30,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization-controller/pkg/logger" "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned" + "github.com/deckhouse/virtualization/api/core/v1alpha2" ) const ( @@ -72,6 +74,13 @@ func NewController( return nil, err } + if err = builder.WebhookManagedBy(mgr). + For(&v1alpha2.USBDevice{}). + WithValidator(NewValidator(log)). + Complete(); err != nil { + return nil, err + } + log.Info("Initialized USBDevice controller") return c, nil } diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_webhook.go b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_webhook.go new file mode 100644 index 0000000000..af4eb54f95 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_webhook.go @@ -0,0 +1,89 @@ +/* +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 usbdevice + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func NewValidator(log *log.Logger) *Validator { + return &Validator{ + log: log.With("webhook", "validation"), + } +} + +type Validator struct { + log *log.Logger +} + +// ValidateCreate validates USBDevice creation. +// USBDevice resources are managed by the controller and should not be created by users. +func (v *Validator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + usbDevice, ok := obj.(*v1alpha2.USBDevice) + if !ok { + return nil, fmt.Errorf("expected a new USBDevice but got a %T", obj) + } + + v.log.Info("Validate USBDevice creating", "name", usbDevice.Name, "namespace", usbDevice.Namespace) + + // USBDevice resources are created automatically by the controller + // Users should not create them directly + return nil, fmt.Errorf("USBDevice resources are managed by the controller and cannot be created manually. Use NodeUSBDevice to assign devices to namespaces") +} + +// ValidateUpdate validates USBDevice updates. +// Only status updates are allowed (performed by the controller). +func (v *Validator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + oldUSBDevice, ok := oldObj.(*v1alpha2.USBDevice) + if !ok { + return nil, fmt.Errorf("expected an old USBDevice but got a %T", oldObj) + } + + newUSBDevice, ok := newObj.(*v1alpha2.USBDevice) + if !ok { + return nil, fmt.Errorf("expected a new USBDevice but got a %T", newObj) + } + + v.log.Info("Validate USBDevice updating", "name", newUSBDevice.Name, "namespace", newUSBDevice.Namespace) + + // USBDevice has no spec, only status + // Status updates are allowed (performed by the controller) + // Users should not modify USBDevice resources + return nil, nil +} + +// ValidateDelete validates USBDevice deletion. +// USBDevice resources are managed by the controller and should not be deleted by users. +func (v *Validator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + usbDevice, ok := obj.(*v1alpha2.USBDevice) + if !ok { + return nil, fmt.Errorf("expected a USBDevice but got a %T", obj) + } + + v.log.Info("Validate USBDevice deleting", "name", usbDevice.Name, "namespace", usbDevice.Namespace) + + // USBDevice resources are deleted automatically by the controller + // Users should not delete them directly + return nil, fmt.Errorf("USBDevice resources are managed by the controller and cannot be deleted manually. Modify NodeUSBDevice.spec.assignedNamespace to remove the device from a namespace") +} diff --git a/templates/virtualization-controller/validation-webhook.yaml b/templates/virtualization-controller/validation-webhook.yaml index 9ca31adaa8..025be822ca 100644 --- a/templates/virtualization-controller/validation-webhook.yaml +++ b/templates/virtualization-controller/validation-webhook.yaml @@ -267,4 +267,38 @@ webhooks: - name: 'match-virtualization' expression: 'request.name == "virtualization"' {{- end }} + - name: "usbdevice.virtualization-controller.validate.d8-virtualization" + rules: + - apiGroups: ["virtualization.deckhouse.io"] + apiVersions: ["v1alpha2"] + operations: ["CREATE", "UPDATE", "DELETE"] + resources: ["usbdevices"] + scope: "Namespaced" + clientConfig: + service: + namespace: d8-{{ .Chart.Name }} + name: virtualization-controller + path: /validate-virtualization-deckhouse-io-v1alpha2-usbdevice + port: 443 + caBundle: | + {{ .Values.virtualization.internal.controller.cert.ca | b64enc }} + admissionReviewVersions: ["v1"] + sideEffects: None + - name: "nodeusbdevice.virtualization-controller.validate.d8-virtualization" + rules: + - apiGroups: ["virtualization.deckhouse.io"] + apiVersions: ["v1alpha2"] + operations: ["CREATE", "UPDATE", "DELETE"] + resources: ["nodeusbdevices"] + scope: "Cluster" + clientConfig: + service: + namespace: d8-{{ .Chart.Name }} + name: virtualization-controller + path: /validate-virtualization-deckhouse-io-v1alpha2-nodeusbdevice + port: 443 + caBundle: | + {{ .Values.virtualization.internal.controller.cert.ca | b64enc }} + admissionReviewVersions: ["v1"] + sideEffects: None {{- end }} From 9db461ff239ccd107e974725d707fdecf5ceac9c Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Tue, 27 Jan 2026 07:31:30 +0200 Subject: [PATCH 20/53] simplify Signed-off-by: Daniil Antoshin --- .../pkg/controller/indexer/indexer.go | 13 ++ .../nodeusbdevice/internal/discovery.go | 169 +++++++++++------- .../nodeusbdevice/internal/hash/hash.go | 91 ++++++++++ .../nodeusbdevice/internal/ready.go | 47 +---- .../usbdevice/internal/ready_test.go | 12 +- .../usbdevice/internal/state/state.go | 6 +- .../pkg/controller/usbdevice/internal/sync.go | 74 -------- .../internal/{ready.go => sync_ready.go} | 28 ++- .../usbdevice/internal/sync_test.go | 8 +- .../usbdevice/usbdevice_controller.go | 3 +- 10 files changed, 247 insertions(+), 204 deletions(-) create mode 100644 images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/hash/hash.go delete mode 100644 images/virtualization-artifact/pkg/controller/usbdevice/internal/sync.go rename images/virtualization-artifact/pkg/controller/usbdevice/internal/{ready.go => sync_ready.go} (80%) diff --git a/images/virtualization-artifact/pkg/controller/indexer/indexer.go b/images/virtualization-artifact/pkg/controller/indexer/indexer.go index c802138f48..8e761cd418 100644 --- a/images/virtualization-artifact/pkg/controller/indexer/indexer.go +++ b/images/virtualization-artifact/pkg/controller/indexer/indexer.go @@ -66,6 +66,8 @@ const ( IndexFieldVMIPLeaseByVMIP = "spec.virtualMachineIPAddressRef" IndexFieldVMByProvisioningSecret = "spec.provisioning.secretRef" + + IndexFieldNodeUSBDeviceByName = "metadata.name" ) var IndexGetters = []IndexGetter{ @@ -93,6 +95,7 @@ var IndexGetters = []IndexGetter{ IndexVMMACByAddress, IndexVMMACLeaseByVMMAC, IndexVMIPLeaseByVMIP, + IndexNodeUSBDeviceByName, } type IndexGetter func() (obj client.Object, field string, extractValue client.IndexerFunc) @@ -214,3 +217,13 @@ func IndexVMByUSBDevice() (obj client.Object, field string, extractValue client. return result } } + +func IndexNodeUSBDeviceByName() (obj client.Object, field string, extractValue client.IndexerFunc) { + return &v1alpha2.NodeUSBDevice{}, IndexFieldNodeUSBDeviceByName, func(object client.Object) []string { + nodeUSBDevice, ok := object.(*v1alpha2.NodeUSBDevice) + if !ok || nodeUSBDevice == nil { + return nil + } + return []string{nodeUSBDevice.Name} + } +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go index 8605b62251..fc7f3167f3 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go @@ -18,8 +18,6 @@ package internal import ( "context" - "crypto/sha256" - "encoding/hex" "fmt" "strings" @@ -30,6 +28,7 @@ import ( "github.com/deckhouse/deckhouse/pkg/log" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/hash" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization/api/core/v1alpha2" @@ -59,9 +58,15 @@ func (h *DiscoveryHandler) Name() string { func (h *DiscoveryHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) (reconcile.Result, error) { nodeUSBDevice := s.NodeUSBDevice() - // Always check for new devices in ResourceSlice and create NodeUSBDevice if needed + // Get ResourceSlices once for both discovery and update + resourceSlices, err := s.ResourceSlices(ctx) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to get resource slices: %w", err) + } + + // Check for new devices in ResourceSlice and create NodeUSBDevice if needed // This ensures we discover new devices even if reconcile was triggered for other reasons - if err := h.discoverAndCreate(ctx, s); err != nil { + if err := h.discoverAndCreate(ctx, s, resourceSlices); err != nil { // Log error but don't fail reconciliation // This is a best-effort discovery mechanism log.Error("failed to discover and create NodeUSBDevice", log.Err(err)) @@ -76,11 +81,6 @@ func (h *DiscoveryHandler) Handle(ctx context.Context, s state.NodeUSBDeviceStat changed := nodeUSBDevice.Changed() // Update attributes from ResourceSlice if needed - resourceSlices, err := s.ResourceSlices(ctx) - if err != nil { - return reconcile.Result{}, fmt.Errorf("failed to get resource slices: %w", err) - } - deviceInfo, found := h.findDeviceInSlices(resourceSlices, current.Status.Attributes.Hash, current.Status.NodeName) if !found { // Device not found in slices - mark as NotFound @@ -101,13 +101,66 @@ func (h *DiscoveryHandler) Handle(ctx context.Context, s state.NodeUSBDeviceStat return reconcile.Result{}, nil } -func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context, s state.NodeUSBDeviceState) error { - resourceSlices, err := s.ResourceSlices(ctx) - if err != nil { - return fmt.Errorf("failed to get resource slices: %w", err) +func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context, s state.NodeUSBDeviceState, resourceSlices []resourcev1beta1.ResourceSlice) error { + // Check if current device exists - if it does, we only need to check for new devices + // This avoids unnecessary List when reconciling existing devices + currentDevice := s.NodeUSBDevice() + hasCurrentDevice := !currentDevice.IsEmpty() + + // Collect all hashes from ResourceSlices first + deviceHashesInSlices := make(map[string]bool) + for _, slice := range resourceSlices { + for _, device := range slice.Spec.Devices { + if !strings.HasPrefix(device.Name, "usb-") { + continue + } + hash := hash.CalculateHashFromDevice(device, slice.Spec.Pool.Name) + deviceHashesInSlices[hash] = true + } } - // Get all existing NodeUSBDevices to avoid duplicates + // If we have a current device and its hash is in slices, we can skip List + // Only do List if we need to check for new devices + if hasCurrentDevice { + current := currentDevice.Current() + if current.Status.Attributes.Hash != "" && deviceHashesInSlices[current.Status.Attributes.Hash] { + // Current device exists and is in slices - only check for new devices + // We still need to List to check for duplicates, but we can optimize + // by only checking hashes that are in slices + var existingDevices v1alpha2.NodeUSBDeviceList + if err := h.client.List(ctx, &existingDevices); err != nil { + return fmt.Errorf("failed to list existing NodeUSBDevices: %w", err) + } + + existingHashes := make(map[string]bool) + for _, device := range existingDevices.Items { + if device.Status.Attributes.Hash != "" { + existingHashes[device.Status.Attributes.Hash] = true + } + } + + // Only create devices that are in slices but not in existing + for _, slice := range resourceSlices { + for _, device := range slice.Spec.Devices { + if !strings.HasPrefix(device.Name, "usb-") { + continue + } + + attributes := h.convertDeviceToAttributes(device, slice.Spec.Pool.Name) + hash := hash.CalculateHash(attributes) + + if !existingHashes[hash] { + if err := h.createNodeUSBDevice(ctx, attributes, hash); err != nil { + return err + } + } + } + } + return nil + } + } + + // No current device or it's not in slices - need full List var existingDevices v1alpha2.NodeUSBDeviceList if err := h.client.List(ctx, &existingDevices); err != nil { return fmt.Errorf("failed to list existing NodeUSBDevices: %w", err) @@ -129,45 +182,53 @@ func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context, s state.NodeUS } attributes := h.convertDeviceToAttributes(device, slice.Spec.Pool.Name) - hash := h.calculateHash(attributes) + hash := hash.CalculateHash(attributes) if existingHashes[hash] { continue } - nodeUSBDevice := &v1alpha2.NodeUSBDevice{ - ObjectMeta: metav1.ObjectMeta{ - Name: h.generateName(hash, attributes.NodeName), - }, - Spec: v1alpha2.NodeUSBDeviceSpec{ - AssignedNamespace: "", + if err := h.createNodeUSBDevice(ctx, attributes, hash); err != nil { + return err + } + } + } + + return nil +} + +func (h *DiscoveryHandler) createNodeUSBDevice(ctx context.Context, attributes v1alpha2.NodeUSBDeviceAttributes, hash string) error { + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: h.generateName(hash, attributes.NodeName), + }, + Spec: v1alpha2.NodeUSBDeviceSpec{ + AssignedNamespace: "", + }, + Status: v1alpha2.NodeUSBDeviceStatus{ + Attributes: attributes, + NodeName: attributes.NodeName, + Conditions: []metav1.Condition{ + { + Type: string(nodeusbdevicecondition.ReadyType), + Status: metav1.ConditionTrue, + Reason: string(nodeusbdevicecondition.Ready), + Message: "Device is ready to use", + LastTransitionTime: metav1.Now(), }, - Status: v1alpha2.NodeUSBDeviceStatus{ - Attributes: attributes, - NodeName: attributes.NodeName, - Conditions: []metav1.Condition{ - { - Type: string(nodeusbdevicecondition.ReadyType), - Status: metav1.ConditionTrue, - Reason: string(nodeusbdevicecondition.Ready), - Message: "Device is ready to use", - LastTransitionTime: metav1.Now(), - }, - { - Type: string(nodeusbdevicecondition.AssignedType), - Status: metav1.ConditionFalse, - Reason: string(nodeusbdevicecondition.Available), - Message: "No namespace is assigned for the device", - LastTransitionTime: metav1.Now(), - }, - }, + { + Type: string(nodeusbdevicecondition.AssignedType), + Status: metav1.ConditionFalse, + Reason: string(nodeusbdevicecondition.Available), + Message: "No namespace is assigned for the device", + LastTransitionTime: metav1.Now(), }, - } + }, + }, + } - if err := h.client.Create(ctx, nodeUSBDevice); err != nil { - return fmt.Errorf("failed to create NodeUSBDevice: %w", err) - } - } + if err := h.client.Create(ctx, nodeUSBDevice); err != nil { + return fmt.Errorf("failed to create NodeUSBDevice: %w", err) } return nil @@ -259,26 +320,10 @@ func (h *DiscoveryHandler) convertDeviceToAttributes(device resourcev1beta1.Devi } } - attrs.Hash = h.calculateHash(attrs) + attrs.Hash = hash.CalculateHash(attrs) return attrs } -func (h *DiscoveryHandler) calculateHash(attrs v1alpha2.NodeUSBDeviceAttributes) string { - // Calculate hash based on main attributes - hashInput := fmt.Sprintf("%s:%s:%s:%s:%s:%s:%s", - attrs.NodeName, - attrs.VendorID, - attrs.ProductID, - attrs.Bus, - attrs.DeviceNumber, - attrs.Serial, - attrs.DevicePath, - ) - - hash := sha256.Sum256([]byte(hashInput)) - return hex.EncodeToString(hash[:])[:16] // Use first 16 characters -} - func (h *DiscoveryHandler) generateName(hash, nodeName string) string { // Generate name based on hash and node name // Format: nusb-- diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/hash/hash.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/hash/hash.go new file mode 100644 index 0000000000..67a8cb34dd --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/hash/hash.go @@ -0,0 +1,91 @@ +/* +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 hash + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + + resourcev1beta1 "k8s.io/api/resource/v1beta1" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +// CalculateHash calculates hash for USB device attributes. +func CalculateHash(attrs v1alpha2.NodeUSBDeviceAttributes) string { + hashInput := fmt.Sprintf("%s:%s:%s:%s:%s:%s:%s", + attrs.NodeName, + attrs.VendorID, + attrs.ProductID, + attrs.Bus, + attrs.DeviceNumber, + attrs.Serial, + attrs.DevicePath, + ) + + hash := sha256.Sum256([]byte(hashInput)) + return hex.EncodeToString(hash[:])[:16] // Use first 16 characters +} + +// CalculateHashFromDevice calculates hash from ResourceSlice Device. +func CalculateHashFromDevice(device resourcev1beta1.Device, nodeName string) string { + var vendorID, productID, bus, deviceNumber, serial, devicePath string + + if device.Basic != nil { + for key, attr := range device.Basic.Attributes { + switch string(key) { + case "vendorID": + if attr.StringValue != nil { + vendorID = *attr.StringValue + } + case "productID": + if attr.StringValue != nil { + productID = *attr.StringValue + } + case "bus": + if attr.StringValue != nil { + bus = *attr.StringValue + } + case "deviceNumber": + if attr.StringValue != nil { + deviceNumber = *attr.StringValue + } + case "serial": + if attr.StringValue != nil { + serial = *attr.StringValue + } + case "devicePath": + if attr.StringValue != nil { + devicePath = *attr.StringValue + } + } + } + } + + attrs := v1alpha2.NodeUSBDeviceAttributes{ + NodeName: nodeName, + VendorID: vendorID, + ProductID: productID, + Bus: bus, + DeviceNumber: deviceNumber, + Serial: serial, + DevicePath: devicePath, + } + + return CalculateHash(attrs) +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go index 5066a3f4ab..a5b2644d5b 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go @@ -18,8 +18,6 @@ package internal import ( "context" - "crypto/sha256" - "encoding/hex" "fmt" "strings" @@ -28,6 +26,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/hash" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" @@ -105,7 +104,7 @@ func (h *ReadyHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice } // Calculate hash for this device and compare - deviceHash := h.calculateDeviceHash(device, nodeName) + deviceHash := hash.CalculateHashFromDevice(device, nodeName) if deviceHash == hash { return true } @@ -115,48 +114,6 @@ func (h *ReadyHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice return false } -func (h *ReadyHandler) calculateDeviceHash(device resourcev1beta1.Device, nodeName string) string { - // Extract attributes and calculate hash similar to discovery handler - var vendorID, productID, bus, deviceNumber, serial, devicePath string - - if device.Basic != nil { - for key, attr := range device.Basic.Attributes { - switch string(key) { - case "vendorID": - if attr.StringValue != nil { - vendorID = *attr.StringValue - } - case "productID": - if attr.StringValue != nil { - productID = *attr.StringValue - } - case "bus": - if attr.StringValue != nil { - bus = *attr.StringValue - } - case "deviceNumber": - if attr.StringValue != nil { - deviceNumber = *attr.StringValue - } - case "serial": - if attr.StringValue != nil { - serial = *attr.StringValue - } - case "devicePath": - if attr.StringValue != nil { - devicePath = *attr.StringValue - } - } - } - } - - hashInput := fmt.Sprintf("%s:%s:%s:%s:%s:%s:%s", - nodeName, vendorID, productID, bus, deviceNumber, serial, devicePath) - - hash := sha256.Sum256([]byte(hashInput)) - return hex.EncodeToString(hash[:])[:16] -} - func (h *ReadyHandler) Name() string { return nameReadyHandler } diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go index 1742365e9e..e6225782fb 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go @@ -38,10 +38,10 @@ import ( "github.com/deckhouse/virtualization/api/core/v1alpha2/usbdevicecondition" ) -var _ = Describe("ReadyHandler", func() { +var _ = Describe("SyncReadyHandler - Ready condition", func() { var ctx context.Context var fakeClient client.WithWatch - var handler *ReadyHandler + var handler *SyncReadyHandler var usbDeviceState state.USBDeviceState var usbDeviceResource *reconciler.Resource[*v1alpha2.USBDevice, v1alpha2.USBDeviceStatus] @@ -90,7 +90,7 @@ var _ = Describe("ReadyHandler", func() { usbDeviceState = state.New(fakeClient, usbDeviceResource) recorder := &eventrecord.EventRecorderLoggerMock{} - handler = NewReadyHandler(fakeClient, recorder) + handler = NewSyncReadyHandler(fakeClient, recorder) result, err := handler.Handle(ctx, usbDeviceState) Expect(err).NotTo(HaveOccurred()) @@ -144,7 +144,7 @@ var _ = Describe("ReadyHandler", func() { usbDeviceState = state.New(fakeClient, usbDeviceResource) recorder := &eventrecord.EventRecorderLoggerMock{} - handler = NewReadyHandler(fakeClient, recorder) + handler = NewSyncReadyHandler(fakeClient, recorder) result, err := handler.Handle(ctx, usbDeviceState) Expect(err).NotTo(HaveOccurred()) @@ -183,7 +183,7 @@ var _ = Describe("ReadyHandler", func() { usbDeviceState = state.New(fakeClient, usbDeviceResource) recorder := &eventrecord.EventRecorderLoggerMock{} - handler = NewReadyHandler(fakeClient, recorder) + handler = NewSyncReadyHandler(fakeClient, recorder) result, err := handler.Handle(ctx, usbDeviceState) Expect(err).NotTo(HaveOccurred()) @@ -231,7 +231,7 @@ var _ = Describe("ReadyHandler", func() { usbDeviceState = state.New(fakeClient, usbDeviceResource) recorder := &eventrecord.EventRecorderLoggerMock{} - handler = NewReadyHandler(fakeClient, recorder) + handler = NewSyncReadyHandler(fakeClient, recorder) result, err := handler.Handle(ctx, usbDeviceState) Expect(err).NotTo(HaveOccurred()) diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go index b59897d3c2..e7e357660f 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go @@ -50,14 +50,16 @@ func (s *usbDeviceState) USBDevice() *reconciler.Resource[*v1alpha2.USBDevice, v func (s *usbDeviceState) NodeUSBDevice(ctx context.Context) (*v1alpha2.NodeUSBDevice, error) { // USBDevice has the same name as the corresponding NodeUSBDevice - // We need to find the NodeUSBDevice by name across all namespaces + // Use indexer to find NodeUSBDevice by name usbDevice := s.usbDevice.Current() if usbDevice == nil { return nil, nil } var nodeUSBDeviceList v1alpha2.NodeUSBDeviceList - if err := s.client.List(ctx, &nodeUSBDeviceList); err != nil { + if err := s.client.List(ctx, &nodeUSBDeviceList, client.MatchingFields{ + indexer.IndexFieldNodeUSBDeviceByName: usbDevice.Name, + }); err != nil { return nil, err } diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync.go deleted file mode 100644 index 4c0888966f..0000000000 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync.go +++ /dev/null @@ -1,74 +0,0 @@ -/* -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 internal - -import ( - "context" - - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/state" - "github.com/deckhouse/virtualization-controller/pkg/eventrecord" -) - -const ( - nameSyncHandler = "SyncHandler" -) - -func NewSyncHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *SyncHandler { - return &SyncHandler{ - client: client, - recorder: recorder, - } -} - -type SyncHandler struct { - client client.Client - recorder eventrecord.EventRecorderLogger -} - -func (h *SyncHandler) Handle(ctx context.Context, s state.USBDeviceState) (reconcile.Result, error) { - usbDevice := s.USBDevice() - - if usbDevice.IsEmpty() { - return reconcile.Result{}, nil - } - - changed := usbDevice.Changed() - - // Get corresponding NodeUSBDevice - nodeUSBDevice, err := s.NodeUSBDevice(ctx) - if err != nil { - return reconcile.Result{}, err - } - - if nodeUSBDevice == nil { - // NodeUSBDevice not found - nothing to sync - return reconcile.Result{}, nil - } - - // Sync attributes from NodeUSBDevice - changed.Status.Attributes = nodeUSBDevice.Status.Attributes - changed.Status.NodeName = nodeUSBDevice.Status.NodeName - - return reconcile.Result{}, nil -} - -func (h *SyncHandler) Name() string { - return nameSyncHandler -} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_ready.go similarity index 80% rename from images/virtualization-artifact/pkg/controller/usbdevice/internal/ready.go rename to images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_ready.go index 452ec5cb95..d03e4bbbc2 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_ready.go @@ -18,6 +18,7 @@ package internal import ( "context" + "reflect" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -31,22 +32,22 @@ import ( ) const ( - nameReadyHandler = "ReadyHandler" + nameSyncReadyHandler = "SyncReadyHandler" ) -func NewReadyHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *ReadyHandler { - return &ReadyHandler{ +func NewSyncReadyHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *SyncReadyHandler { + return &SyncReadyHandler{ client: client, recorder: recorder, } } -type ReadyHandler struct { +type SyncReadyHandler struct { client client.Client recorder eventrecord.EventRecorderLogger } -func (h *ReadyHandler) Handle(ctx context.Context, s state.USBDeviceState) (reconcile.Result, error) { +func (h *SyncReadyHandler) Handle(ctx context.Context, s state.USBDeviceState) (reconcile.Result, error) { usbDevice := s.USBDevice() if usbDevice.IsEmpty() { @@ -56,7 +57,7 @@ func (h *ReadyHandler) Handle(ctx context.Context, s state.USBDeviceState) (reco current := usbDevice.Current() changed := usbDevice.Changed() - // Get corresponding NodeUSBDevice + // Get corresponding NodeUSBDevice once nodeUSBDevice, err := s.NodeUSBDevice(ctx) if err != nil { return reconcile.Result{}, err @@ -74,7 +75,16 @@ func (h *ReadyHandler) Handle(ctx context.Context, s state.USBDeviceState) (reco return reconcile.Result{}, nil } - // Find Ready condition in NodeUSBDevice + // Sync attributes and nodeName from NodeUSBDevice (only if changed) + needsSync := !reflect.DeepEqual(changed.Status.Attributes, nodeUSBDevice.Status.Attributes) || + changed.Status.NodeName != nodeUSBDevice.Status.NodeName + + if needsSync { + changed.Status.Attributes = nodeUSBDevice.Status.Attributes + changed.Status.NodeName = nodeUSBDevice.Status.NodeName + } + + // Sync Ready condition from NodeUSBDevice var readyCondition *metav1.Condition for i := range nodeUSBDevice.Status.Conditions { if nodeUSBDevice.Status.Conditions[i].Type == string(nodeusbdevicecondition.ReadyType) { @@ -126,6 +136,6 @@ func (h *ReadyHandler) Handle(ctx context.Context, s state.USBDeviceState) (reco return reconcile.Result{}, nil } -func (h *ReadyHandler) Name() string { - return nameReadyHandler +func (h *SyncReadyHandler) Name() string { + return nameSyncReadyHandler } diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go index d57750bcf7..bf26bddf7b 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go @@ -36,10 +36,10 @@ import ( "github.com/deckhouse/virtualization/api/core/v1alpha2" ) -var _ = Describe("SyncHandler", func() { +var _ = Describe("SyncReadyHandler", func() { var ctx context.Context var fakeClient client.WithWatch - var handler *SyncHandler + var handler *SyncReadyHandler var usbDeviceState state.USBDeviceState var usbDeviceResource *reconciler.Resource[*v1alpha2.USBDevice, v1alpha2.USBDeviceStatus] @@ -91,7 +91,7 @@ var _ = Describe("SyncHandler", func() { usbDeviceState = state.New(fakeClient, usbDeviceResource) recorder := &eventrecord.EventRecorderLoggerMock{} - handler = NewSyncHandler(fakeClient, recorder) + handler = NewSyncReadyHandler(fakeClient, recorder) result, err := handler.Handle(ctx, usbDeviceState) Expect(err).NotTo(HaveOccurred()) @@ -135,7 +135,7 @@ var _ = Describe("SyncHandler", func() { usbDeviceState = state.New(fakeClient, usbDeviceResource) recorder := &eventrecord.EventRecorderLoggerMock{} - handler = NewSyncHandler(fakeClient, recorder) + handler = NewSyncReadyHandler(fakeClient, recorder) result, err := handler.Handle(ctx, usbDeviceState) Expect(err).NotTo(HaveOccurred()) diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go index 07ddec329b..d333f6506a 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go @@ -52,9 +52,8 @@ func NewController( handlers := []Handler{ internal.NewDeletionHandler(client, virtClient, recorder), - internal.NewReadyHandler(client, recorder), + internal.NewSyncReadyHandler(client, recorder), internal.NewAttachedHandler(client, recorder), - internal.NewSyncHandler(client, recorder), } r := NewReconciler(client, handlers...) From 92f9e12f5bdc552acac44f2c1764f8c6c9cf0840 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Tue, 27 Jan 2026 07:34:40 +0200 Subject: [PATCH 21/53] update codegen Signed-off-by: Daniil Antoshin --- api/core/v1alpha2/zz_generated.deepcopy.go | 7 ++ crds/virtualmachines.yaml | 94 ++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/api/core/v1alpha2/zz_generated.deepcopy.go b/api/core/v1alpha2/zz_generated.deepcopy.go index 325860bc25..cbca747bb5 100644 --- a/api/core/v1alpha2/zz_generated.deepcopy.go +++ b/api/core/v1alpha2/zz_generated.deepcopy.go @@ -1149,6 +1149,13 @@ func (in *USBDeviceStatusRef) DeepCopyInto(out *USBDeviceStatusRef) { *out = new(USBAddress) **out = **in } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } return } diff --git a/crds/virtualmachines.yaml b/crds/virtualmachines.yaml index c674078a22..60b0a4b23d 100644 --- a/crds/virtualmachines.yaml +++ b/crds/virtualmachines.yaml @@ -987,6 +987,22 @@ spec: type: string description: | The name of the `VirtualMachineMACAddress` resource that is associated with the network interface. + usbDevices: + type: array + maxItems: 8 + description: | + List of USB devices to attach to the virtual machine. + Devices are referenced by name of USBDevice resource in the same namespace. + items: + type: object + required: + - name + properties: + name: + minLength: 1 + type: string + description: | + The name of USBDevice resource in the same namespace. status: type: object properties: @@ -1306,6 +1322,84 @@ spec: - size type: object type: object + usbDevices: + type: array + description: | + List of USB devices attached to the virtual machine. + items: + type: object + required: + - name + - attached + - ready + properties: + name: + type: string + description: | + The name of USBDevice resource. + attached: + type: boolean + description: | + The USB device is attached to the virtual machine. + ready: + type: boolean + description: | + USB device is ready to use. + address: + type: object + description: | + USB address inside the virtual machine. + properties: + bus: + type: integer + description: | + USB bus number (always 0 for the main USB controller). + port: + type: integer + description: | + USB port number on the selected bus. + hotplugged: + type: boolean + description: | + USB device is attached via hot plug connection. + conditions: + type: array + description: | + Conditions for this USB device. + items: + type: object + required: + - lastTransitionTime + - message + - reason + - status + - type + properties: + lastTransitionTime: + format: date-time + type: string + message: + maxLength: 32768 + type: string + observedGeneration: + format: int64 + minimum: 0 + type: integer + reason: + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + enum: + - "True" + - "False" + - Unknown + type: string + type: + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string networks: type: array description: | From c0465832874ae7a8667cd97ee8381435f704bc81 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Tue, 27 Jan 2026 07:56:45 +0200 Subject: [PATCH 22/53] fix errors Signed-off-by: Daniil Antoshin --- .../pkg/controller/nodeusbdevice/internal/discovery.go | 6 +++--- .../pkg/controller/nodeusbdevice/internal/ready.go | 4 ++-- .../pkg/controller/nodeusbdevice/internal/ready_test.go | 2 +- .../pkg/controller/usbdevice/internal/deletion.go | 5 ++--- .../pkg/controller/usbdevice/internal/state/state.go | 2 +- .../pkg/controller/usbdevice/usbdevice_webhook.go | 2 +- .../pkg/controller/vm/internal/usb_device_handler.go | 1 - 7 files changed, 10 insertions(+), 12 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go index fc7f3167f3..5dbb45369e 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go @@ -234,7 +234,7 @@ func (h *DiscoveryHandler) createNodeUSBDevice(ctx context.Context, attributes v return nil } -func (h *DiscoveryHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice, hash, nodeName string) (v1alpha2.NodeUSBDeviceAttributes, bool) { +func (h *DiscoveryHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice, searchedHash, nodeName string) (v1alpha2.NodeUSBDeviceAttributes, bool) { for _, slice := range slices { if slice.Spec.Pool.Name != nodeName { continue @@ -246,9 +246,9 @@ func (h *DiscoveryHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceS } attributes := h.convertDeviceToAttributes(device, nodeName) - deviceHash := h.calculateHash(attributes) + deviceHash := hash.CalculateHash(attributes) - if deviceHash == hash { + if deviceHash == searchedHash { return attributes, true } } diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go index a5b2644d5b..890bae401d 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go @@ -92,7 +92,7 @@ func (h *ReadyHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) ( return reconcile.Result{}, nil } -func (h *ReadyHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice, hash, nodeName string) bool { +func (h *ReadyHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice, searchedHash, nodeName string) bool { for _, slice := range slices { if slice.Spec.Pool.Name != nodeName { continue @@ -105,7 +105,7 @@ func (h *ReadyHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice // Calculate hash for this device and compare deviceHash := hash.CalculateHashFromDevice(device, nodeName) - if deviceHash == hash { + if deviceHash == searchedHash { return true } } diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready_test.go index 689f9947a3..3b9cfd5e12 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready_test.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready_test.go @@ -59,7 +59,7 @@ var _ = Describe("ReadyHandler", func() { Name: "slice-1", }, Spec: resourcev1beta1.ResourceSliceSpec{ - Driver: draDriverName, + Driver: "virtualization-dra", Pool: resourcev1beta1.ResourcePool{ Name: "node-1", }, diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go index 70322832fd..39cc0186ce 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go @@ -28,7 +28,6 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned" "github.com/deckhouse/virtualization/api/core/v1alpha2" - "github.com/deckhouse/virtualization/api/core/v1alpha2/usbdevicecondition" subv1alpha2 "github.com/deckhouse/virtualization/api/subresources/v1alpha2" ) @@ -75,7 +74,7 @@ func (h *DeletionHandler) Handle(ctx context.Context, s state.USBDeviceState) (r if len(vms) > 0 { // Device is attached to one or more VMs - perform hot unplug h.recorder.Eventf(changed, "Normal", "Deletion", "Device is attached to VM(s), performing hot unplug") - + for _, vm := range vms { // Remove ResourceClaim from VM opts := subv1alpha2.VirtualMachineRemoveResourceClaim{ @@ -88,7 +87,7 @@ func (h *DeletionHandler) Handle(ctx context.Context, s state.USBDeviceState) (r } h.recorder.Eventf(changed, "Normal", "Deletion", "Removed ResourceClaim from VM %s/%s", vm.Namespace, vm.Name) } - + // Requeue to verify that device is no longer attached return reconcile.Result{Requeue: true}, nil } diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go index e7e357660f..10f9df2f9b 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go @@ -21,8 +21,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" - "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" "github.com/deckhouse/virtualization/api/core/v1alpha2" ) diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_webhook.go b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_webhook.go index af4eb54f95..35ecaaa086 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_webhook.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_webhook.go @@ -55,7 +55,7 @@ func (v *Validator) ValidateCreate(ctx context.Context, obj runtime.Object) (adm // ValidateUpdate validates USBDevice updates. // Only status updates are allowed (performed by the controller). func (v *Validator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { - oldUSBDevice, ok := oldObj.(*v1alpha2.USBDevice) + _, ok := oldObj.(*v1alpha2.USBDevice) if !ok { return nil, fmt.Errorf("expected an old USBDevice but got a %T", oldObj) } diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go index 7c13b4ac77..99b5d43241 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go @@ -27,7 +27,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "github.com/deckhouse/deckhouse/pkg/log" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/vm/internal/state" "github.com/deckhouse/virtualization-controller/pkg/logger" From a7d8a1fb2e7c819464943b9a4aff9b3f67c044ff Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Tue, 27 Jan 2026 09:25:45 +0200 Subject: [PATCH 23/53] fix test Signed-off-by: Daniil Antoshin --- .../nodeusbdevice/internal/assigned_test.go | 8 +++++++- .../controller/nodeusbdevice/internal/hash/hash.go | 12 ++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go index a9ca9a644d..ff40ddcdde 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go @@ -150,7 +150,11 @@ var _ = Describe("AssignedHandler", func() { Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) Expect(corev1.AddToScheme(scheme)).To(Succeed()) - fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(nodeUSBDevice, namespace, existingUSBDevice).Build() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(nodeUSBDevice, namespace, existingUSBDevice). + WithStatusSubresource(&v1alpha2.USBDevice{}). + Build() nodeUSBDeviceResource = reconciler.NewResource( types.NamespacedName{Name: nodeUSBDevice.Name}, @@ -173,6 +177,8 @@ var _ = Describe("AssignedHandler", func() { err = fakeClient.Get(ctx, types.NamespacedName{Name: "usb-device-1", Namespace: "test-namespace"}, usbDevice) Expect(err).NotTo(HaveOccurred()) Expect(usbDevice.Status.Attributes.VendorID).To(Equal("1234")) + Expect(usbDevice.Status.Attributes.ProductID).To(Equal("5678")) + Expect(usbDevice.Status.NodeName).To(Equal("node-1")) }) }) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/hash/hash.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/hash/hash.go index 67a8cb34dd..586cc32a6e 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/hash/hash.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/hash/hash.go @@ -78,13 +78,13 @@ func CalculateHashFromDevice(device resourcev1beta1.Device, nodeName string) str } attrs := v1alpha2.NodeUSBDeviceAttributes{ - NodeName: nodeName, - VendorID: vendorID, - ProductID: productID, - Bus: bus, + NodeName: nodeName, + VendorID: vendorID, + ProductID: productID, + Bus: bus, DeviceNumber: deviceNumber, - Serial: serial, - DevicePath: devicePath, + Serial: serial, + DevicePath: devicePath, } return CalculateHash(attrs) From bc41fbc2b0de2a4389f1467d71ce8ae693eeac1e Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Tue, 27 Jan 2026 09:46:48 +0200 Subject: [PATCH 24/53] add indexers for tests Signed-off-by: Daniil Antoshin --- .../usbdevice/internal/deletion_test.go | 37 +++++++++++++++++-- .../usbdevice/internal/ready_test.go | 29 +++++++++++++-- .../usbdevice/internal/sync_test.go | 15 +++++++- 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go index a28953a689..08f7a3b944 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go @@ -29,6 +29,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" @@ -61,7 +62,14 @@ var _ = Describe("DeletionHandler", func() { scheme := apiruntime.NewScheme() Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) - fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice).Build() + vmObj, vmField, vmExtractValue := indexer.IndexVMByUSBDevice() + nodeUSBDeviceObj, nodeUSBDeviceField, nodeUSBDeviceExtractValue := indexer.IndexNodeUSBDeviceByName() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(usbDevice). + WithIndex(vmObj, vmField, vmExtractValue). + WithIndex(nodeUSBDeviceObj, nodeUSBDeviceField, nodeUSBDeviceExtractValue). + Build() usbDeviceResource = reconciler.NewResource( types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, @@ -109,7 +117,14 @@ var _ = Describe("DeletionHandler", func() { scheme := apiruntime.NewScheme() Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) - fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice).Build() + vmObj, vmField, vmExtractValue := indexer.IndexVMByUSBDevice() + nodeUSBDeviceObj, nodeUSBDeviceField, nodeUSBDeviceExtractValue := indexer.IndexNodeUSBDeviceByName() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(usbDevice). + WithIndex(vmObj, vmField, vmExtractValue). + WithIndex(nodeUSBDeviceObj, nodeUSBDeviceField, nodeUSBDeviceExtractValue). + Build() usbDeviceResource = reconciler.NewResource( types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, @@ -158,6 +173,13 @@ var _ = Describe("DeletionHandler", func() { Name: "test-vm", Namespace: "default", }, + Spec: v1alpha2.VirtualMachineSpec{ + USBDevices: []v1alpha2.USBDeviceSpecRef{ + { + Name: "usb-device-1", + }, + }, + }, Status: v1alpha2.VirtualMachineStatus{ USBDevices: []v1alpha2.USBDeviceStatusRef{ { @@ -171,7 +193,14 @@ var _ = Describe("DeletionHandler", func() { scheme := apiruntime.NewScheme() Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) - fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice, vm).Build() + vmObj, vmField, vmExtractValue := indexer.IndexVMByUSBDevice() + nodeUSBDeviceObj, nodeUSBDeviceField, nodeUSBDeviceExtractValue := indexer.IndexNodeUSBDeviceByName() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(usbDevice, vm). + WithIndex(vmObj, vmField, vmExtractValue). + WithIndex(nodeUSBDeviceObj, nodeUSBDeviceField, nodeUSBDeviceExtractValue). + Build() usbDeviceResource = reconciler.NewResource( types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, @@ -183,7 +212,7 @@ var _ = Describe("DeletionHandler", func() { usbDeviceState = state.New(fakeClient, usbDeviceResource) recorder := &eventrecord.EventRecorderLoggerMock{ - EventfFunc: func(involvedObject client.Object, eventtype, reason, messageFmt string, args ...interface{}) {}, + EventfFunc: func(involvedObject client.Object, eventtype, reason, messageFmt string, args ...any) {}, } fakeVirtClient := fakeversioned.NewSimpleClientset() handler = NewDeletionHandler(fakeClient, fakeVirtClient, recorder) diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go index e6225782fb..8d71661a33 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go @@ -29,6 +29,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" @@ -78,7 +79,12 @@ var _ = Describe("SyncReadyHandler - Ready condition", func() { scheme := apiruntime.NewScheme() Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) - fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice, nodeUSBDevice).Build() + obj, field, extractValue := indexer.IndexNodeUSBDeviceByName() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(usbDevice, nodeUSBDevice). + WithIndex(obj, field, extractValue). + Build() usbDeviceResource = reconciler.NewResource( types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, @@ -132,7 +138,12 @@ var _ = Describe("SyncReadyHandler - Ready condition", func() { scheme := apiruntime.NewScheme() Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) - fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice, nodeUSBDevice).Build() + obj, field, extractValue := indexer.IndexNodeUSBDeviceByName() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(usbDevice, nodeUSBDevice). + WithIndex(obj, field, extractValue). + Build() usbDeviceResource = reconciler.NewResource( types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, @@ -171,7 +182,12 @@ var _ = Describe("SyncReadyHandler - Ready condition", func() { scheme := apiruntime.NewScheme() Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) - fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice).Build() + obj, field, extractValue := indexer.IndexNodeUSBDeviceByName() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(usbDevice). + WithIndex(obj, field, extractValue). + Build() usbDeviceResource = reconciler.NewResource( types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, @@ -219,7 +235,12 @@ var _ = Describe("SyncReadyHandler - Ready condition", func() { scheme := apiruntime.NewScheme() Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) - fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice, nodeUSBDevice).Build() + obj, field, extractValue := indexer.IndexNodeUSBDeviceByName() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(usbDevice, nodeUSBDevice). + WithIndex(obj, field, extractValue). + Build() usbDeviceResource = reconciler.NewResource( types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go index bf26bddf7b..5d62feae1f 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go @@ -29,6 +29,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" @@ -79,7 +80,12 @@ var _ = Describe("SyncReadyHandler", func() { scheme := apiruntime.NewScheme() Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) - fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice, nodeUSBDevice).Build() + obj, field, extractValue := indexer.IndexNodeUSBDeviceByName() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(usbDevice, nodeUSBDevice). + WithIndex(obj, field, extractValue). + Build() usbDeviceResource = reconciler.NewResource( types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, @@ -123,7 +129,12 @@ var _ = Describe("SyncReadyHandler", func() { scheme := apiruntime.NewScheme() Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) - fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice).Build() + obj, field, extractValue := indexer.IndexNodeUSBDeviceByName() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(usbDevice). + WithIndex(obj, field, extractValue). + Build() usbDeviceResource = reconciler.NewResource( types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, From b20435eb5ac00a899a8f4f0d54fa737f07581812 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Tue, 27 Jan 2026 13:08:30 +0200 Subject: [PATCH 25/53] fix rbac Signed-off-by: Daniil Antoshin --- templates/virtualization-controller/rbac-for-us.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/templates/virtualization-controller/rbac-for-us.yaml b/templates/virtualization-controller/rbac-for-us.yaml index 73acbabd5a..263d12ea53 100644 --- a/templates/virtualization-controller/rbac-for-us.yaml +++ b/templates/virtualization-controller/rbac-for-us.yaml @@ -214,6 +214,8 @@ rules: - virtualdisksnapshots - virtualmachinesnapshots - virtualmachinerestores + - nodeusbdevices + - usbdevices verbs: - create - delete @@ -240,6 +242,8 @@ rules: - virtualdisksnapshots/finalizers - virtualmachinesnapshots/finalizers - virtualmachinerestores/finalizers + - nodeusbdevices/finalizers + - usbdevices/finalizers - virtualmachineipaddresses/status - virtualmachineipaddressleases/status - virtualmachinemacaddresses/status @@ -255,6 +259,8 @@ rules: - virtualdisksnapshots/status - virtualmachinesnapshots/status - virtualmachinerestores/status + - nodeusbdevices/status + - usbdevices/status verbs: - patch - update From 6ab5abbf2c2cd3d0241c59cc5ac16115bc51bcff Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Tue, 27 Jan 2026 13:58:49 +0200 Subject: [PATCH 26/53] fix rbac Signed-off-by: Daniil Antoshin --- templates/virtualization-controller/rbac-for-us.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/templates/virtualization-controller/rbac-for-us.yaml b/templates/virtualization-controller/rbac-for-us.yaml index 263d12ea53..ee64dc05e6 100644 --- a/templates/virtualization-controller/rbac-for-us.yaml +++ b/templates/virtualization-controller/rbac-for-us.yaml @@ -287,6 +287,14 @@ rules: - get - list - watch +- apiGroups: + - resource.k8s.io + resources: + - resourceslices + verbs: + - get + - list + - watch - apiGroups: - apiextensions.k8s.io resources: From 0471614cb0b862a2cfca5fe107166d522fcaa632 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Tue, 27 Jan 2026 14:28:33 +0200 Subject: [PATCH 27/53] fix Signed-off-by: Daniil Antoshin --- .../pkg/controller/nodeusbdevice/internal/discovery.go | 8 ++++---- .../pkg/controller/nodeusbdevice/internal/ready.go | 2 +- .../internal/watcher/resourceslice_watcher.go | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go index 5dbb45369e..c20479b3d8 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go @@ -111,7 +111,7 @@ func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context, s state.NodeUS deviceHashesInSlices := make(map[string]bool) for _, slice := range resourceSlices { for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "usb-") { + if !strings.HasPrefix(device.Name, "virtualization-dra-") { continue } hash := hash.CalculateHashFromDevice(device, slice.Spec.Pool.Name) @@ -142,7 +142,7 @@ func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context, s state.NodeUS // Only create devices that are in slices but not in existing for _, slice := range resourceSlices { for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "usb-") { + if !strings.HasPrefix(device.Name, "virtualization-dra-") { continue } @@ -177,7 +177,7 @@ func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context, s state.NodeUS // Note: resourceSlices are already filtered by draDriverName in state.ResourceSlices for _, slice := range resourceSlices { for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "usb-") { + if !strings.HasPrefix(device.Name, "virtualization-dra-") { continue } @@ -241,7 +241,7 @@ func (h *DiscoveryHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceS } for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "usb-") { + if !strings.HasPrefix(device.Name, "virtualization-dra-") { continue } diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go index 890bae401d..7ba80d4eb0 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go @@ -99,7 +99,7 @@ func (h *ReadyHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice } for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "usb-") { + if !strings.HasPrefix(device.Name, "virtualization-dra-") { continue } diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go index ab0d80ab4d..db038c789e 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go @@ -55,7 +55,7 @@ func (w *ResourceSliceWatcher) Watch(mgr manager.Manager, ctr controller.Control // Check if ResourceSlice contains USB devices hasUSBDevices := false for _, device := range slice.Spec.Devices { - if strings.HasPrefix(device.Name, "usb-") { + if strings.HasPrefix(device.Name, "virtualization-dra-") { hasUSBDevices = true break } From f8a150129fb6a595dfccd09943a9e4924857d4a1 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Tue, 27 Jan 2026 21:34:07 +0200 Subject: [PATCH 28/53] fix Signed-off-by: Daniil Antoshin --- api/scripts/update-codegen.sh | 2 +- crds/nodeusbdevices.yaml | 61 ++++++++++------------------------- 2 files changed, 18 insertions(+), 45 deletions(-) diff --git a/api/scripts/update-codegen.sh b/api/scripts/update-codegen.sh index 53b1ebff75..905b665369 100755 --- a/api/scripts/update-codegen.sh +++ b/api/scripts/update-codegen.sh @@ -41,7 +41,7 @@ function source::settings { "VirtualDisk" "VirtualImage" "ClusterVirtualImage" - "NodeUSBDevices" + "NodeUSBDevice" "USBDevice") source "${CODEGEN_PKG}/kube_codegen.sh" diff --git a/crds/nodeusbdevices.yaml b/crds/nodeusbdevices.yaml index fb62f20e66..197d501db8 100644 --- a/crds/nodeusbdevices.yaml +++ b/crds/nodeusbdevices.yaml @@ -23,36 +23,19 @@ spec: versions: - additionalPrinterColumns: - jsonPath: .status.nodeName - name: NODENAME + name: Node type: string - - jsonPath: .status.attributes.manufacturer - name: MANUFACTURER + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready type: string - - jsonPath: .status.attributes.product - name: PRODUCT + - jsonPath: .status.conditions[?(@.type=="Assigned")].status + name: Assigned type: string - - jsonPath: .status.attributes.vendorID - name: VENDORID - priority: 1 - type: string - - jsonPath: .status.attributes.productID - name: PRODUCTID - priority: 1 - type: string - - jsonPath: .status.attributes.bus - name: BUS - priority: 1 - type: string - - jsonPath: .status.attributes.deviceNumber - name: DEVICENUMBER - priority: 1 - type: string - - jsonPath: .status.attributes.serial - name: SERIAL - priority: 1 + - jsonPath: .spec.assignedNamespace + name: Namespace type: string - jsonPath: .metadata.creationTimestamp - name: AGE + name: Age type: date name: v1alpha2 schema: @@ -139,9 +122,11 @@ spec: type: string type: object conditions: - description: The latest available observations of an object's current state. + description: + The latest available observations of an object's current + state. items: - description: |- + description: Condition contains details for one aspect of the current state of this API Resource. properties: @@ -172,14 +157,6 @@ spec: and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. - For Ready condition type, possible values are: - * Ready - device is ready to use - * NotReady - device exists in the system but is not ready to use - * NotFound - device is absent on the host - For Assigned condition type, possible values are: - * Assigned - namespace is assigned for the device and corresponding USBDevice resource is created in this namespace - * Available - no namespace is assigned for the device - * InProgress - device connection to namespace is in progress (USBDevice resource creation) maxLength: 1024 minLength: 1 pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ @@ -192,15 +169,7 @@ spec: - Unknown type: string type: - description: |- - type of condition in CamelCase or in foo.example.com/CamelCase. - Supported condition types: - * Ready - indicates whether the device is ready to use. When reason is "Ready", status is "True". - When reason is "NotReady" or "NotFound", status is "False". When transitioning to NotFound, - the resource remains in the cluster, administrator can delete it manually. Based on lastTransitionTime, - a Garbage Collector can be implemented for automatic cleanup. - * Assigned - indicates whether a namespace is assigned for the device. When reason is "Assigned", - status is "True". When reason is "Available" or "InProgress", status is "False". + description: type of condition in CamelCase or in foo.example.com/CamelCase. maxLength: 316 pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ type: string @@ -215,6 +184,10 @@ spec: nodeName: description: Name of the node where the USB device is located. type: string + observedGeneration: + description: Resource generation last processed by the controller. + format: int64 + type: integer type: object required: - spec From 6102c878c09803488c7e0e77257fa8baf9ce4792 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Tue, 27 Jan 2026 21:44:11 +0200 Subject: [PATCH 29/53] prefix Signed-off-by: Daniil Antoshin --- .../pkg/controller/nodeusbdevice/internal/discovery.go | 8 ++++---- .../pkg/controller/nodeusbdevice/internal/ready.go | 2 +- .../internal/watcher/resourceslice_watcher.go | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go index c20479b3d8..5dbb45369e 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go @@ -111,7 +111,7 @@ func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context, s state.NodeUS deviceHashesInSlices := make(map[string]bool) for _, slice := range resourceSlices { for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "virtualization-dra-") { + if !strings.HasPrefix(device.Name, "usb-") { continue } hash := hash.CalculateHashFromDevice(device, slice.Spec.Pool.Name) @@ -142,7 +142,7 @@ func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context, s state.NodeUS // Only create devices that are in slices but not in existing for _, slice := range resourceSlices { for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "virtualization-dra-") { + if !strings.HasPrefix(device.Name, "usb-") { continue } @@ -177,7 +177,7 @@ func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context, s state.NodeUS // Note: resourceSlices are already filtered by draDriverName in state.ResourceSlices for _, slice := range resourceSlices { for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "virtualization-dra-") { + if !strings.HasPrefix(device.Name, "usb-") { continue } @@ -241,7 +241,7 @@ func (h *DiscoveryHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceS } for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "virtualization-dra-") { + if !strings.HasPrefix(device.Name, "usb-") { continue } diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go index 7ba80d4eb0..890bae401d 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go @@ -99,7 +99,7 @@ func (h *ReadyHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice } for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "virtualization-dra-") { + if !strings.HasPrefix(device.Name, "usb-") { continue } diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go index db038c789e..ab0d80ab4d 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go @@ -55,7 +55,7 @@ func (w *ResourceSliceWatcher) Watch(mgr manager.Manager, ctr controller.Control // Check if ResourceSlice contains USB devices hasUSBDevices := false for _, device := range slice.Spec.Devices { - if strings.HasPrefix(device.Name, "virtualization-dra-") { + if strings.HasPrefix(device.Name, "usb-") { hasUSBDevices = true break } From 14510a5955b805636c69792463ae7f96ded22fce Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Tue, 27 Jan 2026 22:11:50 +0200 Subject: [PATCH 30/53] fix discovery Signed-off-by: Daniil Antoshin --- .../internal/watcher/resourceslice_watcher.go | 32 +++++++++++-------- .../nodeusbdevice/nodeusbdevice_reconciler.go | 21 ++++++++++-- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go index ab0d80ab4d..993f9233e8 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go @@ -21,6 +21,7 @@ import ( "strings" resourcev1beta1 "k8s.io/api/resource/v1beta1" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" @@ -87,21 +88,26 @@ func (w *ResourceSliceWatcher) Watch(mgr manager.Manager, ctr controller.Control } } - // If no devices exist on this node yet, trigger discovery by enqueueing - // any existing NodeUSBDevice to trigger a reconciliation cycle. - // DiscoveryHandler will check all ResourceSlices during reconciliation - // and automatically create new NodeUSBDevice for devices found in this ResourceSlice. - if !hasDevicesOnNode && len(deviceList.Items) > 0 { - // Enqueue first device to trigger reconciliation cycle - // DiscoveryHandler will discover new devices during this cycle - result = append(result, reconcile.Request{ - NamespacedName: object.NamespacedName(&deviceList.Items[0]), - }) + // If no devices exist on this node yet, trigger discovery + if !hasDevicesOnNode { + if len(deviceList.Items) > 0 { + // Enqueue first device to trigger reconciliation cycle + // DiscoveryHandler will discover new devices during this cycle + result = append(result, reconcile.Request{ + NamespacedName: object.NamespacedName(&deviceList.Items[0]), + }) + } else { + // If no NodeUSBDevices exist at all, create a dummy reconcile request + // to trigger discovery. The reconciler will handle empty NodeUSBDevice + // and run DiscoveryHandler to discover new devices. + result = append(result, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "discovery-" + slice.Spec.Pool.Name, + }, + }) + } } - // Note: If no NodeUSBDevices exist at all, discovery will happen - // on next periodic reconciliation or when controller starts - return result }), ), diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go index fd4eef8288..ecb464df00 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go @@ -19,6 +19,7 @@ package nodeusbdevice import ( "context" "fmt" + "log/slog" "reflect" "sigs.k8s.io/controller-runtime/pkg/client" @@ -88,13 +89,27 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco return reconcile.Result{}, err } + s := state.New(r.client, nodeUSBDevice) + + // If NodeUSBDevice doesn't exist, only run DiscoveryHandler to discover new devices if nodeUSBDevice.IsEmpty() { - log.Info("Reconcile observe an absent NodeUSBDevice: it may be deleted") + log.Info("Reconcile observe an absent NodeUSBDevice: running discovery to find new devices") + + // Find DiscoveryHandler and run it + for _, handler := range r.handlers { + if handler.Name() == "DiscoveryHandler" { + result, err := handler.Handle(ctx, s) + if err != nil { + log.Error("DiscoveryHandler failed", slog.Attr{Key: "error", Value: slog.StringValue(err.Error())}) + } + return result, err + } + } + + // If DiscoveryHandler not found, return return reconcile.Result{}, nil } - s := state.New(r.client, nodeUSBDevice) - rec := reconciler.NewBaseReconciler[Handler](r.handlers) rec.SetHandlerExecutor(func(ctx context.Context, h Handler) (reconcile.Result, error) { return h.Handle(ctx, s) From 982559ff625883a60289392005a32bb4f3136a5f Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Tue, 27 Jan 2026 22:13:33 +0200 Subject: [PATCH 31/53] check if exist Signed-off-by: Daniil Antoshin --- .../nodeusbdevice/internal/discovery.go | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go index 5dbb45369e..38b779baef 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go @@ -22,6 +22,7 @@ import ( "strings" resourcev1beta1 "k8s.io/api/resource/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -198,9 +199,23 @@ func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context, s state.NodeUS } func (h *DiscoveryHandler) createNodeUSBDevice(ctx context.Context, attributes v1alpha2.NodeUSBDeviceAttributes, hash string) error { + name := h.generateName(hash, attributes.NodeName) + + // Check if device already exists + existing := &v1alpha2.NodeUSBDevice{} + err := h.client.Get(ctx, client.ObjectKey{Name: name}, existing) + if err == nil { + // Device already exists, skip creation + return nil + } + if !apierrors.IsNotFound(err) { + // Unexpected error + return fmt.Errorf("failed to check if NodeUSBDevice exists: %w", err) + } + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ ObjectMeta: metav1.ObjectMeta{ - Name: h.generateName(hash, attributes.NodeName), + Name: name, }, Spec: v1alpha2.NodeUSBDeviceSpec{ AssignedNamespace: "", @@ -228,6 +243,10 @@ func (h *DiscoveryHandler) createNodeUSBDevice(ctx context.Context, attributes v } if err := h.client.Create(ctx, nodeUSBDevice); err != nil { + // If device was created by another process between check and create, ignore the error + if apierrors.IsAlreadyExists(err) { + return nil + } return fmt.Errorf("failed to create NodeUSBDevice: %w", err) } From 4b3404d41045b94376bda718921be69b3adb6a50 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Wed, 28 Jan 2026 00:25:11 +0200 Subject: [PATCH 32/53] fix subresource update Signed-off-by: Daniil Antoshin --- .../nodeusbdevice/internal/assigned.go | 15 ++++-- .../nodeusbdevice/internal/discovery.go | 51 +++++++++++-------- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go index 3f7aeafe5f..561ed7ec12 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go @@ -176,6 +176,7 @@ func (h *AssignedHandler) ensureUSBDevice(ctx context.Context, nodeUSBDevice *v1 } // USBDevice doesn't exist - create it + // Create USBDevice without status (status is a subresource) usbDevice = &v1alpha2.USBDevice{ ObjectMeta: metav1.ObjectMeta{ Name: nodeUSBDevice.Name, @@ -190,16 +191,22 @@ func (h *AssignedHandler) ensureUSBDevice(ctx context.Context, nodeUSBDevice *v1 }, }, }, - Status: v1alpha2.USBDeviceStatus{ - Attributes: nodeUSBDevice.Status.Attributes, - NodeName: nodeUSBDevice.Status.NodeName, - }, } if err := h.client.Create(ctx, usbDevice); err != nil { return nil, fmt.Errorf("failed to create USBDevice: %w", err) } + // Update status separately (status is a subresource) + usbDevice.Status = v1alpha2.USBDeviceStatus{ + Attributes: nodeUSBDevice.Status.Attributes, + NodeName: nodeUSBDevice.Status.NodeName, + } + + if err := h.client.Status().Update(ctx, usbDevice); err != nil { + return nil, fmt.Errorf("failed to update USBDevice status: %w", err) + } + return usbDevice, nil } diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go index 38b779baef..1d0d64a451 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go @@ -200,7 +200,7 @@ func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context, s state.NodeUS func (h *DiscoveryHandler) createNodeUSBDevice(ctx context.Context, attributes v1alpha2.NodeUSBDeviceAttributes, hash string) error { name := h.generateName(hash, attributes.NodeName) - + // Check if device already exists existing := &v1alpha2.NodeUSBDevice{} err := h.client.Get(ctx, client.ObjectKey{Name: name}, existing) @@ -213,6 +213,7 @@ func (h *DiscoveryHandler) createNodeUSBDevice(ctx context.Context, attributes v return fmt.Errorf("failed to check if NodeUSBDevice exists: %w", err) } + // Create NodeUSBDevice without status (status is a subresource) nodeUSBDevice := &v1alpha2.NodeUSBDevice{ ObjectMeta: metav1.ObjectMeta{ Name: name, @@ -220,26 +221,6 @@ func (h *DiscoveryHandler) createNodeUSBDevice(ctx context.Context, attributes v Spec: v1alpha2.NodeUSBDeviceSpec{ AssignedNamespace: "", }, - Status: v1alpha2.NodeUSBDeviceStatus{ - Attributes: attributes, - NodeName: attributes.NodeName, - Conditions: []metav1.Condition{ - { - Type: string(nodeusbdevicecondition.ReadyType), - Status: metav1.ConditionTrue, - Reason: string(nodeusbdevicecondition.Ready), - Message: "Device is ready to use", - LastTransitionTime: metav1.Now(), - }, - { - Type: string(nodeusbdevicecondition.AssignedType), - Status: metav1.ConditionFalse, - Reason: string(nodeusbdevicecondition.Available), - Message: "No namespace is assigned for the device", - LastTransitionTime: metav1.Now(), - }, - }, - }, } if err := h.client.Create(ctx, nodeUSBDevice); err != nil { @@ -250,6 +231,34 @@ func (h *DiscoveryHandler) createNodeUSBDevice(ctx context.Context, attributes v return fmt.Errorf("failed to create NodeUSBDevice: %w", err) } + // Update status separately (status is a subresource) + // Set all attributes including Hash + attributes.Hash = hash + nodeUSBDevice.Status = v1alpha2.NodeUSBDeviceStatus{ + Attributes: attributes, + NodeName: attributes.NodeName, + Conditions: []metav1.Condition{ + { + Type: string(nodeusbdevicecondition.ReadyType), + Status: metav1.ConditionTrue, + Reason: string(nodeusbdevicecondition.Ready), + Message: "Device is ready to use", + LastTransitionTime: metav1.Now(), + }, + { + Type: string(nodeusbdevicecondition.AssignedType), + Status: metav1.ConditionFalse, + Reason: string(nodeusbdevicecondition.Available), + Message: "No namespace is assigned for the device", + LastTransitionTime: metav1.Now(), + }, + }, + } + + if err := h.client.Status().Update(ctx, nodeUSBDevice); err != nil { + return fmt.Errorf("failed to update NodeUSBDevice status: %w", err) + } + return nil } From ec8927339aa566d56e4d7d84b42d07a6c90c2f2d Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Wed, 28 Jan 2026 00:49:25 +0200 Subject: [PATCH 33/53] fix subresource Signed-off-by: Daniil Antoshin --- api/core/v1alpha2/usb_device.go | 1 - crds/usbdevices.yaml | 3 +-- .../nodeusbdevice/internal/assigned.go | 21 +++++++------------ .../rbac-for-us.yaml | 1 + 4 files changed, 9 insertions(+), 17 deletions(-) diff --git a/api/core/v1alpha2/usb_device.go b/api/core/v1alpha2/usb_device.go index dc76f3d32e..832bb5f369 100644 --- a/api/core/v1alpha2/usb_device.go +++ b/api/core/v1alpha2/usb_device.go @@ -30,7 +30,6 @@ const ( // +kubebuilder:object:root=true // +kubebuilder:metadata:labels={heritage=deckhouse,module=virtualization} // +kubebuilder:resource:categories={virtualization},scope=Namespaced,shortName={usb},singular=usbdevice -// +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Node",type=string,JSONPath=`.status.nodeName` // +kubebuilder:printcolumn:name="VendorID",type=string,JSONPath=`.status.attributes.vendorID`,priority=1 // +kubebuilder:printcolumn:name="ProductID",type=string,JSONPath=`.status.attributes.productID`,priority=1 diff --git a/crds/usbdevices.yaml b/crds/usbdevices.yaml index 4a3b2750f7..161617ac80 100644 --- a/crds/usbdevices.yaml +++ b/crds/usbdevices.yaml @@ -203,5 +203,4 @@ spec: type: object served: true storage: true - subresources: - status: {} + subresources: {} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go index 561ed7ec12..07ae5fcea0 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go @@ -164,8 +164,8 @@ func (h *AssignedHandler) ensureUSBDevice(ctx context.Context, nodeUSBDevice *v1 if needsUpdate { usbDevice.Status.Attributes = nodeUSBDevice.Status.Attributes usbDevice.Status.NodeName = nodeUSBDevice.Status.NodeName - if err := h.client.Status().Update(ctx, usbDevice); err != nil { - return nil, fmt.Errorf("failed to update USBDevice status: %w", err) + if err := h.client.Update(ctx, usbDevice); err != nil { + return nil, fmt.Errorf("failed to update USBDevice: %w", err) } } return usbDevice, nil @@ -175,8 +175,7 @@ func (h *AssignedHandler) ensureUSBDevice(ctx context.Context, nodeUSBDevice *v1 return nil, fmt.Errorf("failed to get USBDevice: %w", err) } - // USBDevice doesn't exist - create it - // Create USBDevice without status (status is a subresource) + // USBDevice doesn't exist - create it with status usbDevice = &v1alpha2.USBDevice{ ObjectMeta: metav1.ObjectMeta{ Name: nodeUSBDevice.Name, @@ -191,22 +190,16 @@ func (h *AssignedHandler) ensureUSBDevice(ctx context.Context, nodeUSBDevice *v1 }, }, }, + Status: v1alpha2.USBDeviceStatus{ + Attributes: nodeUSBDevice.Status.Attributes, + NodeName: nodeUSBDevice.Status.NodeName, + }, } if err := h.client.Create(ctx, usbDevice); err != nil { return nil, fmt.Errorf("failed to create USBDevice: %w", err) } - // Update status separately (status is a subresource) - usbDevice.Status = v1alpha2.USBDeviceStatus{ - Attributes: nodeUSBDevice.Status.Attributes, - NodeName: nodeUSBDevice.Status.NodeName, - } - - if err := h.client.Status().Update(ctx, usbDevice); err != nil { - return nil, fmt.Errorf("failed to update USBDevice status: %w", err) - } - return usbDevice, nil } diff --git a/templates/virtualization-controller/rbac-for-us.yaml b/templates/virtualization-controller/rbac-for-us.yaml index ee64dc05e6..001158abda 100644 --- a/templates/virtualization-controller/rbac-for-us.yaml +++ b/templates/virtualization-controller/rbac-for-us.yaml @@ -22,6 +22,7 @@ rules: - services - secrets - configmaps + - namespaces verbs: - get - create From b08c10655b14e518da00db6cbe56a4a65f31a34a Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Wed, 28 Jan 2026 01:19:29 +0200 Subject: [PATCH 34/53] update rbac Signed-off-by: Daniil Antoshin --- .../nodeusbdevice/nodeusbdevice_webhook.go | 39 +++++++++++----- .../usbdevice/usbdevice_controller.go | 9 ---- .../controller/usbdevice/usbdevice_webhook.go | 24 +++++----- templates/admission-policy.yaml | 44 +++++++++++++++++++ .../validation-webhook.yaml | 19 +------- 5 files changed, 86 insertions(+), 49 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go index 2ec01e7142..085e882095 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go @@ -19,8 +19,10 @@ package nodeusbdevice import ( "context" "fmt" + "reflect" "k8s.io/apimachinery/pkg/runtime" + "maps" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" "github.com/deckhouse/deckhouse/pkg/log" @@ -54,8 +56,8 @@ func (v *Validator) ValidateCreate(ctx context.Context, obj runtime.Object) (adm } // ValidateUpdate validates NodeUSBDevice updates. -// Only spec.assignedNamespace can be changed by administrators. -// Status updates are performed by the controller. +// Only spec can be changed by administrators. Metadata cannot be modified. +// Status updates are performed by the controller via subresource. func (v *Validator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { oldNodeUSBDevice, ok := oldObj.(*v1alpha2.NodeUSBDevice) if !ok { @@ -69,16 +71,33 @@ func (v *Validator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.O v.log.Info("Validate NodeUSBDevice updating", "name", newNodeUSBDevice.Name) - // Only spec.assignedNamespace can be changed by administrators - // Status is managed by the controller - // If spec changed in a way other than assignedNamespace, reject - if oldNodeUSBDevice.Spec.AssignedNamespace != newNodeUSBDevice.Spec.AssignedNamespace { - // This is allowed - administrators can assign/unassign namespaces - return nil, nil + // Metadata cannot be modified + if oldNodeUSBDevice.Name != newNodeUSBDevice.Name { + return nil, fmt.Errorf("metadata.name cannot be changed") + } + if oldNodeUSBDevice.Namespace != newNodeUSBDevice.Namespace { + return nil, fmt.Errorf("metadata.namespace cannot be changed") + } + if oldNodeUSBDevice.UID != newNodeUSBDevice.UID { + return nil, fmt.Errorf("metadata.uid cannot be changed") + } + if !maps.Equal(oldNodeUSBDevice.Labels, newNodeUSBDevice.Labels) { + return nil, fmt.Errorf("metadata.labels cannot be changed") + } + if !maps.Equal(oldNodeUSBDevice.Annotations, newNodeUSBDevice.Annotations) { + return nil, fmt.Errorf("metadata.annotations cannot be changed") + } + if !reflect.DeepEqual(oldNodeUSBDevice.Finalizers, newNodeUSBDevice.Finalizers) { + return nil, fmt.Errorf("metadata.finalizers cannot be changed") + } + + // Status changes are not allowed via main resource update (use /status subresource) + if oldNodeUSBDevice.Status != newNodeUSBDevice.Status { + return nil, fmt.Errorf("status cannot be changed via main resource update, use /status subresource") } - // Status changes are allowed (performed by the controller) - // Spec changes other than assignedNamespace are not allowed + // Only spec can be changed + // This is allowed - administrators can modify spec (e.g., assignedNamespace) return nil, nil } diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go index d333f6506a..584c623f42 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go @@ -21,7 +21,6 @@ import ( "time" "k8s.io/utils/ptr" - "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/manager" @@ -30,7 +29,6 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization-controller/pkg/logger" "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned" - "github.com/deckhouse/virtualization/api/core/v1alpha2" ) const ( @@ -73,13 +71,6 @@ func NewController( return nil, err } - if err = builder.WebhookManagedBy(mgr). - For(&v1alpha2.USBDevice{}). - WithValidator(NewValidator(log)). - Complete(); err != nil { - return nil, err - } - log.Info("Initialized USBDevice controller") return c, nil } diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_webhook.go b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_webhook.go index 35ecaaa086..425ae9886a 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_webhook.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_webhook.go @@ -27,18 +27,18 @@ import ( "github.com/deckhouse/virtualization/api/core/v1alpha2" ) +type Validator struct { + log *log.Logger +} + func NewValidator(log *log.Logger) *Validator { return &Validator{ log: log.With("webhook", "validation"), } } -type Validator struct { - log *log.Logger -} - // ValidateCreate validates USBDevice creation. -// USBDevice resources are managed by the controller and should not be created by users. +// Access control is handled by RBAC - only the controller ServiceAccount has create permissions. func (v *Validator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { usbDevice, ok := obj.(*v1alpha2.USBDevice) if !ok { @@ -47,9 +47,9 @@ func (v *Validator) ValidateCreate(ctx context.Context, obj runtime.Object) (adm v.log.Info("Validate USBDevice creating", "name", usbDevice.Name, "namespace", usbDevice.Namespace) - // USBDevice resources are created automatically by the controller - // Users should not create them directly - return nil, fmt.Errorf("USBDevice resources are managed by the controller and cannot be created manually. Use NodeUSBDevice to assign devices to namespaces") + // RBAC controls access - only the controller ServiceAccount can create USBDevice + // No additional validation needed here + return nil, nil } // ValidateUpdate validates USBDevice updates. @@ -74,7 +74,7 @@ func (v *Validator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.O } // ValidateDelete validates USBDevice deletion. -// USBDevice resources are managed by the controller and should not be deleted by users. +// Access control is handled by RBAC - only the controller ServiceAccount has delete permissions. func (v *Validator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { usbDevice, ok := obj.(*v1alpha2.USBDevice) if !ok { @@ -83,7 +83,7 @@ func (v *Validator) ValidateDelete(ctx context.Context, obj runtime.Object) (adm v.log.Info("Validate USBDevice deleting", "name", usbDevice.Name, "namespace", usbDevice.Namespace) - // USBDevice resources are deleted automatically by the controller - // Users should not delete them directly - return nil, fmt.Errorf("USBDevice resources are managed by the controller and cannot be deleted manually. Modify NodeUSBDevice.spec.assignedNamespace to remove the device from a namespace") + // RBAC controls access - only the controller ServiceAccount can delete USBDevice + // No additional validation needed here + return nil, nil } diff --git a/templates/admission-policy.yaml b/templates/admission-policy.yaml index 597b40fb55..86a2885434 100644 --- a/templates/admission-policy.yaml +++ b/templates/admission-policy.yaml @@ -28,6 +28,7 @@ spec: - "pool.internal.virtualization.deckhouse.io" - "snapshot.internal.virtualization.deckhouse.io" - "migrations.internal.virtualization.deckhouse.io" + - "nodeusbdevice.internal.virtualization.deckhouse.io" apiVersions: ["*"] operations: - "CREATE" @@ -68,4 +69,47 @@ spec: matchResources: namespaceSelector: {} objectSelector: {} +--- +apiVersion: {{ $apiVersion }} +kind: ValidatingAdmissionPolicy +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: virtualization-usbdevice-access-policy +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: + - "virtualization.deckhouse.io" + apiVersions: ["v1alpha2"] + operations: + - "CREATE" + - "DELETE" + resources: + - "nodeusbdevices" + - "usbdevices" + - apiGroups: + - "virtualization.deckhouse.io" + apiVersions: ["v1alpha2"] + operations: + - "UPDATE" + resources: + - "usbdevices" + validations: + - expression: | + request.userInfo.username.startsWith("system:serviceaccount:d8") + message: "NodeUSBDevice and USBDevice resources can only be created, deleted, or have their status updated by ServiceAccounts with 'd8' prefix. Only spec updates are allowed for other users." +--- +apiVersion: {{ $apiVersion }} +kind: ValidatingAdmissionPolicyBinding +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: virtualization-usbdevice-access-policy-binding +spec: + policyName: virtualization-usbdevice-access-policy + validationActions: + - "Deny" + matchResources: + namespaceSelector: {} + objectSelector: {} {{- end }} diff --git a/templates/virtualization-controller/validation-webhook.yaml b/templates/virtualization-controller/validation-webhook.yaml index 025be822ca..5c20c11827 100644 --- a/templates/virtualization-controller/validation-webhook.yaml +++ b/templates/virtualization-controller/validation-webhook.yaml @@ -267,28 +267,11 @@ webhooks: - name: 'match-virtualization' expression: 'request.name == "virtualization"' {{- end }} - - name: "usbdevice.virtualization-controller.validate.d8-virtualization" - rules: - - apiGroups: ["virtualization.deckhouse.io"] - apiVersions: ["v1alpha2"] - operations: ["CREATE", "UPDATE", "DELETE"] - resources: ["usbdevices"] - scope: "Namespaced" - clientConfig: - service: - namespace: d8-{{ .Chart.Name }} - name: virtualization-controller - path: /validate-virtualization-deckhouse-io-v1alpha2-usbdevice - port: 443 - caBundle: | - {{ .Values.virtualization.internal.controller.cert.ca | b64enc }} - admissionReviewVersions: ["v1"] - sideEffects: None - name: "nodeusbdevice.virtualization-controller.validate.d8-virtualization" rules: - apiGroups: ["virtualization.deckhouse.io"] apiVersions: ["v1alpha2"] - operations: ["CREATE", "UPDATE", "DELETE"] + operations: ["UPDATE"] resources: ["nodeusbdevices"] scope: "Cluster" clientConfig: From 8ac401db9186b7aa98f1239dd25e91301af533ce Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Wed, 28 Jan 2026 01:25:02 +0200 Subject: [PATCH 35/53] fix rbac Signed-off-by: Daniil Antoshin --- .../controller/nodeusbdevice/nodeusbdevice_webhook.go | 11 ++++++++--- templates/admission-policy.yaml | 3 ++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go index 085e882095..85a6403cd7 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go @@ -19,10 +19,10 @@ package nodeusbdevice import ( "context" "fmt" + "maps" "reflect" "k8s.io/apimachinery/pkg/runtime" - "maps" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" "github.com/deckhouse/deckhouse/pkg/log" @@ -71,7 +71,12 @@ func (v *Validator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.O v.log.Info("Validate NodeUSBDevice updating", "name", newNodeUSBDevice.Name) - // Metadata cannot be modified + // TypeMeta cannot be modified + if !reflect.DeepEqual(oldNodeUSBDevice.TypeMeta, newNodeUSBDevice.TypeMeta) { + return nil, fmt.Errorf("TypeMeta cannot be changed") + } + + // Metadata cannot be modified (except fields that Kubernetes manages automatically) if oldNodeUSBDevice.Name != newNodeUSBDevice.Name { return nil, fmt.Errorf("metadata.name cannot be changed") } @@ -92,7 +97,7 @@ func (v *Validator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.O } // Status changes are not allowed via main resource update (use /status subresource) - if oldNodeUSBDevice.Status != newNodeUSBDevice.Status { + if !reflect.DeepEqual(oldNodeUSBDevice.Status, newNodeUSBDevice.Status) { return nil, fmt.Errorf("status cannot be changed via main resource update, use /status subresource") } diff --git a/templates/admission-policy.yaml b/templates/admission-policy.yaml index 86a2885434..bc2cb5b115 100644 --- a/templates/admission-policy.yaml +++ b/templates/admission-policy.yaml @@ -94,11 +94,12 @@ spec: operations: - "UPDATE" resources: + - "nodeusbdevices/status" - "usbdevices" validations: - expression: | request.userInfo.username.startsWith("system:serviceaccount:d8") - message: "NodeUSBDevice and USBDevice resources can only be created, deleted, or have their status updated by ServiceAccounts with 'd8' prefix. Only spec updates are allowed for other users." + message: "NodeUSBDevice and USBDevice resources can only be created, deleted, or updated by ServiceAccounts with 'd8' prefix. NodeUSBDevice status can only be updated by ServiceAccounts with 'd8' prefix. Only spec updates are allowed for other users on NodeUSBDevice." --- apiVersion: {{ $apiVersion }} kind: ValidatingAdmissionPolicyBinding From 2f017af324b140eb0e060e021ac357578afdc727 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Wed, 28 Jan 2026 01:45:27 +0200 Subject: [PATCH 36/53] Revert "fix subresource" This reverts commit ec8927339aa566d56e4d7d84b42d07a6c90c2f2d. Signed-off-by: Daniil Antoshin --- api/core/v1alpha2/usb_device.go | 1 + crds/usbdevices.yaml | 3 ++- .../nodeusbdevice/internal/assigned.go | 21 ++++++++++++------- templates/admission-policy.yaml | 2 +- .../rbac-for-us.yaml | 1 - 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/api/core/v1alpha2/usb_device.go b/api/core/v1alpha2/usb_device.go index 832bb5f369..dc76f3d32e 100644 --- a/api/core/v1alpha2/usb_device.go +++ b/api/core/v1alpha2/usb_device.go @@ -30,6 +30,7 @@ const ( // +kubebuilder:object:root=true // +kubebuilder:metadata:labels={heritage=deckhouse,module=virtualization} // +kubebuilder:resource:categories={virtualization},scope=Namespaced,shortName={usb},singular=usbdevice +// +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Node",type=string,JSONPath=`.status.nodeName` // +kubebuilder:printcolumn:name="VendorID",type=string,JSONPath=`.status.attributes.vendorID`,priority=1 // +kubebuilder:printcolumn:name="ProductID",type=string,JSONPath=`.status.attributes.productID`,priority=1 diff --git a/crds/usbdevices.yaml b/crds/usbdevices.yaml index 161617ac80..4a3b2750f7 100644 --- a/crds/usbdevices.yaml +++ b/crds/usbdevices.yaml @@ -203,4 +203,5 @@ spec: type: object served: true storage: true - subresources: {} + subresources: + status: {} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go index 07ae5fcea0..561ed7ec12 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go @@ -164,8 +164,8 @@ func (h *AssignedHandler) ensureUSBDevice(ctx context.Context, nodeUSBDevice *v1 if needsUpdate { usbDevice.Status.Attributes = nodeUSBDevice.Status.Attributes usbDevice.Status.NodeName = nodeUSBDevice.Status.NodeName - if err := h.client.Update(ctx, usbDevice); err != nil { - return nil, fmt.Errorf("failed to update USBDevice: %w", err) + if err := h.client.Status().Update(ctx, usbDevice); err != nil { + return nil, fmt.Errorf("failed to update USBDevice status: %w", err) } } return usbDevice, nil @@ -175,7 +175,8 @@ func (h *AssignedHandler) ensureUSBDevice(ctx context.Context, nodeUSBDevice *v1 return nil, fmt.Errorf("failed to get USBDevice: %w", err) } - // USBDevice doesn't exist - create it with status + // USBDevice doesn't exist - create it + // Create USBDevice without status (status is a subresource) usbDevice = &v1alpha2.USBDevice{ ObjectMeta: metav1.ObjectMeta{ Name: nodeUSBDevice.Name, @@ -190,16 +191,22 @@ func (h *AssignedHandler) ensureUSBDevice(ctx context.Context, nodeUSBDevice *v1 }, }, }, - Status: v1alpha2.USBDeviceStatus{ - Attributes: nodeUSBDevice.Status.Attributes, - NodeName: nodeUSBDevice.Status.NodeName, - }, } if err := h.client.Create(ctx, usbDevice); err != nil { return nil, fmt.Errorf("failed to create USBDevice: %w", err) } + // Update status separately (status is a subresource) + usbDevice.Status = v1alpha2.USBDeviceStatus{ + Attributes: nodeUSBDevice.Status.Attributes, + NodeName: nodeUSBDevice.Status.NodeName, + } + + if err := h.client.Status().Update(ctx, usbDevice); err != nil { + return nil, fmt.Errorf("failed to update USBDevice status: %w", err) + } + return usbDevice, nil } diff --git a/templates/admission-policy.yaml b/templates/admission-policy.yaml index bc2cb5b115..3734503655 100644 --- a/templates/admission-policy.yaml +++ b/templates/admission-policy.yaml @@ -95,7 +95,7 @@ spec: - "UPDATE" resources: - "nodeusbdevices/status" - - "usbdevices" + - "usbdevices/status" validations: - expression: | request.userInfo.username.startsWith("system:serviceaccount:d8") diff --git a/templates/virtualization-controller/rbac-for-us.yaml b/templates/virtualization-controller/rbac-for-us.yaml index 001158abda..ee64dc05e6 100644 --- a/templates/virtualization-controller/rbac-for-us.yaml +++ b/templates/virtualization-controller/rbac-for-us.yaml @@ -22,7 +22,6 @@ rules: - services - secrets - configmaps - - namespaces verbs: - get - create From 01490e8530a6e17dd2ddaa044eb6587245c60ba9 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Wed, 28 Jan 2026 01:56:42 +0200 Subject: [PATCH 37/53] add ns Signed-off-by: Daniil Antoshin --- templates/virtualization-controller/rbac-for-us.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/virtualization-controller/rbac-for-us.yaml b/templates/virtualization-controller/rbac-for-us.yaml index ee64dc05e6..001158abda 100644 --- a/templates/virtualization-controller/rbac-for-us.yaml +++ b/templates/virtualization-controller/rbac-for-us.yaml @@ -22,6 +22,7 @@ rules: - services - secrets - configmaps + - namespaces verbs: - get - create From d9f56e410b94598054614e6691f003abce428f73 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Wed, 28 Jan 2026 02:06:14 +0200 Subject: [PATCH 38/53] fix err Signed-off-by: Daniil Antoshin --- .../pkg/controller/nodeusbdevice/internal/assigned_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go index ff40ddcdde..c665c158a3 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go @@ -60,6 +60,7 @@ var _ = Describe("AssignedHandler", func() { nodeUSBDevice := &v1alpha2.NodeUSBDevice{ ObjectMeta: metav1.ObjectMeta{ Name: "usb-device-1", + UID: types.UID("node-usb-device-uid-1"), }, Spec: v1alpha2.NodeUSBDeviceSpec{ AssignedNamespace: "test-namespace", @@ -77,7 +78,11 @@ var _ = Describe("AssignedHandler", func() { Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) Expect(corev1.AddToScheme(scheme)).To(Succeed()) - fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(nodeUSBDevice, namespace).Build() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(nodeUSBDevice, namespace). + WithStatusSubresource(&v1alpha2.USBDevice{}). + Build() nodeUSBDeviceResource = reconciler.NewResource( types.NamespacedName{Name: nodeUSBDevice.Name}, From be86023d64b3bdfb39f154bb53a99f489608db39 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Wed, 28 Jan 2026 02:19:43 +0200 Subject: [PATCH 39/53] fix rbac Signed-off-by: Daniil Antoshin --- templates/virtualization-controller/rbac-for-us.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/templates/virtualization-controller/rbac-for-us.yaml b/templates/virtualization-controller/rbac-for-us.yaml index 001158abda..a26393280c 100644 --- a/templates/virtualization-controller/rbac-for-us.yaml +++ b/templates/virtualization-controller/rbac-for-us.yaml @@ -292,10 +292,15 @@ rules: - resource.k8s.io resources: - resourceslices + - resourceclaimtemplates verbs: - get - list - watch + - create + - update + - patch + - delete - apiGroups: - apiextensions.k8s.io resources: From 129bd19784437f9da00c938d94546ceb5269ae0d Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Thu, 29 Jan 2026 12:23:28 +0200 Subject: [PATCH 40/53] fix policies Signed-off-by: Daniil Antoshin --- templates/admission-policy.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/templates/admission-policy.yaml b/templates/admission-policy.yaml index 3734503655..25151bb7b1 100644 --- a/templates/admission-policy.yaml +++ b/templates/admission-policy.yaml @@ -98,7 +98,9 @@ spec: - "usbdevices/status" validations: - expression: | - request.userInfo.username.startsWith("system:serviceaccount:d8") + request.userInfo.username.startsWith("system:serviceaccount:kube-system:") || + request.userInfo.username.startsWith("system:serviceaccount:d8-system:") || + request.userInfo.username.startsWith("system:serviceaccount:d8-virtualization:") message: "NodeUSBDevice and USBDevice resources can only be created, deleted, or updated by ServiceAccounts with 'd8' prefix. NodeUSBDevice status can only be updated by ServiceAccounts with 'd8' prefix. Only spec updates are allowed for other users on NodeUSBDevice." --- apiVersion: {{ $apiVersion }} From cd134d981490d6a8ae13294ccb43e95362065e4f Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Thu, 29 Jan 2026 15:14:04 +0200 Subject: [PATCH 41/53] move to separate controller Signed-off-by: Daniil Antoshin --- .../cmd/virtualization-controller/main.go | 7 + .../internal => common}/hash/hash.go | 0 .../nodeusbdevice/internal/discovery.go | 369 ------------------ .../nodeusbdevice/internal/ready.go | 2 +- .../internal/watcher/resourceslice_watcher.go | 35 +- .../nodeusbdevice/nodeusbdevice_controller.go | 1 - .../nodeusbdevice/nodeusbdevice_reconciler.go | 20 +- .../internal/nodeusbdevice_handler.go | 224 +++++++++++ .../resourceslice/resourceslice_controller.go | 73 ++++ .../resourceslice/resourceslice_reconciler.go | 70 ++++ 10 files changed, 379 insertions(+), 422 deletions(-) rename images/virtualization-artifact/pkg/{controller/nodeusbdevice/internal => common}/hash/hash.go (100%) delete mode 100644 images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go create mode 100644 images/virtualization-artifact/pkg/controller/resourceslice/internal/nodeusbdevice_handler.go create mode 100644 images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_controller.go create mode 100644 images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_reconciler.go diff --git a/images/virtualization-artifact/cmd/virtualization-controller/main.go b/images/virtualization-artifact/cmd/virtualization-controller/main.go index 7302614dd3..75815f1b67 100644 --- a/images/virtualization-artifact/cmd/virtualization-controller/main.go +++ b/images/virtualization-artifact/cmd/virtualization-controller/main.go @@ -48,6 +48,7 @@ import ( mc "github.com/deckhouse/virtualization-controller/pkg/controller/moduleconfig" mcapi "github.com/deckhouse/virtualization-controller/pkg/controller/moduleconfig/api" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice" + "github.com/deckhouse/virtualization-controller/pkg/controller/resourceslice" "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice" "github.com/deckhouse/virtualization-controller/pkg/controller/vd" "github.com/deckhouse/virtualization-controller/pkg/controller/vdsnapshot" @@ -377,6 +378,12 @@ func main() { os.Exit(1) } + resourcesliceLogger := logger.NewControllerLogger(resourceslice.ControllerName, logLevel, logOutput, logDebugVerbosity, logDebugControllerList) + if _, err = resourceslice.NewController(ctx, mgr, resourcesliceLogger); err != nil { + log.Error(err.Error()) + os.Exit(1) + } + nodeusbdeviceLogger := logger.NewControllerLogger(nodeusbdevice.ControllerName, logLevel, logOutput, logDebugVerbosity, logDebugControllerList) if _, err = nodeusbdevice.NewController(ctx, mgr, nodeusbdeviceLogger); err != nil { log.Error(err.Error()) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/hash/hash.go b/images/virtualization-artifact/pkg/common/hash/hash.go similarity index 100% rename from images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/hash/hash.go rename to images/virtualization-artifact/pkg/common/hash/hash.go diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go deleted file mode 100644 index 1d0d64a451..0000000000 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go +++ /dev/null @@ -1,369 +0,0 @@ -/* -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 internal - -import ( - "context" - "fmt" - "strings" - - resourcev1beta1 "k8s.io/api/resource/v1beta1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - "github.com/deckhouse/deckhouse/pkg/log" - "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" - "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/hash" - "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" - "github.com/deckhouse/virtualization-controller/pkg/eventrecord" - "github.com/deckhouse/virtualization/api/core/v1alpha2" - "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" -) - -const ( - nameDiscoveryHandler = "DiscoveryHandler" -) - -func NewDiscoveryHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *DiscoveryHandler { - return &DiscoveryHandler{ - client: client, - recorder: recorder, - } -} - -type DiscoveryHandler struct { - client client.Client - recorder eventrecord.EventRecorderLogger -} - -func (h *DiscoveryHandler) Name() string { - return nameDiscoveryHandler -} - -func (h *DiscoveryHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) (reconcile.Result, error) { - nodeUSBDevice := s.NodeUSBDevice() - - // Get ResourceSlices once for both discovery and update - resourceSlices, err := s.ResourceSlices(ctx) - if err != nil { - return reconcile.Result{}, fmt.Errorf("failed to get resource slices: %w", err) - } - - // Check for new devices in ResourceSlice and create NodeUSBDevice if needed - // This ensures we discover new devices even if reconcile was triggered for other reasons - if err := h.discoverAndCreate(ctx, s, resourceSlices); err != nil { - // Log error but don't fail reconciliation - // This is a best-effort discovery mechanism - log.Error("failed to discover and create NodeUSBDevice", log.Err(err)) - } - - if nodeUSBDevice.IsEmpty() { - // Resource doesn't exist - nothing to update - return reconcile.Result{}, nil - } - - current := nodeUSBDevice.Current() - changed := nodeUSBDevice.Changed() - - // Update attributes from ResourceSlice if needed - deviceInfo, found := h.findDeviceInSlices(resourceSlices, current.Status.Attributes.Hash, current.Status.NodeName) - if !found { - // Device not found in slices - mark as NotFound - cb := conditions.NewConditionBuilder(nodeusbdevicecondition.ReadyType). - Generation(current.GetGeneration()). - Status(metav1.ConditionFalse). - Reason(nodeusbdevicecondition.NotFound). - Message("Device not found in ResourceSlice") - conditions.SetCondition(cb, &changed.Status.Conditions) - return reconcile.Result{}, nil - } - - // Update attributes if they changed - if !h.attributesEqual(current.Status.Attributes, deviceInfo) { - changed.Status.Attributes = deviceInfo - } - - return reconcile.Result{}, nil -} - -func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context, s state.NodeUSBDeviceState, resourceSlices []resourcev1beta1.ResourceSlice) error { - // Check if current device exists - if it does, we only need to check for new devices - // This avoids unnecessary List when reconciling existing devices - currentDevice := s.NodeUSBDevice() - hasCurrentDevice := !currentDevice.IsEmpty() - - // Collect all hashes from ResourceSlices first - deviceHashesInSlices := make(map[string]bool) - for _, slice := range resourceSlices { - for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "usb-") { - continue - } - hash := hash.CalculateHashFromDevice(device, slice.Spec.Pool.Name) - deviceHashesInSlices[hash] = true - } - } - - // If we have a current device and its hash is in slices, we can skip List - // Only do List if we need to check for new devices - if hasCurrentDevice { - current := currentDevice.Current() - if current.Status.Attributes.Hash != "" && deviceHashesInSlices[current.Status.Attributes.Hash] { - // Current device exists and is in slices - only check for new devices - // We still need to List to check for duplicates, but we can optimize - // by only checking hashes that are in slices - var existingDevices v1alpha2.NodeUSBDeviceList - if err := h.client.List(ctx, &existingDevices); err != nil { - return fmt.Errorf("failed to list existing NodeUSBDevices: %w", err) - } - - existingHashes := make(map[string]bool) - for _, device := range existingDevices.Items { - if device.Status.Attributes.Hash != "" { - existingHashes[device.Status.Attributes.Hash] = true - } - } - - // Only create devices that are in slices but not in existing - for _, slice := range resourceSlices { - for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "usb-") { - continue - } - - attributes := h.convertDeviceToAttributes(device, slice.Spec.Pool.Name) - hash := hash.CalculateHash(attributes) - - if !existingHashes[hash] { - if err := h.createNodeUSBDevice(ctx, attributes, hash); err != nil { - return err - } - } - } - } - return nil - } - } - - // No current device or it's not in slices - need full List - var existingDevices v1alpha2.NodeUSBDeviceList - if err := h.client.List(ctx, &existingDevices); err != nil { - return fmt.Errorf("failed to list existing NodeUSBDevices: %w", err) - } - - existingHashes := make(map[string]bool) - for _, device := range existingDevices.Items { - if device.Status.Attributes.Hash != "" { - existingHashes[device.Status.Attributes.Hash] = true - } - } - - // Create NodeUSBDevice for each USB device in ResourceSlices - // Note: resourceSlices are already filtered by draDriverName in state.ResourceSlices - for _, slice := range resourceSlices { - for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "usb-") { - continue - } - - attributes := h.convertDeviceToAttributes(device, slice.Spec.Pool.Name) - hash := hash.CalculateHash(attributes) - - if existingHashes[hash] { - continue - } - - if err := h.createNodeUSBDevice(ctx, attributes, hash); err != nil { - return err - } - } - } - - return nil -} - -func (h *DiscoveryHandler) createNodeUSBDevice(ctx context.Context, attributes v1alpha2.NodeUSBDeviceAttributes, hash string) error { - name := h.generateName(hash, attributes.NodeName) - - // Check if device already exists - existing := &v1alpha2.NodeUSBDevice{} - err := h.client.Get(ctx, client.ObjectKey{Name: name}, existing) - if err == nil { - // Device already exists, skip creation - return nil - } - if !apierrors.IsNotFound(err) { - // Unexpected error - return fmt.Errorf("failed to check if NodeUSBDevice exists: %w", err) - } - - // Create NodeUSBDevice without status (status is a subresource) - nodeUSBDevice := &v1alpha2.NodeUSBDevice{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - }, - Spec: v1alpha2.NodeUSBDeviceSpec{ - AssignedNamespace: "", - }, - } - - if err := h.client.Create(ctx, nodeUSBDevice); err != nil { - // If device was created by another process between check and create, ignore the error - if apierrors.IsAlreadyExists(err) { - return nil - } - return fmt.Errorf("failed to create NodeUSBDevice: %w", err) - } - - // Update status separately (status is a subresource) - // Set all attributes including Hash - attributes.Hash = hash - nodeUSBDevice.Status = v1alpha2.NodeUSBDeviceStatus{ - Attributes: attributes, - NodeName: attributes.NodeName, - Conditions: []metav1.Condition{ - { - Type: string(nodeusbdevicecondition.ReadyType), - Status: metav1.ConditionTrue, - Reason: string(nodeusbdevicecondition.Ready), - Message: "Device is ready to use", - LastTransitionTime: metav1.Now(), - }, - { - Type: string(nodeusbdevicecondition.AssignedType), - Status: metav1.ConditionFalse, - Reason: string(nodeusbdevicecondition.Available), - Message: "No namespace is assigned for the device", - LastTransitionTime: metav1.Now(), - }, - }, - } - - if err := h.client.Status().Update(ctx, nodeUSBDevice); err != nil { - return fmt.Errorf("failed to update NodeUSBDevice status: %w", err) - } - - return nil -} - -func (h *DiscoveryHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice, searchedHash, nodeName string) (v1alpha2.NodeUSBDeviceAttributes, bool) { - for _, slice := range slices { - if slice.Spec.Pool.Name != nodeName { - continue - } - - for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "usb-") { - continue - } - - attributes := h.convertDeviceToAttributes(device, nodeName) - deviceHash := hash.CalculateHash(attributes) - - if deviceHash == searchedHash { - return attributes, true - } - } - } - - return v1alpha2.NodeUSBDeviceAttributes{}, false -} - -func (h *DiscoveryHandler) convertDeviceToAttributes(device resourcev1beta1.Device, nodeName string) v1alpha2.NodeUSBDeviceAttributes { - attrs := v1alpha2.NodeUSBDeviceAttributes{ - NodeName: nodeName, - Name: device.Name, - } - - if device.Basic == nil { - return attrs - } - - for key, attr := range device.Basic.Attributes { - switch string(key) { - case "name": - if attr.StringValue != nil { - attrs.Name = *attr.StringValue - } - case "manufacturer": - if attr.StringValue != nil { - attrs.Manufacturer = *attr.StringValue - } - case "product": - if attr.StringValue != nil { - attrs.Product = *attr.StringValue - } - case "vendorID": - if attr.StringValue != nil { - attrs.VendorID = *attr.StringValue - } - case "productID": - if attr.StringValue != nil { - attrs.ProductID = *attr.StringValue - } - case "bcd": - if attr.StringValue != nil { - attrs.BCD = *attr.StringValue - } - case "bus": - if attr.StringValue != nil { - attrs.Bus = *attr.StringValue - } - case "deviceNumber": - if attr.StringValue != nil { - attrs.DeviceNumber = *attr.StringValue - } - case "serial": - if attr.StringValue != nil { - attrs.Serial = *attr.StringValue - } - case "devicePath": - if attr.StringValue != nil { - attrs.DevicePath = *attr.StringValue - } - case "major": - if attr.IntValue != nil { - attrs.Major = int(*attr.IntValue) - } - case "minor": - if attr.IntValue != nil { - attrs.Minor = int(*attr.IntValue) - } - } - } - - attrs.Hash = hash.CalculateHash(attrs) - return attrs -} - -func (h *DiscoveryHandler) generateName(hash, nodeName string) string { - // Generate name based on hash and node name - // Format: nusb-- - nodeNameSanitized := strings.ToLower(strings.ReplaceAll(nodeName, ".", "-")) - return fmt.Sprintf("nusb-%s-%s", hash[:8], nodeNameSanitized) -} - -func (h *DiscoveryHandler) attributesEqual(a, b v1alpha2.NodeUSBDeviceAttributes) bool { - return a.Hash == b.Hash && - a.NodeName == b.NodeName && - a.VendorID == b.VendorID && - a.ProductID == b.ProductID && - a.Bus == b.Bus && - a.DeviceNumber == b.DeviceNumber -} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go index 890bae401d..4338f08993 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go @@ -26,7 +26,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" - "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/hash" + "github.com/deckhouse/virtualization-controller/pkg/common/hash" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go index 993f9233e8..7b51a3e8ef 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go @@ -21,7 +21,6 @@ import ( "strings" resourcev1beta1 "k8s.io/api/resource/v1beta1" - "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" @@ -61,53 +60,25 @@ func (w *ResourceSliceWatcher) Watch(mgr manager.Manager, ctr controller.Control break } } - - // If no USB devices in this ResourceSlice, skip reconciliation if !hasUSBDevices { return nil } - var result []reconcile.Request - client := mgr.GetClient() - - // Enqueue all existing NodeUSBDevices for reconciliation + // Enqueue NodeUSBDevices on this node so they can sync attributes from the updated slice deviceList := &v1alpha2.NodeUSBDeviceList{} - if err := client.List(ctx, deviceList); err != nil { + if err := mgr.GetClient().List(ctx, deviceList); err != nil { log.Error("failed to list NodeUSBDevices in ResourceSliceWatcher", log.Err(err)) return nil } - hasDevicesOnNode := false + var result []reconcile.Request for _, device := range deviceList.Items { - // Only enqueue devices from the same node as the ResourceSlice if device.Status.NodeName == slice.Spec.Pool.Name { - hasDevicesOnNode = true result = append(result, reconcile.Request{ NamespacedName: object.NamespacedName(&device), }) } } - - // If no devices exist on this node yet, trigger discovery - if !hasDevicesOnNode { - if len(deviceList.Items) > 0 { - // Enqueue first device to trigger reconciliation cycle - // DiscoveryHandler will discover new devices during this cycle - result = append(result, reconcile.Request{ - NamespacedName: object.NamespacedName(&deviceList.Items[0]), - }) - } else { - // If no NodeUSBDevices exist at all, create a dummy reconcile request - // to trigger discovery. The reconciler will handle empty NodeUSBDevice - // and run DiscoveryHandler to discover new devices. - result = append(result, reconcile.Request{ - NamespacedName: types.NamespacedName{ - Name: "discovery-" + slice.Spec.Pool.Name, - }, - }) - } - } - return result }), ), diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go index 8bb43916d3..f328209945 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go @@ -48,7 +48,6 @@ func NewController( internal.NewDeletionHandler(client, recorder), internal.NewReadyHandler(recorder), internal.NewAssignedHandler(client, recorder), - internal.NewDiscoveryHandler(client, recorder), } r := NewReconciler(client, handlers...) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go index ecb464df00..411052f80f 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go @@ -19,7 +19,6 @@ package nodeusbdevice import ( "context" "fmt" - "log/slog" "reflect" "sigs.k8s.io/controller-runtime/pkg/client" @@ -32,7 +31,6 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/watcher" "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" - "github.com/deckhouse/virtualization-controller/pkg/logger" "github.com/deckhouse/virtualization/api/core/v1alpha2" ) @@ -80,8 +78,6 @@ func (r *Reconciler) SetupController(_ context.Context, mgr manager.Manager, ctr } func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { - log := logger.FromContext(ctx) - nodeUSBDevice := reconciler.NewResource(req.NamespacedName, r.client, r.factory, r.statusGetter) err := nodeUSBDevice.Fetch(ctx) @@ -91,22 +87,8 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco s := state.New(r.client, nodeUSBDevice) - // If NodeUSBDevice doesn't exist, only run DiscoveryHandler to discover new devices if nodeUSBDevice.IsEmpty() { - log.Info("Reconcile observe an absent NodeUSBDevice: running discovery to find new devices") - - // Find DiscoveryHandler and run it - for _, handler := range r.handlers { - if handler.Name() == "DiscoveryHandler" { - result, err := handler.Handle(ctx, s) - if err != nil { - log.Error("DiscoveryHandler failed", slog.Attr{Key: "error", Value: slog.StringValue(err.Error())}) - } - return result, err - } - } - - // If DiscoveryHandler not found, return + // NodeUSBDevice is created by the ResourceSlice controller return reconcile.Result{}, nil } diff --git a/images/virtualization-artifact/pkg/controller/resourceslice/internal/nodeusbdevice_handler.go b/images/virtualization-artifact/pkg/controller/resourceslice/internal/nodeusbdevice_handler.go new file mode 100644 index 0000000000..1e813fe79e --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/resourceslice/internal/nodeusbdevice_handler.go @@ -0,0 +1,224 @@ +/* +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 internal + +import ( + "context" + "fmt" + "strings" + + resourcev1beta1 "k8s.io/api/resource/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/common/hash" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" +) + +const ( + nameNodeUSBDeviceHandler = "NodeUSBDeviceHandler" +) + +func NewNodeUSBDeviceHandler(client client.Client) *NodeUSBDeviceHandler { + return &NodeUSBDeviceHandler{client: client} +} + +type NodeUSBDeviceHandler struct { + client client.Client +} + +func (h *NodeUSBDeviceHandler) Name() string { + return nameNodeUSBDeviceHandler +} + +func (h *NodeUSBDeviceHandler) Handle(ctx context.Context, slice *resourcev1beta1.ResourceSlice) error { + hasUSBDevices := false + for _, device := range slice.Spec.Devices { + if strings.HasPrefix(device.Name, "usb-") { + hasUSBDevices = true + break + } + } + if !hasUSBDevices { + return nil + } + + nodeName := slice.Spec.Pool.Name + + var existingDevices v1alpha2.NodeUSBDeviceList + if err := h.client.List(ctx, &existingDevices); err != nil { + return fmt.Errorf("list NodeUSBDevices: %w", err) + } + + existingHashes := make(map[string]bool) + for _, device := range existingDevices.Items { + if device.Status.Attributes.Hash != "" { + existingHashes[device.Status.Attributes.Hash] = true + } + } + + for _, device := range slice.Spec.Devices { + if !strings.HasPrefix(device.Name, "usb-") { + continue + } + + attributes := convertDeviceToAttributes(device, nodeName) + hashStr := hash.CalculateHash(attributes) + + if existingHashes[hashStr] { + continue + } + + if err := h.createNodeUSBDevice(ctx, attributes, hashStr); err != nil { + return err + } + + existingHashes[hashStr] = true + } + + return nil +} + +func (h *NodeUSBDeviceHandler) createNodeUSBDevice(ctx context.Context, attributes v1alpha2.NodeUSBDeviceAttributes, hashStr string) error { + name := generateName(hashStr, attributes.NodeName) + + existing := &v1alpha2.NodeUSBDevice{} + err := h.client.Get(ctx, client.ObjectKey{Name: name}, existing) + if err == nil { + return nil + } + if !apierrors.IsNotFound(err) { + return fmt.Errorf("check NodeUSBDevice %s: %w", name, err) + } + + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: v1alpha2.NodeUSBDeviceSpec{ + AssignedNamespace: "", + }, + } + + if err := h.client.Create(ctx, nodeUSBDevice); err != nil { + if apierrors.IsAlreadyExists(err) { + return nil + } + return fmt.Errorf("create NodeUSBDevice %s: %w", name, err) + } + + attributes.Hash = hashStr + nodeUSBDevice.Status = v1alpha2.NodeUSBDeviceStatus{ + Attributes: attributes, + NodeName: attributes.NodeName, + Conditions: []metav1.Condition{ + { + Type: string(nodeusbdevicecondition.ReadyType), + Status: metav1.ConditionTrue, + Reason: string(nodeusbdevicecondition.Ready), + Message: "Device is ready to use", + LastTransitionTime: metav1.Now(), + }, + { + Type: string(nodeusbdevicecondition.AssignedType), + Status: metav1.ConditionFalse, + Reason: string(nodeusbdevicecondition.Available), + Message: "No namespace is assigned for the device", + LastTransitionTime: metav1.Now(), + }, + }, + } + + if err := h.client.Status().Update(ctx, nodeUSBDevice); err != nil { + return fmt.Errorf("update NodeUSBDevice %s status: %w", name, err) + } + + return nil +} + +func convertDeviceToAttributes(device resourcev1beta1.Device, nodeName string) v1alpha2.NodeUSBDeviceAttributes { + attrs := v1alpha2.NodeUSBDeviceAttributes{ + NodeName: nodeName, + Name: device.Name, + } + + if device.Basic == nil { + return attrs + } + + for key, attr := range device.Basic.Attributes { + switch string(key) { + case "name": + if attr.StringValue != nil { + attrs.Name = *attr.StringValue + } + case "manufacturer": + if attr.StringValue != nil { + attrs.Manufacturer = *attr.StringValue + } + case "product": + if attr.StringValue != nil { + attrs.Product = *attr.StringValue + } + case "vendorID": + if attr.StringValue != nil { + attrs.VendorID = *attr.StringValue + } + case "productID": + if attr.StringValue != nil { + attrs.ProductID = *attr.StringValue + } + case "bcd": + if attr.StringValue != nil { + attrs.BCD = *attr.StringValue + } + case "bus": + if attr.StringValue != nil { + attrs.Bus = *attr.StringValue + } + case "deviceNumber": + if attr.StringValue != nil { + attrs.DeviceNumber = *attr.StringValue + } + case "serial": + if attr.StringValue != nil { + attrs.Serial = *attr.StringValue + } + case "devicePath": + if attr.StringValue != nil { + attrs.DevicePath = *attr.StringValue + } + case "major": + if attr.IntValue != nil { + attrs.Major = int(*attr.IntValue) + } + case "minor": + if attr.IntValue != nil { + attrs.Minor = int(*attr.IntValue) + } + } + } + + return attrs +} + +func generateName(hashStr, nodeName string) string { + nodeNameSanitized := strings.ToLower(strings.ReplaceAll(nodeName, ".", "-")) + return fmt.Sprintf("nusb-%s-%s", hashStr[:8], nodeNameSanitized) +} diff --git a/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_controller.go b/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_controller.go new file mode 100644 index 0000000000..490fcac72c --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_controller.go @@ -0,0 +1,73 @@ +/* +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 resourceslice + +import ( + "context" + "fmt" + "time" + + resourcev1beta1 "k8s.io/api/resource/v1beta1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/virtualization-controller/pkg/controller/resourceslice/internal" + "github.com/deckhouse/virtualization-controller/pkg/logger" +) + +const ( + ControllerName = "resourceslice-controller" +) + +func NewController( + ctx context.Context, + mgr manager.Manager, + log *log.Logger, +) (controller.Controller, error) { + client := mgr.GetClient() + + handlers := []Handler{ + internal.NewNodeUSBDeviceHandler(client), + } + r := NewReconciler(client, handlers...) + + c, err := controller.New(ControllerName, mgr, controller.Options{ + Reconciler: r, + RecoverPanic: ptr.To(true), + LogConstructor: logger.NewConstructor(log), + CacheSyncTimeout: 10 * time.Minute, + }) + if err != nil { + return nil, err + } + + if err := c.Watch( + source.Kind(mgr.GetCache(), + &resourcev1beta1.ResourceSlice{}, + &handler.TypedEnqueueRequestForObject[*resourcev1beta1.ResourceSlice]{}, + ), + ); err != nil { + return nil, fmt.Errorf("watch ResourceSlice: %w", err) + } + + log.Info("Initialized ResourceSlice controller") + return c, nil +} diff --git a/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_reconciler.go b/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_reconciler.go new file mode 100644 index 0000000000..38f93226dc --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_reconciler.go @@ -0,0 +1,70 @@ +/* +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 resourceslice + +import ( + "context" + "fmt" + + resourcev1beta1 "k8s.io/api/resource/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +const ( + draDriverName = "virtualization-dra" +) + +type Handler interface { + Handle(ctx context.Context, slice *resourcev1beta1.ResourceSlice) error + Name() string +} + +type Reconciler struct { + client client.Client + handlers []Handler +} + +func NewReconciler(client client.Client, handlers ...Handler) *Reconciler { + return &Reconciler{ + client: client, + handlers: handlers, + } +} + +func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + slice := &resourcev1beta1.ResourceSlice{} + if err := r.client.Get(ctx, client.ObjectKey{Name: req.Name}, slice); err != nil { + if apierrors.IsNotFound(err) { + return reconcile.Result{}, nil + } + return reconcile.Result{}, fmt.Errorf("get ResourceSlice %s: %w", req.Name, err) + } + + if slice.Spec.Driver != draDriverName { + return reconcile.Result{}, nil + } + + for _, h := range r.handlers { + if err := h.Handle(ctx, slice); err != nil { + return reconcile.Result{}, err + } + } + + return reconcile.Result{}, nil +} From 227b52734576403e94e8a52dba4643d06aa1d8d1 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Thu, 29 Jan 2026 15:42:46 +0200 Subject: [PATCH 42/53] fix deletion Signed-off-by: Daniil Antoshin --- .../nodeusbdevice/internal/assigned.go | 5 ++ .../nodeusbdevice/internal/deletion.go | 20 ++--- .../nodeusbdevice/internal/deletion_test.go | 82 +++++++++++++++++-- 3 files changed, 90 insertions(+), 17 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go index 561ed7ec12..6a5bb4b1f5 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go @@ -66,6 +66,11 @@ func (h *AssignedHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState current := nodeUSBDevice.Current() changed := nodeUSBDevice.Changed() + // Do not create or update USBDevice when NodeUSBDevice is being deleted — cleanup is done by DeletionHandler + if !current.GetDeletionTimestamp().IsZero() { + return reconcile.Result{}, nil + } + assignedNamespace := current.Spec.AssignedNamespace // Check previous assignedNamespace if it changed diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go index 0f21737d68..2b4d7b2e46 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go @@ -20,6 +20,7 @@ import ( "context" "fmt" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -61,17 +62,16 @@ func (h *DeletionHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState return reconcile.Result{}, nil } - // Resource is being deleted - clean up USBDevice in namespace - if current.Spec.AssignedNamespace != "" { - usbDevice := &v1alpha2.USBDevice{} - key := client.ObjectKey{ - Namespace: current.Spec.AssignedNamespace, - Name: current.Name, - } - if err := h.client.Get(ctx, key, usbDevice); err == nil { - // USBDevice exists - delete it + // Resource is being deleted - clean up all USBDevice resources owned by this NodeUSBDevice (in any namespace) + var usbDeviceList v1alpha2.USBDeviceList + if err := h.client.List(ctx, &usbDeviceList); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to list USBDevices: %w", err) + } + for i := range usbDeviceList.Items { + usbDevice := &usbDeviceList.Items[i] + if metav1.IsControlledBy(usbDevice, current) { if err := h.client.Delete(ctx, usbDevice); err != nil { - return reconcile.Result{}, fmt.Errorf("failed to delete USBDevice: %w", err) + return reconcile.Result{}, fmt.Errorf("failed to delete USBDevice %s/%s: %w", usbDevice.Namespace, usbDevice.Name, err) } } } diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion_test.go index 4887d71cb8..2e3b2ddff2 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion_test.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion_test.go @@ -25,6 +25,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" apiruntime "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -84,16 +85,10 @@ var _ = Describe("DeletionHandler", func() { Context("when NodeUSBDevice is being deleted", func() { It("should delete USBDevice and remove finalizer", func() { now := metav1.Now() - usbDevice := &v1alpha2.USBDevice{ - ObjectMeta: metav1.ObjectMeta{ - Name: "usb-device-1", - Namespace: "test-namespace", - }, - } - nodeUSBDevice := &v1alpha2.NodeUSBDevice{ ObjectMeta: metav1.ObjectMeta{ Name: "usb-device-1", + UID: "node-usb-device-uid", Finalizers: []string{v1alpha2.FinalizerNodeUSBDeviceCleanup}, DeletionTimestamp: &now, }, @@ -101,6 +96,21 @@ var _ = Describe("DeletionHandler", func() { AssignedNamespace: "test-namespace", }, } + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "test-namespace", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: v1alpha2.SchemeGroupVersion.String(), + Kind: "NodeUSBDevice", + Name: nodeUSBDevice.Name, + UID: nodeUSBDevice.UID, + Controller: ptr.To(true), + }, + }, + }, + } scheme := apiruntime.NewScheme() Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) @@ -169,5 +179,63 @@ var _ = Describe("DeletionHandler", func() { // Verify finalizer was removed Expect(nodeUSBDeviceResource.Changed().GetFinalizers()).NotTo(ContainElement(v1alpha2.FinalizerNodeUSBDeviceCleanup)) }) + + It("should delete USBDevice by OwnerReference even when in different namespace than AssignedNamespace", func() { + now := metav1.Now() + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + UID: "node-usb-device-uid", + Finalizers: []string{v1alpha2.FinalizerNodeUSBDeviceCleanup}, + DeletionTimestamp: &now, + }, + Spec: v1alpha2.NodeUSBDeviceSpec{ + AssignedNamespace: "other-namespace", // different from where USBDevice actually is + }, + } + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "previous-namespace", // e.g. spec was changed but AssignedHandler did not run yet + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: v1alpha2.SchemeGroupVersion.String(), + Kind: "NodeUSBDevice", + Name: nodeUSBDevice.Name, + UID: nodeUSBDevice.UID, + Controller: ptr.To(true), + }, + }, + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(nodeUSBDevice, usbDevice).Build() + + nodeUSBDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: nodeUSBDevice.Name}, + fakeClient, + func() *v1alpha2.NodeUSBDevice { return &v1alpha2.NodeUSBDevice{} }, + func(obj *v1alpha2.NodeUSBDevice) v1alpha2.NodeUSBDeviceStatus { return obj.Status }, + ) + Expect(nodeUSBDeviceResource.Fetch(ctx)).To(Succeed()) + + nodeUSBDeviceState = state.New(fakeClient, nodeUSBDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{} + handler = NewDeletionHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, nodeUSBDeviceState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // USBDevice in previous-namespace must be deleted (found by OwnerReference) + deletedUSBDevice := &v1alpha2.USBDevice{} + err = fakeClient.Get(ctx, types.NamespacedName{Name: "usb-device-1", Namespace: "previous-namespace"}, deletedUSBDevice) + Expect(err).To(HaveOccurred()) + + Expect(nodeUSBDeviceResource.Changed().GetFinalizers()).NotTo(ContainElement(v1alpha2.FinalizerNodeUSBDeviceCleanup)) + }) }) }) From 46d7ad0fe2ea8ed6b0aabde452918eef4bf7ecb3 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Thu, 29 Jan 2026 15:42:50 +0200 Subject: [PATCH 43/53] fix rbac Signed-off-by: Daniil Antoshin --- templates/admission-policy.yaml | 10 ++++++++-- templates/user-authz-cluster-roles.yaml | 7 +++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/templates/admission-policy.yaml b/templates/admission-policy.yaml index 25151bb7b1..32c3eeae40 100644 --- a/templates/admission-policy.yaml +++ b/templates/admission-policy.yaml @@ -84,9 +84,15 @@ spec: apiVersions: ["v1alpha2"] operations: - "CREATE" - - "DELETE" resources: - "nodeusbdevices" + - apiGroups: + - "virtualization.deckhouse.io" + apiVersions: ["v1alpha2"] + operations: + - "CREATE" + - "DELETE" + resources: - "usbdevices" - apiGroups: - "virtualization.deckhouse.io" @@ -101,7 +107,7 @@ spec: request.userInfo.username.startsWith("system:serviceaccount:kube-system:") || request.userInfo.username.startsWith("system:serviceaccount:d8-system:") || request.userInfo.username.startsWith("system:serviceaccount:d8-virtualization:") - message: "NodeUSBDevice and USBDevice resources can only be created, deleted, or updated by ServiceAccounts with 'd8' prefix. NodeUSBDevice status can only be updated by ServiceAccounts with 'd8' prefix. Only spec updates are allowed for other users on NodeUSBDevice." + message: "NodeUSBDevice and USBDevice resources can only be created by ServiceAccounts with 'd8' prefix. USBDevice DELETE is allowed only for ServiceAccounts with 'd8' prefix. NodeUSBDevice DELETE is controlled via RBAC (allowed for cluster-admin role). Status updates are allowed only for ServiceAccounts with 'd8' prefix." --- apiVersion: {{ $apiVersion }} kind: ValidatingAdmissionPolicyBinding diff --git a/templates/user-authz-cluster-roles.yaml b/templates/user-authz-cluster-roles.yaml index d574566298..9d181c2a48 100644 --- a/templates/user-authz-cluster-roles.yaml +++ b/templates/user-authz-cluster-roles.yaml @@ -152,3 +152,10 @@ rules: - deletecollection - patch - update +- apiGroups: + - virtualization.deckhouse.io + resources: + - nodeusbdevices + verbs: + - delete + - update From 6c6eaf4efdfae338642f472d5b5848988bfa8aae Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Thu, 29 Jan 2026 15:59:48 +0200 Subject: [PATCH 44/53] fix bugs Signed-off-by: Daniil Antoshin --- .../nodeusbdevice/internal/assigned.go | 36 ++++++++++--------- .../nodeusbdevice/internal/deletion.go | 3 +- .../controller/usbdevice/internal/attached.go | 5 +-- .../controller/usbdevice/internal/deletion.go | 12 ++++--- .../usbdevice/internal/ready_test.go | 8 ++--- .../usbdevice/internal/state/state.go | 7 ++-- .../usbdevice/internal/sync_ready.go | 5 +-- .../usbdevice/internal/sync_test.go | 4 +-- .../usbdevice/usbdevice_controller.go | 4 +-- 9 files changed, 41 insertions(+), 43 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go index 6a5bb4b1f5..c97c95a82a 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go @@ -76,15 +76,16 @@ func (h *AssignedHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState // Check previous assignedNamespace if it changed // Try to find previous USBDevice to delete it if namespace changed var usbDeviceList v1alpha2.USBDeviceList - if err := h.client.List(ctx, &usbDeviceList); err == nil { - for _, usbDevice := range usbDeviceList.Items { - if usbDevice.Name == current.Name && usbDevice.Namespace != assignedNamespace { - // Delete USBDevice from previous namespace - if err := h.deleteUSBDevice(ctx, usbDevice.Namespace, usbDevice.Name); err != nil { - return reconcile.Result{}, fmt.Errorf("failed to delete USBDevice from previous namespace: %w", err) - } - break + if err := h.client.List(ctx, &usbDeviceList); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to list USBDevices: %w", err) + } + for _, usbDevice := range usbDeviceList.Items { + if usbDevice.Name == current.Name && usbDevice.Namespace != assignedNamespace { + // Delete USBDevice from previous namespace + if err := h.deleteUSBDevice(ctx, usbDevice.Namespace, usbDevice.Name); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to delete USBDevice from previous namespace: %w", err) } + break } } @@ -126,13 +127,14 @@ func (h *AssignedHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState } } else { // No namespace assigned - delete USBDevice if it exists - var usbDeviceList v1alpha2.USBDeviceList - if err := h.client.List(ctx, &usbDeviceList); err == nil { - for _, usbDevice := range usbDeviceList.Items { - if usbDevice.Name == current.Name { - if err := h.deleteUSBDevice(ctx, usbDevice.Namespace, usbDevice.Name); err != nil { - return reconcile.Result{}, fmt.Errorf("failed to delete USBDevice: %w", err) - } + var usbDeviceListForDelete v1alpha2.USBDeviceList + if err := h.client.List(ctx, &usbDeviceListForDelete); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to list USBDevices: %w", err) + } + for _, usbDevice := range usbDeviceListForDelete.Items { + if usbDevice.Name == current.Name { + if err := h.deleteUSBDevice(ctx, usbDevice.Namespace, usbDevice.Name); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to delete USBDevice: %w", err) } } } @@ -188,8 +190,8 @@ func (h *AssignedHandler) ensureUSBDevice(ctx context.Context, nodeUSBDevice *v1 Namespace: namespace, OwnerReferences: []metav1.OwnerReference{ { - APIVersion: nodeUSBDevice.APIVersion, - Kind: nodeUSBDevice.Kind, + APIVersion: v1alpha2.SchemeGroupVersion.String(), + Kind: "NodeUSBDevice", Name: nodeUSBDevice.Name, UID: nodeUSBDevice.UID, Controller: ptr.To(true), diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go index 2b4d7b2e46..695b7893c6 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go @@ -20,6 +20,7 @@ import ( "context" "fmt" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -70,7 +71,7 @@ func (h *DeletionHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState for i := range usbDeviceList.Items { usbDevice := &usbDeviceList.Items[i] if metav1.IsControlledBy(usbDevice, current) { - if err := h.client.Delete(ctx, usbDevice); err != nil { + if err := h.client.Delete(ctx, usbDevice); err != nil && !apierrors.IsNotFound(err) { return reconcile.Result{}, fmt.Errorf("failed to delete USBDevice %s/%s: %w", usbDevice.Namespace, usbDevice.Name, err) } } diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/attached.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/attached.go index b01593d76c..ccf32ec342 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/attached.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/attached.go @@ -21,7 +21,6 @@ import ( "fmt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" @@ -34,15 +33,13 @@ const ( nameAttachedHandler = "AttachedHandler" ) -func NewAttachedHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *AttachedHandler { +func NewAttachedHandler(recorder eventrecord.EventRecorderLogger) *AttachedHandler { return &AttachedHandler{ - client: client, recorder: recorder, } } type AttachedHandler struct { - client client.Client recorder eventrecord.EventRecorderLogger } diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go index 39cc0186ce..56515f5ef5 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go @@ -20,6 +20,7 @@ import ( "context" "fmt" + apierrors "k8s.io/apimachinery/pkg/api/errors" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -81,11 +82,14 @@ func (h *DeletionHandler) Handle(ctx context.Context, s state.USBDeviceState) (r Name: current.Name, } if err := h.virtClient.VirtualizationV1alpha2().VirtualMachines(vm.Namespace).RemoveResourceClaim(ctx, vm.Name, opts); err != nil { - h.recorder.Eventf(changed, "Warning", "Deletion", "Failed to remove ResourceClaim from VM %s/%s: %v", vm.Namespace, vm.Name, err) - // Continue with other VMs, but requeue to retry - return reconcile.Result{Requeue: true}, fmt.Errorf("failed to remove ResourceClaim from VM %s/%s: %w", vm.Namespace, vm.Name, err) + // Ignore NotFound - VM or ResourceClaim may have been deleted already + if !apierrors.IsNotFound(err) { + h.recorder.Eventf(changed, "Warning", "Deletion", "Failed to remove ResourceClaim from VM %s/%s: %v", vm.Namespace, vm.Name, err) + return reconcile.Result{Requeue: true}, fmt.Errorf("failed to remove ResourceClaim from VM %s/%s: %w", vm.Namespace, vm.Name, err) + } + } else { + h.recorder.Eventf(changed, "Normal", "Deletion", "Removed ResourceClaim from VM %s/%s", vm.Namespace, vm.Name) } - h.recorder.Eventf(changed, "Normal", "Deletion", "Removed ResourceClaim from VM %s/%s", vm.Namespace, vm.Name) } // Requeue to verify that device is no longer attached diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go index 8d71661a33..fc2a4eb948 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go @@ -96,7 +96,7 @@ var _ = Describe("SyncReadyHandler - Ready condition", func() { usbDeviceState = state.New(fakeClient, usbDeviceResource) recorder := &eventrecord.EventRecorderLoggerMock{} - handler = NewSyncReadyHandler(fakeClient, recorder) + handler = NewSyncReadyHandler(recorder) result, err := handler.Handle(ctx, usbDeviceState) Expect(err).NotTo(HaveOccurred()) @@ -155,7 +155,7 @@ var _ = Describe("SyncReadyHandler - Ready condition", func() { usbDeviceState = state.New(fakeClient, usbDeviceResource) recorder := &eventrecord.EventRecorderLoggerMock{} - handler = NewSyncReadyHandler(fakeClient, recorder) + handler = NewSyncReadyHandler(recorder) result, err := handler.Handle(ctx, usbDeviceState) Expect(err).NotTo(HaveOccurred()) @@ -199,7 +199,7 @@ var _ = Describe("SyncReadyHandler - Ready condition", func() { usbDeviceState = state.New(fakeClient, usbDeviceResource) recorder := &eventrecord.EventRecorderLoggerMock{} - handler = NewSyncReadyHandler(fakeClient, recorder) + handler = NewSyncReadyHandler(recorder) result, err := handler.Handle(ctx, usbDeviceState) Expect(err).NotTo(HaveOccurred()) @@ -252,7 +252,7 @@ var _ = Describe("SyncReadyHandler - Ready condition", func() { usbDeviceState = state.New(fakeClient, usbDeviceResource) recorder := &eventrecord.EventRecorderLoggerMock{} - handler = NewSyncReadyHandler(fakeClient, recorder) + handler = NewSyncReadyHandler(recorder) result, err := handler.Handle(ctx, usbDeviceState) Expect(err).NotTo(HaveOccurred()) diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go index 10f9df2f9b..9e20c54b24 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go @@ -63,11 +63,8 @@ func (s *usbDeviceState) NodeUSBDevice(ctx context.Context) (*v1alpha2.NodeUSBDe return nil, err } - // Find the NodeUSBDevice that matches by name - for i := range nodeUSBDeviceList.Items { - if nodeUSBDeviceList.Items[i].Name == usbDevice.Name { - return &nodeUSBDeviceList.Items[i], nil - } + if len(nodeUSBDeviceList.Items) > 0 { + return &nodeUSBDeviceList.Items[0], nil } return nil, nil diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_ready.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_ready.go index d03e4bbbc2..97af12efe8 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_ready.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_ready.go @@ -21,7 +21,6 @@ import ( "reflect" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" @@ -35,15 +34,13 @@ const ( nameSyncReadyHandler = "SyncReadyHandler" ) -func NewSyncReadyHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *SyncReadyHandler { +func NewSyncReadyHandler(recorder eventrecord.EventRecorderLogger) *SyncReadyHandler { return &SyncReadyHandler{ - client: client, recorder: recorder, } } type SyncReadyHandler struct { - client client.Client recorder eventrecord.EventRecorderLogger } diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go index 5d62feae1f..9271d91498 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go @@ -97,7 +97,7 @@ var _ = Describe("SyncReadyHandler", func() { usbDeviceState = state.New(fakeClient, usbDeviceResource) recorder := &eventrecord.EventRecorderLoggerMock{} - handler = NewSyncReadyHandler(fakeClient, recorder) + handler = NewSyncReadyHandler(recorder) result, err := handler.Handle(ctx, usbDeviceState) Expect(err).NotTo(HaveOccurred()) @@ -146,7 +146,7 @@ var _ = Describe("SyncReadyHandler", func() { usbDeviceState = state.New(fakeClient, usbDeviceResource) recorder := &eventrecord.EventRecorderLoggerMock{} - handler = NewSyncReadyHandler(fakeClient, recorder) + handler = NewSyncReadyHandler(recorder) result, err := handler.Handle(ctx, usbDeviceState) Expect(err).NotTo(HaveOccurred()) diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go index 584c623f42..0b42d813f7 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go @@ -50,8 +50,8 @@ func NewController( handlers := []Handler{ internal.NewDeletionHandler(client, virtClient, recorder), - internal.NewSyncReadyHandler(client, recorder), - internal.NewAttachedHandler(client, recorder), + internal.NewSyncReadyHandler(recorder), + internal.NewAttachedHandler(recorder), } r := NewReconciler(client, handlers...) From 1e4418ed6e26389d22fe96d796a4e6039c2009f9 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Thu, 29 Jan 2026 16:05:44 +0200 Subject: [PATCH 45/53] fix bugs Signed-off-by: Daniil Antoshin --- .../controller/vm/internal/usb_device_handler.go | 16 +++++++++------- .../vm/internal/usb_device_handler_test.go | 15 ++++++++++++++- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go index 99b5d43241..77615cb045 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go @@ -21,6 +21,7 @@ import ( "fmt" resourcev1beta1 "k8s.io/api/resource/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" @@ -32,6 +33,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/logger" "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned" "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/usbdevicecondition" "github.com/deckhouse/virtualization/api/core/v1alpha2/vmcondition" subv1alpha2 "github.com/deckhouse/virtualization/api/subresources/v1alpha2" ) @@ -144,7 +146,7 @@ func (h *USBDeviceHandler) Handle(ctx context.Context, s state.VirtualMachineSta // Try to attach via addResourceClaim API requestName := fmt.Sprintf("req-%s", usbDeviceRef.Name) err = h.attachUSBDevice(ctx, vm, usbDeviceRef.Name, templateName, requestName) - if err != nil { + if err != nil && !apierrors.IsAlreadyExists(err) { log.Error("failed to attach USB device", "error", err, "usbDevice", usbDeviceRef.Name) // Keep existing status or create new one, but update ready and conditions if existingStatus != nil { @@ -190,13 +192,13 @@ func (h *USBDeviceHandler) Handle(ctx context.Context, s state.VirtualMachineSta if !specDeviceNames[existingStatus.Name] && existingStatus.Attached { // Device was removed from spec but is still attached, need to detach err := h.detachUSBDevice(ctx, vm, existingStatus.Name) - if err != nil { + if err != nil && !apierrors.IsNotFound(err) { log.Error("failed to detach USB device", "error", err, "usbDevice", existingStatus.Name) // Keep status but mark as not attached existingStatus.Attached = false statusRefs = append(statusRefs, *existingStatus) } - // If detach succeeded, device is removed from status (not added to statusRefs) + // If detach succeeded or NotFound, device is removed from status (not added to statusRefs) } } @@ -259,8 +261,8 @@ func (h *USBDeviceHandler) getOrCreateResourceClaimTemplate( Namespace: vm.Namespace, OwnerReferences: []metav1.OwnerReference{ { - APIVersion: vm.APIVersion, - Kind: vm.Kind, + APIVersion: v1alpha2.SchemeGroupVersion.String(), + Kind: v1alpha2.VirtualMachineKind, Name: vm.Name, UID: vm.UID, Controller: ptr.To(true), @@ -310,8 +312,8 @@ func (h *USBDeviceHandler) isUSBDeviceReady(usbDevice *v1alpha2.USBDevice) bool // Check Ready condition for _, condition := range usbDevice.Status.Conditions { - if condition.Type == "Ready" { - return condition.Status == "True" + if condition.Type == string(usbdevicecondition.ReadyType) { + return condition.Status == metav1.ConditionTrue } } diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go index 5437d5d24c..e9055f090f 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go @@ -144,6 +144,12 @@ var _ = Describe("USBDeviceHandler", func() { ProductID: "5678", }, NodeName: "node-1", + Conditions: []metav1.Condition{ + { + Type: "Ready", + Status: metav1.ConditionTrue, + }, + }, }, } @@ -175,7 +181,8 @@ var _ = Describe("USBDeviceHandler", func() { Expect(vmResource.Changed().Status.USBDevices[0].Name).To(Equal("usb-device-1")) Expect(vmResource.Changed().Status.USBDevices[0].Attached).To(BeTrue()) Expect(vmResource.Changed().Status.USBDevices[0].Hotplugged).To(BeTrue()) - Expect(vmResource.Changed().Status.USBDevices[0].Address).NotTo(BeNil()) + // Hotplugged devices don't get a fixed address - it's assigned dynamically by hypervisor + Expect(vmResource.Changed().Status.USBDevices[0].Address).To(BeNil()) }) It("should not attach USB device when not ready", func() { @@ -369,6 +376,12 @@ var _ = Describe("USBDeviceHandler", func() { ProductID: "5678", }, NodeName: "node-1", + Conditions: []metav1.Condition{ + { + Type: "Ready", + Status: metav1.ConditionTrue, + }, + }, }, } From 8ec41f81b6c26ced90d00bb9e6fd62c23e02ee53 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Thu, 29 Jan 2026 16:21:30 +0200 Subject: [PATCH 46/53] fix client Signed-off-by: Daniil Antoshin --- .../cmd/virtualization-controller/main.go | 2 +- .../pkg/controller/vm/internal/usb_device_handler.go | 6 +++--- .../pkg/controller/vm/vm_controller.go | 10 +++------- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/images/virtualization-artifact/cmd/virtualization-controller/main.go b/images/virtualization-artifact/cmd/virtualization-controller/main.go index 75815f1b67..9dd77f4bdf 100644 --- a/images/virtualization-artifact/cmd/virtualization-controller/main.go +++ b/images/virtualization-artifact/cmd/virtualization-controller/main.go @@ -345,7 +345,7 @@ func main() { } vmLogger := logger.NewControllerLogger(vm.ControllerName, logLevel, logOutput, logDebugVerbosity, logDebugControllerList) - if err = vm.SetupController(ctx, mgr, vmLogger, dvcrSettings, firmwareImage); err != nil { + if err = vm.SetupController(ctx, mgr, virtClient, vmLogger, dvcrSettings, firmwareImage); err != nil { log.Error(err.Error()) os.Exit(1) } diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go index 77615cb045..1672febd1a 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go @@ -31,7 +31,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/vm/internal/state" "github.com/deckhouse/virtualization-controller/pkg/logger" - "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned" + "github.com/deckhouse/virtualization/api/client/kubeclient" "github.com/deckhouse/virtualization/api/core/v1alpha2" "github.com/deckhouse/virtualization/api/core/v1alpha2/usbdevicecondition" "github.com/deckhouse/virtualization/api/core/v1alpha2/vmcondition" @@ -40,7 +40,7 @@ import ( const nameUSBDeviceHandler = "USBDeviceHandler" -func NewUSBDeviceHandler(cl client.Client, virtClient versioned.Interface) *USBDeviceHandler { +func NewUSBDeviceHandler(cl client.Client, virtClient kubeclient.Client) *USBDeviceHandler { return &USBDeviceHandler{ client: cl, virtClient: virtClient, @@ -49,7 +49,7 @@ func NewUSBDeviceHandler(cl client.Client, virtClient versioned.Interface) *USBD type USBDeviceHandler struct { client client.Client - virtClient versioned.Interface + virtClient kubeclient.Client } func (h *USBDeviceHandler) Name() string { diff --git a/images/virtualization-artifact/pkg/controller/vm/vm_controller.go b/images/virtualization-artifact/pkg/controller/vm/vm_controller.go index 473c0b9411..f6471902b9 100644 --- a/images/virtualization-artifact/pkg/controller/vm/vm_controller.go +++ b/images/virtualization-artifact/pkg/controller/vm/vm_controller.go @@ -18,7 +18,6 @@ package vm import ( "context" - "fmt" "time" "k8s.io/utils/ptr" @@ -37,7 +36,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/featuregates" "github.com/deckhouse/virtualization-controller/pkg/logger" vmmetrics "github.com/deckhouse/virtualization-controller/pkg/monitoring/metrics/virtualmachine" - "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned" + "github.com/deckhouse/virtualization/api/client/kubeclient" "github.com/deckhouse/virtualization/api/core/v1alpha2" ) @@ -48,6 +47,7 @@ const ( func SetupController( ctx context.Context, mgr manager.Manager, + virtClient kubeclient.Client, log *log.Logger, dvcrSettings *dvcr.Settings, firmwareImage string, @@ -58,12 +58,8 @@ func SetupController( blockDeviceService := service.NewBlockDeviceService(client) vmClassService := service.NewVirtualMachineClassService(client) - migrateVolumesService := vmservice.NewMigrationVolumesService(client, internal.MakeKVVMFromVMSpec, 10*time.Second) - virtClient, err := versioned.NewForConfig(mgr.GetConfig()) - if err != nil { - return fmt.Errorf("failed to create virtualization client: %w", err) - } + migrateVolumesService := vmservice.NewMigrationVolumesService(client, internal.MakeKVVMFromVMSpec, 10*time.Second) handlers := []Handler{ internal.NewMaintenanceHandler(client), From 4682088d4f656be846d41bc3ccad239eefb8dff8 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Thu, 29 Jan 2026 16:49:36 +0200 Subject: [PATCH 47/53] fix webhook Signed-off-by: Daniil Antoshin --- .../nodeusbdevice/nodeusbdevice_webhook.go | 80 +++++++++---------- 1 file changed, 36 insertions(+), 44 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go index 85a6403cd7..13f8cb5e4f 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go @@ -21,6 +21,7 @@ import ( "fmt" "maps" "reflect" + "strings" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -29,6 +30,12 @@ import ( "github.com/deckhouse/virtualization/api/core/v1alpha2" ) +var systemServiceAccountPrefixes = []string{ + "system:serviceaccount:kube-system:", + "system:serviceaccount:d8-system:", + "system:serviceaccount:d8-virtualization:", +} + func NewValidator(log *log.Logger) *Validator { return &Validator{ log: log.With("webhook", "validation"), @@ -40,25 +47,23 @@ type Validator struct { } // ValidateCreate validates NodeUSBDevice creation. -// NodeUSBDevice resources are created automatically by the controller when devices are discovered. +// NodeUSBDevice resources can only be created by system service accounts (controllers). func (v *Validator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { - nodeUSBDevice, ok := obj.(*v1alpha2.NodeUSBDevice) - if !ok { - return nil, fmt.Errorf("expected a new NodeUSBDevice but got a %T", obj) + if isSystemServiceAccount(ctx) { + return nil, nil } - - v.log.Info("Validate NodeUSBDevice creating", "name", nodeUSBDevice.Name) - - // NodeUSBDevice resources are created automatically by the controller - // Manual creation is allowed for administrative purposes (e.g., testing) - // but spec.assignedNamespace can be set by administrators - return nil, nil + return nil, fmt.Errorf("NodeUSBDevice can only be created by system service accounts") } // ValidateUpdate validates NodeUSBDevice updates. // Only spec can be changed by administrators. Metadata cannot be modified. // Status updates are performed by the controller via subresource. func (v *Validator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + // System service accounts can change anything + if isSystemServiceAccount(ctx) { + return nil, nil + } + oldNodeUSBDevice, ok := oldObj.(*v1alpha2.NodeUSBDevice) if !ok { return nil, fmt.Errorf("expected an old NodeUSBDevice but got a %T", oldObj) @@ -69,41 +74,12 @@ func (v *Validator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.O return nil, fmt.Errorf("expected a new NodeUSBDevice but got a %T", newObj) } - v.log.Info("Validate NodeUSBDevice updating", "name", newNodeUSBDevice.Name) - - // TypeMeta cannot be modified - if !reflect.DeepEqual(oldNodeUSBDevice.TypeMeta, newNodeUSBDevice.TypeMeta) { - return nil, fmt.Errorf("TypeMeta cannot be changed") - } - - // Metadata cannot be modified (except fields that Kubernetes manages automatically) - if oldNodeUSBDevice.Name != newNodeUSBDevice.Name { - return nil, fmt.Errorf("metadata.name cannot be changed") - } - if oldNodeUSBDevice.Namespace != newNodeUSBDevice.Namespace { - return nil, fmt.Errorf("metadata.namespace cannot be changed") - } - if oldNodeUSBDevice.UID != newNodeUSBDevice.UID { - return nil, fmt.Errorf("metadata.uid cannot be changed") - } - if !maps.Equal(oldNodeUSBDevice.Labels, newNodeUSBDevice.Labels) { - return nil, fmt.Errorf("metadata.labels cannot be changed") - } - if !maps.Equal(oldNodeUSBDevice.Annotations, newNodeUSBDevice.Annotations) { - return nil, fmt.Errorf("metadata.annotations cannot be changed") - } - if !reflect.DeepEqual(oldNodeUSBDevice.Finalizers, newNodeUSBDevice.Finalizers) { - return nil, fmt.Errorf("metadata.finalizers cannot be changed") - } - - // Status changes are not allowed via main resource update (use /status subresource) - if !reflect.DeepEqual(oldNodeUSBDevice.Status, newNodeUSBDevice.Status) { - return nil, fmt.Errorf("status cannot be changed via main resource update, use /status subresource") + // Spec changes are only allowed + if !reflect.DeepEqual(oldNodeUSBDevice.Spec, newNodeUSBDevice.Spec) { + return nil, nil } - // Only spec can be changed - // This is allowed - administrators can modify spec (e.g., assignedNamespace) - return nil, nil + return nil, fmt.Errorf("only spec.assignedNamespace can be changed") } // ValidateDelete validates NodeUSBDevice deletion. @@ -120,3 +96,19 @@ func (v *Validator) ValidateDelete(ctx context.Context, obj runtime.Object) (adm // The controller will clean up associated USBDevice resources via finalizer return nil, nil } + +// isSystemServiceAccount checks if the request is made by a system service account. +func isSystemServiceAccount(ctx context.Context) bool { + req, err := admission.RequestFromContext(ctx) + if err != nil { + return false + } + + for _, systemServiceAccountPrefix := range systemServiceAccountPrefixes { + if strings.HasPrefix(req.UserInfo.Username, systemServiceAccountPrefix) { + return true + } + } + + return false +} From ab91922a768b3a1ead3fc7e38a24e87f5e05b22a Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Thu, 29 Jan 2026 16:54:24 +0200 Subject: [PATCH 48/53] fix Signed-off-by: Daniil Antoshin --- .../pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go | 1 - 1 file changed, 1 deletion(-) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go index 13f8cb5e4f..71f50e4f89 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go @@ -19,7 +19,6 @@ package nodeusbdevice import ( "context" "fmt" - "maps" "reflect" "strings" From bff06792a6a916c2a19d51713c04909632cad84e Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Thu, 29 Jan 2026 16:57:04 +0200 Subject: [PATCH 49/53] fix Signed-off-by: Daniil Antoshin --- .../nodeusbdevice/nodeusbdevice_webhook.go | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go index 71f50e4f89..5bd1b3b131 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go @@ -29,12 +29,6 @@ import ( "github.com/deckhouse/virtualization/api/core/v1alpha2" ) -var systemServiceAccountPrefixes = []string{ - "system:serviceaccount:kube-system:", - "system:serviceaccount:d8-system:", - "system:serviceaccount:d8-virtualization:", -} - func NewValidator(log *log.Logger) *Validator { return &Validator{ log: log.With("webhook", "validation"), @@ -103,10 +97,10 @@ func isSystemServiceAccount(ctx context.Context) bool { return false } - for _, systemServiceAccountPrefix := range systemServiceAccountPrefixes { - if strings.HasPrefix(req.UserInfo.Username, systemServiceAccountPrefix) { - return true - } + if strings.HasPrefix(req.UserInfo.Username, "system:serviceaccount:kube-system:") || + strings.HasPrefix(req.UserInfo.Username, "system:serviceaccount:d8-system:") || + strings.HasPrefix(req.UserInfo.Username, "system:serviceaccount:d8-virtualization:") { + return true } return false From 8de7b47c5515d45079af0c7d967a868436a4367d Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Thu, 29 Jan 2026 17:06:02 +0200 Subject: [PATCH 50/53] fix test Signed-off-by: Daniil Antoshin --- .../vm/internal/usb_device_handler.go | 15 ++-- .../vm/internal/usb_device_handler_test.go | 71 +++++++++++++++---- 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go index 1672febd1a..28847a09fc 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go @@ -31,16 +31,21 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/vm/internal/state" "github.com/deckhouse/virtualization-controller/pkg/logger" - "github.com/deckhouse/virtualization/api/client/kubeclient" + virtualizationv1alpha2 "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/typed/core/v1alpha2" "github.com/deckhouse/virtualization/api/core/v1alpha2" "github.com/deckhouse/virtualization/api/core/v1alpha2/usbdevicecondition" "github.com/deckhouse/virtualization/api/core/v1alpha2/vmcondition" subv1alpha2 "github.com/deckhouse/virtualization/api/subresources/v1alpha2" ) +// VirtClient is an interface for accessing VirtualMachine resources with subresource operations. +type VirtClient interface { + VirtualMachines(namespace string) virtualizationv1alpha2.VirtualMachineInterface +} + const nameUSBDeviceHandler = "USBDeviceHandler" -func NewUSBDeviceHandler(cl client.Client, virtClient kubeclient.Client) *USBDeviceHandler { +func NewUSBDeviceHandler(cl client.Client, virtClient VirtClient) *USBDeviceHandler { return &USBDeviceHandler{ client: cl, virtClient: virtClient, @@ -49,7 +54,7 @@ func NewUSBDeviceHandler(cl client.Client, virtClient kubeclient.Client) *USBDev type USBDeviceHandler struct { client client.Client - virtClient kubeclient.Client + virtClient VirtClient } func (h *USBDeviceHandler) Name() string { @@ -335,7 +340,7 @@ func (h *USBDeviceHandler) attachUSBDevice( RequestName: requestName, } - return h.virtClient.VirtualizationV1alpha2().VirtualMachines(vm.Namespace).AddResourceClaim(ctx, vm.Name, opts) + return h.virtClient.VirtualMachines(vm.Namespace).AddResourceClaim(ctx, vm.Name, opts) } func (h *USBDeviceHandler) detachUSBDevice( @@ -348,7 +353,7 @@ func (h *USBDeviceHandler) detachUSBDevice( Name: usbDeviceName, } - return h.virtClient.VirtualizationV1alpha2().VirtualMachines(vm.Namespace).RemoveResourceClaim(ctx, vm.Name, opts) + return h.virtClient.VirtualMachines(vm.Namespace).RemoveResourceClaim(ctx, vm.Name, opts) } func (h *USBDeviceHandler) getOrAssignUSBAddress( diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go index e9055f090f..7ed7611e9e 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go @@ -34,21 +34,62 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" "github.com/deckhouse/virtualization-controller/pkg/controller/vm/internal/state" "github.com/deckhouse/virtualization-controller/pkg/logger" - fakeversioned "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/fake" + virtualizationv1alpha2 "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/typed/core/v1alpha2" "github.com/deckhouse/virtualization/api/core/v1alpha2" + subv1alpha2 "github.com/deckhouse/virtualization/api/subresources/v1alpha2" ) +// mockVirtClient implements kubeclient.Client interface for testing +type mockVirtClient struct { + vmClients map[string]*mockVirtualMachines +} + +func newMockVirtClient() *mockVirtClient { + return &mockVirtClient{ + vmClients: make(map[string]*mockVirtualMachines), + } +} + +func (m *mockVirtClient) VirtualMachines(namespace string) virtualizationv1alpha2.VirtualMachineInterface { + if _, ok := m.vmClients[namespace]; !ok { + m.vmClients[namespace] = &mockVirtualMachines{ + addResourceClaimCalls: make([]subv1alpha2.VirtualMachineAddResourceClaim, 0), + removeResourceClaimCalls: make([]subv1alpha2.VirtualMachineRemoveResourceClaim, 0), + } + } + return m.vmClients[namespace] +} + +// mockVirtualMachines implements VirtualMachineInterface for testing +type mockVirtualMachines struct { + virtualizationv1alpha2.VirtualMachineInterface + addResourceClaimCalls []subv1alpha2.VirtualMachineAddResourceClaim + removeResourceClaimCalls []subv1alpha2.VirtualMachineRemoveResourceClaim + addResourceClaimErr error + removeResourceClaimErr error +} + +func (m *mockVirtualMachines) AddResourceClaim(_ context.Context, _ string, opts subv1alpha2.VirtualMachineAddResourceClaim) error { + m.addResourceClaimCalls = append(m.addResourceClaimCalls, opts) + return m.addResourceClaimErr +} + +func (m *mockVirtualMachines) RemoveResourceClaim(_ context.Context, _ string, opts subv1alpha2.VirtualMachineRemoveResourceClaim) error { + m.removeResourceClaimCalls = append(m.removeResourceClaimCalls, opts) + return m.removeResourceClaimErr +} + var _ = Describe("USBDeviceHandler", func() { var ctx context.Context var fakeClient client.WithWatch - var fakeVirtClient *fakeversioned.Clientset + var mockVirtCl *mockVirtClient var handler *USBDeviceHandler var vmState state.VirtualMachineState var vmResource *reconciler.Resource[*v1alpha2.VirtualMachine, v1alpha2.VirtualMachineStatus] BeforeEach(func() { ctx = logger.ToContext(context.TODO(), slog.Default()) - fakeVirtClient = fakeversioned.NewSimpleClientset() + mockVirtCl = newMockVirtClient() }) Context("when handling USB devices", func() { @@ -98,9 +139,7 @@ var _ = Describe("USBDeviceHandler", func() { Expect(vmResource.Fetch(ctx)).To(Succeed()) vmState = state.New(fakeClient, vmResource) - handler = NewUSBDeviceHandler(fakeClient, fakeVirtClient) - - // Fake client already implements AddResourceClaim, no need to mock + handler = NewUSBDeviceHandler(fakeClient, mockVirtCl) result, err := handler.Handle(ctx, vmState) Expect(err).NotTo(HaveOccurred()) @@ -168,13 +207,16 @@ var _ = Describe("USBDeviceHandler", func() { Expect(vmResource.Fetch(ctx)).To(Succeed()) vmState = state.New(fakeClient, vmResource) - handler = NewUSBDeviceHandler(fakeClient, fakeVirtClient) + handler = NewUSBDeviceHandler(fakeClient, mockVirtCl) result, err := handler.Handle(ctx, vmState) Expect(err).NotTo(HaveOccurred()) Expect(result).To(Equal(reconcile.Result{})) - // Verify AddResourceClaim was called (fake client implements it) + // Verify AddResourceClaim was called + mockVM := mockVirtCl.vmClients["default"] + Expect(mockVM.addResourceClaimCalls).To(HaveLen(1)) + Expect(mockVM.addResourceClaimCalls[0].Name).To(Equal("usb-device-1")) // Verify status was updated Expect(vmResource.Changed().Status.USBDevices).To(HaveLen(1)) @@ -231,7 +273,7 @@ var _ = Describe("USBDeviceHandler", func() { Expect(vmResource.Fetch(ctx)).To(Succeed()) vmState = state.New(fakeClient, vmResource) - handler = NewUSBDeviceHandler(fakeClient, fakeVirtClient) + handler = NewUSBDeviceHandler(fakeClient, mockVirtCl) result, err := handler.Handle(ctx, vmState) Expect(err).NotTo(HaveOccurred()) @@ -273,7 +315,7 @@ var _ = Describe("USBDeviceHandler", func() { Expect(vmResource.Fetch(ctx)).To(Succeed()) vmState = state.New(fakeClient, vmResource) - handler = NewUSBDeviceHandler(fakeClient, fakeVirtClient) + handler = NewUSBDeviceHandler(fakeClient, mockVirtCl) result, err := handler.Handle(ctx, vmState) Expect(err).NotTo(HaveOccurred()) @@ -324,13 +366,16 @@ var _ = Describe("USBDeviceHandler", func() { Expect(vmResource.Fetch(ctx)).To(Succeed()) vmState = state.New(fakeClient, vmResource) - handler = NewUSBDeviceHandler(fakeClient, fakeVirtClient) + handler = NewUSBDeviceHandler(fakeClient, mockVirtCl) result, err := handler.Handle(ctx, vmState) Expect(err).NotTo(HaveOccurred()) Expect(result).To(Equal(reconcile.Result{})) - // Verify RemoveResourceClaim was called (fake client implements it) + // Verify RemoveResourceClaim was called + mockVM := mockVirtCl.vmClients["default"] + Expect(mockVM.removeResourceClaimCalls).To(HaveLen(1)) + Expect(mockVM.removeResourceClaimCalls[0].Name).To(Equal("usb-device-1")) // Verify device was removed from status Expect(vmResource.Changed().Status.USBDevices).To(BeEmpty()) @@ -400,7 +445,7 @@ var _ = Describe("USBDeviceHandler", func() { Expect(vmResource.Fetch(ctx)).To(Succeed()) vmState = state.New(fakeClient, vmResource) - handler = NewUSBDeviceHandler(fakeClient, fakeVirtClient) + handler = NewUSBDeviceHandler(fakeClient, mockVirtCl) result, err := handler.Handle(ctx, vmState) Expect(err).NotTo(HaveOccurred()) From a76b103e13befa8856c17a7cb3a0f904a090df4d Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Thu, 29 Jan 2026 18:18:32 +0200 Subject: [PATCH 51/53] Revert "move to separate controller" This reverts commit cd134d981490d6a8ae13294ccb43e95362065e4f. Signed-off-by: Daniil Antoshin --- .../cmd/virtualization-controller/main.go | 7 - .../nodeusbdevice/internal/discovery.go | 369 ++++++++++++++++++ .../nodeusbdevice/internal}/hash/hash.go | 0 .../nodeusbdevice/internal/ready.go | 2 +- .../internal/watcher/resourceslice_watcher.go | 35 +- .../nodeusbdevice/nodeusbdevice_controller.go | 1 + .../nodeusbdevice/nodeusbdevice_reconciler.go | 20 +- .../internal/nodeusbdevice_handler.go | 224 ----------- .../resourceslice/resourceslice_controller.go | 73 ---- .../resourceslice/resourceslice_reconciler.go | 70 ---- 10 files changed, 422 insertions(+), 379 deletions(-) create mode 100644 images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go rename images/virtualization-artifact/pkg/{common => controller/nodeusbdevice/internal}/hash/hash.go (100%) delete mode 100644 images/virtualization-artifact/pkg/controller/resourceslice/internal/nodeusbdevice_handler.go delete mode 100644 images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_controller.go delete mode 100644 images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_reconciler.go diff --git a/images/virtualization-artifact/cmd/virtualization-controller/main.go b/images/virtualization-artifact/cmd/virtualization-controller/main.go index 9dd77f4bdf..451b7b4b19 100644 --- a/images/virtualization-artifact/cmd/virtualization-controller/main.go +++ b/images/virtualization-artifact/cmd/virtualization-controller/main.go @@ -48,7 +48,6 @@ import ( mc "github.com/deckhouse/virtualization-controller/pkg/controller/moduleconfig" mcapi "github.com/deckhouse/virtualization-controller/pkg/controller/moduleconfig/api" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice" - "github.com/deckhouse/virtualization-controller/pkg/controller/resourceslice" "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice" "github.com/deckhouse/virtualization-controller/pkg/controller/vd" "github.com/deckhouse/virtualization-controller/pkg/controller/vdsnapshot" @@ -378,12 +377,6 @@ func main() { os.Exit(1) } - resourcesliceLogger := logger.NewControllerLogger(resourceslice.ControllerName, logLevel, logOutput, logDebugVerbosity, logDebugControllerList) - if _, err = resourceslice.NewController(ctx, mgr, resourcesliceLogger); err != nil { - log.Error(err.Error()) - os.Exit(1) - } - nodeusbdeviceLogger := logger.NewControllerLogger(nodeusbdevice.ControllerName, logLevel, logOutput, logDebugVerbosity, logDebugControllerList) if _, err = nodeusbdevice.NewController(ctx, mgr, nodeusbdeviceLogger); err != nil { log.Error(err.Error()) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go new file mode 100644 index 0000000000..1d0d64a451 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go @@ -0,0 +1,369 @@ +/* +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 internal + +import ( + "context" + "fmt" + "strings" + + resourcev1beta1 "k8s.io/api/resource/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/hash" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" +) + +const ( + nameDiscoveryHandler = "DiscoveryHandler" +) + +func NewDiscoveryHandler(client client.Client, recorder eventrecord.EventRecorderLogger) *DiscoveryHandler { + return &DiscoveryHandler{ + client: client, + recorder: recorder, + } +} + +type DiscoveryHandler struct { + client client.Client + recorder eventrecord.EventRecorderLogger +} + +func (h *DiscoveryHandler) Name() string { + return nameDiscoveryHandler +} + +func (h *DiscoveryHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) (reconcile.Result, error) { + nodeUSBDevice := s.NodeUSBDevice() + + // Get ResourceSlices once for both discovery and update + resourceSlices, err := s.ResourceSlices(ctx) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to get resource slices: %w", err) + } + + // Check for new devices in ResourceSlice and create NodeUSBDevice if needed + // This ensures we discover new devices even if reconcile was triggered for other reasons + if err := h.discoverAndCreate(ctx, s, resourceSlices); err != nil { + // Log error but don't fail reconciliation + // This is a best-effort discovery mechanism + log.Error("failed to discover and create NodeUSBDevice", log.Err(err)) + } + + if nodeUSBDevice.IsEmpty() { + // Resource doesn't exist - nothing to update + return reconcile.Result{}, nil + } + + current := nodeUSBDevice.Current() + changed := nodeUSBDevice.Changed() + + // Update attributes from ResourceSlice if needed + deviceInfo, found := h.findDeviceInSlices(resourceSlices, current.Status.Attributes.Hash, current.Status.NodeName) + if !found { + // Device not found in slices - mark as NotFound + cb := conditions.NewConditionBuilder(nodeusbdevicecondition.ReadyType). + Generation(current.GetGeneration()). + Status(metav1.ConditionFalse). + Reason(nodeusbdevicecondition.NotFound). + Message("Device not found in ResourceSlice") + conditions.SetCondition(cb, &changed.Status.Conditions) + return reconcile.Result{}, nil + } + + // Update attributes if they changed + if !h.attributesEqual(current.Status.Attributes, deviceInfo) { + changed.Status.Attributes = deviceInfo + } + + return reconcile.Result{}, nil +} + +func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context, s state.NodeUSBDeviceState, resourceSlices []resourcev1beta1.ResourceSlice) error { + // Check if current device exists - if it does, we only need to check for new devices + // This avoids unnecessary List when reconciling existing devices + currentDevice := s.NodeUSBDevice() + hasCurrentDevice := !currentDevice.IsEmpty() + + // Collect all hashes from ResourceSlices first + deviceHashesInSlices := make(map[string]bool) + for _, slice := range resourceSlices { + for _, device := range slice.Spec.Devices { + if !strings.HasPrefix(device.Name, "usb-") { + continue + } + hash := hash.CalculateHashFromDevice(device, slice.Spec.Pool.Name) + deviceHashesInSlices[hash] = true + } + } + + // If we have a current device and its hash is in slices, we can skip List + // Only do List if we need to check for new devices + if hasCurrentDevice { + current := currentDevice.Current() + if current.Status.Attributes.Hash != "" && deviceHashesInSlices[current.Status.Attributes.Hash] { + // Current device exists and is in slices - only check for new devices + // We still need to List to check for duplicates, but we can optimize + // by only checking hashes that are in slices + var existingDevices v1alpha2.NodeUSBDeviceList + if err := h.client.List(ctx, &existingDevices); err != nil { + return fmt.Errorf("failed to list existing NodeUSBDevices: %w", err) + } + + existingHashes := make(map[string]bool) + for _, device := range existingDevices.Items { + if device.Status.Attributes.Hash != "" { + existingHashes[device.Status.Attributes.Hash] = true + } + } + + // Only create devices that are in slices but not in existing + for _, slice := range resourceSlices { + for _, device := range slice.Spec.Devices { + if !strings.HasPrefix(device.Name, "usb-") { + continue + } + + attributes := h.convertDeviceToAttributes(device, slice.Spec.Pool.Name) + hash := hash.CalculateHash(attributes) + + if !existingHashes[hash] { + if err := h.createNodeUSBDevice(ctx, attributes, hash); err != nil { + return err + } + } + } + } + return nil + } + } + + // No current device or it's not in slices - need full List + var existingDevices v1alpha2.NodeUSBDeviceList + if err := h.client.List(ctx, &existingDevices); err != nil { + return fmt.Errorf("failed to list existing NodeUSBDevices: %w", err) + } + + existingHashes := make(map[string]bool) + for _, device := range existingDevices.Items { + if device.Status.Attributes.Hash != "" { + existingHashes[device.Status.Attributes.Hash] = true + } + } + + // Create NodeUSBDevice for each USB device in ResourceSlices + // Note: resourceSlices are already filtered by draDriverName in state.ResourceSlices + for _, slice := range resourceSlices { + for _, device := range slice.Spec.Devices { + if !strings.HasPrefix(device.Name, "usb-") { + continue + } + + attributes := h.convertDeviceToAttributes(device, slice.Spec.Pool.Name) + hash := hash.CalculateHash(attributes) + + if existingHashes[hash] { + continue + } + + if err := h.createNodeUSBDevice(ctx, attributes, hash); err != nil { + return err + } + } + } + + return nil +} + +func (h *DiscoveryHandler) createNodeUSBDevice(ctx context.Context, attributes v1alpha2.NodeUSBDeviceAttributes, hash string) error { + name := h.generateName(hash, attributes.NodeName) + + // Check if device already exists + existing := &v1alpha2.NodeUSBDevice{} + err := h.client.Get(ctx, client.ObjectKey{Name: name}, existing) + if err == nil { + // Device already exists, skip creation + return nil + } + if !apierrors.IsNotFound(err) { + // Unexpected error + return fmt.Errorf("failed to check if NodeUSBDevice exists: %w", err) + } + + // Create NodeUSBDevice without status (status is a subresource) + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: v1alpha2.NodeUSBDeviceSpec{ + AssignedNamespace: "", + }, + } + + if err := h.client.Create(ctx, nodeUSBDevice); err != nil { + // If device was created by another process between check and create, ignore the error + if apierrors.IsAlreadyExists(err) { + return nil + } + return fmt.Errorf("failed to create NodeUSBDevice: %w", err) + } + + // Update status separately (status is a subresource) + // Set all attributes including Hash + attributes.Hash = hash + nodeUSBDevice.Status = v1alpha2.NodeUSBDeviceStatus{ + Attributes: attributes, + NodeName: attributes.NodeName, + Conditions: []metav1.Condition{ + { + Type: string(nodeusbdevicecondition.ReadyType), + Status: metav1.ConditionTrue, + Reason: string(nodeusbdevicecondition.Ready), + Message: "Device is ready to use", + LastTransitionTime: metav1.Now(), + }, + { + Type: string(nodeusbdevicecondition.AssignedType), + Status: metav1.ConditionFalse, + Reason: string(nodeusbdevicecondition.Available), + Message: "No namespace is assigned for the device", + LastTransitionTime: metav1.Now(), + }, + }, + } + + if err := h.client.Status().Update(ctx, nodeUSBDevice); err != nil { + return fmt.Errorf("failed to update NodeUSBDevice status: %w", err) + } + + return nil +} + +func (h *DiscoveryHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice, searchedHash, nodeName string) (v1alpha2.NodeUSBDeviceAttributes, bool) { + for _, slice := range slices { + if slice.Spec.Pool.Name != nodeName { + continue + } + + for _, device := range slice.Spec.Devices { + if !strings.HasPrefix(device.Name, "usb-") { + continue + } + + attributes := h.convertDeviceToAttributes(device, nodeName) + deviceHash := hash.CalculateHash(attributes) + + if deviceHash == searchedHash { + return attributes, true + } + } + } + + return v1alpha2.NodeUSBDeviceAttributes{}, false +} + +func (h *DiscoveryHandler) convertDeviceToAttributes(device resourcev1beta1.Device, nodeName string) v1alpha2.NodeUSBDeviceAttributes { + attrs := v1alpha2.NodeUSBDeviceAttributes{ + NodeName: nodeName, + Name: device.Name, + } + + if device.Basic == nil { + return attrs + } + + for key, attr := range device.Basic.Attributes { + switch string(key) { + case "name": + if attr.StringValue != nil { + attrs.Name = *attr.StringValue + } + case "manufacturer": + if attr.StringValue != nil { + attrs.Manufacturer = *attr.StringValue + } + case "product": + if attr.StringValue != nil { + attrs.Product = *attr.StringValue + } + case "vendorID": + if attr.StringValue != nil { + attrs.VendorID = *attr.StringValue + } + case "productID": + if attr.StringValue != nil { + attrs.ProductID = *attr.StringValue + } + case "bcd": + if attr.StringValue != nil { + attrs.BCD = *attr.StringValue + } + case "bus": + if attr.StringValue != nil { + attrs.Bus = *attr.StringValue + } + case "deviceNumber": + if attr.StringValue != nil { + attrs.DeviceNumber = *attr.StringValue + } + case "serial": + if attr.StringValue != nil { + attrs.Serial = *attr.StringValue + } + case "devicePath": + if attr.StringValue != nil { + attrs.DevicePath = *attr.StringValue + } + case "major": + if attr.IntValue != nil { + attrs.Major = int(*attr.IntValue) + } + case "minor": + if attr.IntValue != nil { + attrs.Minor = int(*attr.IntValue) + } + } + } + + attrs.Hash = hash.CalculateHash(attrs) + return attrs +} + +func (h *DiscoveryHandler) generateName(hash, nodeName string) string { + // Generate name based on hash and node name + // Format: nusb-- + nodeNameSanitized := strings.ToLower(strings.ReplaceAll(nodeName, ".", "-")) + return fmt.Sprintf("nusb-%s-%s", hash[:8], nodeNameSanitized) +} + +func (h *DiscoveryHandler) attributesEqual(a, b v1alpha2.NodeUSBDeviceAttributes) bool { + return a.Hash == b.Hash && + a.NodeName == b.NodeName && + a.VendorID == b.VendorID && + a.ProductID == b.ProductID && + a.Bus == b.Bus && + a.DeviceNumber == b.DeviceNumber +} diff --git a/images/virtualization-artifact/pkg/common/hash/hash.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/hash/hash.go similarity index 100% rename from images/virtualization-artifact/pkg/common/hash/hash.go rename to images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/hash/hash.go diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go index 4338f08993..890bae401d 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go @@ -26,7 +26,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" - "github.com/deckhouse/virtualization-controller/pkg/common/hash" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/hash" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go index 7b51a3e8ef..993f9233e8 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go @@ -21,6 +21,7 @@ import ( "strings" resourcev1beta1 "k8s.io/api/resource/v1beta1" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" @@ -60,25 +61,53 @@ func (w *ResourceSliceWatcher) Watch(mgr manager.Manager, ctr controller.Control break } } + + // If no USB devices in this ResourceSlice, skip reconciliation if !hasUSBDevices { return nil } - // Enqueue NodeUSBDevices on this node so they can sync attributes from the updated slice + var result []reconcile.Request + client := mgr.GetClient() + + // Enqueue all existing NodeUSBDevices for reconciliation deviceList := &v1alpha2.NodeUSBDeviceList{} - if err := mgr.GetClient().List(ctx, deviceList); err != nil { + if err := client.List(ctx, deviceList); err != nil { log.Error("failed to list NodeUSBDevices in ResourceSliceWatcher", log.Err(err)) return nil } - var result []reconcile.Request + hasDevicesOnNode := false for _, device := range deviceList.Items { + // Only enqueue devices from the same node as the ResourceSlice if device.Status.NodeName == slice.Spec.Pool.Name { + hasDevicesOnNode = true result = append(result, reconcile.Request{ NamespacedName: object.NamespacedName(&device), }) } } + + // If no devices exist on this node yet, trigger discovery + if !hasDevicesOnNode { + if len(deviceList.Items) > 0 { + // Enqueue first device to trigger reconciliation cycle + // DiscoveryHandler will discover new devices during this cycle + result = append(result, reconcile.Request{ + NamespacedName: object.NamespacedName(&deviceList.Items[0]), + }) + } else { + // If no NodeUSBDevices exist at all, create a dummy reconcile request + // to trigger discovery. The reconciler will handle empty NodeUSBDevice + // and run DiscoveryHandler to discover new devices. + result = append(result, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "discovery-" + slice.Spec.Pool.Name, + }, + }) + } + } + return result }), ), diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go index f328209945..8bb43916d3 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go @@ -48,6 +48,7 @@ func NewController( internal.NewDeletionHandler(client, recorder), internal.NewReadyHandler(recorder), internal.NewAssignedHandler(client, recorder), + internal.NewDiscoveryHandler(client, recorder), } r := NewReconciler(client, handlers...) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go index 411052f80f..ecb464df00 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go @@ -19,6 +19,7 @@ package nodeusbdevice import ( "context" "fmt" + "log/slog" "reflect" "sigs.k8s.io/controller-runtime/pkg/client" @@ -31,6 +32,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/watcher" "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization-controller/pkg/logger" "github.com/deckhouse/virtualization/api/core/v1alpha2" ) @@ -78,6 +80,8 @@ func (r *Reconciler) SetupController(_ context.Context, mgr manager.Manager, ctr } func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + log := logger.FromContext(ctx) + nodeUSBDevice := reconciler.NewResource(req.NamespacedName, r.client, r.factory, r.statusGetter) err := nodeUSBDevice.Fetch(ctx) @@ -87,8 +91,22 @@ func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reco s := state.New(r.client, nodeUSBDevice) + // If NodeUSBDevice doesn't exist, only run DiscoveryHandler to discover new devices if nodeUSBDevice.IsEmpty() { - // NodeUSBDevice is created by the ResourceSlice controller + log.Info("Reconcile observe an absent NodeUSBDevice: running discovery to find new devices") + + // Find DiscoveryHandler and run it + for _, handler := range r.handlers { + if handler.Name() == "DiscoveryHandler" { + result, err := handler.Handle(ctx, s) + if err != nil { + log.Error("DiscoveryHandler failed", slog.Attr{Key: "error", Value: slog.StringValue(err.Error())}) + } + return result, err + } + } + + // If DiscoveryHandler not found, return return reconcile.Result{}, nil } diff --git a/images/virtualization-artifact/pkg/controller/resourceslice/internal/nodeusbdevice_handler.go b/images/virtualization-artifact/pkg/controller/resourceslice/internal/nodeusbdevice_handler.go deleted file mode 100644 index 1e813fe79e..0000000000 --- a/images/virtualization-artifact/pkg/controller/resourceslice/internal/nodeusbdevice_handler.go +++ /dev/null @@ -1,224 +0,0 @@ -/* -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 internal - -import ( - "context" - "fmt" - "strings" - - resourcev1beta1 "k8s.io/api/resource/v1beta1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/deckhouse/virtualization-controller/pkg/common/hash" - "github.com/deckhouse/virtualization/api/core/v1alpha2" - "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" -) - -const ( - nameNodeUSBDeviceHandler = "NodeUSBDeviceHandler" -) - -func NewNodeUSBDeviceHandler(client client.Client) *NodeUSBDeviceHandler { - return &NodeUSBDeviceHandler{client: client} -} - -type NodeUSBDeviceHandler struct { - client client.Client -} - -func (h *NodeUSBDeviceHandler) Name() string { - return nameNodeUSBDeviceHandler -} - -func (h *NodeUSBDeviceHandler) Handle(ctx context.Context, slice *resourcev1beta1.ResourceSlice) error { - hasUSBDevices := false - for _, device := range slice.Spec.Devices { - if strings.HasPrefix(device.Name, "usb-") { - hasUSBDevices = true - break - } - } - if !hasUSBDevices { - return nil - } - - nodeName := slice.Spec.Pool.Name - - var existingDevices v1alpha2.NodeUSBDeviceList - if err := h.client.List(ctx, &existingDevices); err != nil { - return fmt.Errorf("list NodeUSBDevices: %w", err) - } - - existingHashes := make(map[string]bool) - for _, device := range existingDevices.Items { - if device.Status.Attributes.Hash != "" { - existingHashes[device.Status.Attributes.Hash] = true - } - } - - for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "usb-") { - continue - } - - attributes := convertDeviceToAttributes(device, nodeName) - hashStr := hash.CalculateHash(attributes) - - if existingHashes[hashStr] { - continue - } - - if err := h.createNodeUSBDevice(ctx, attributes, hashStr); err != nil { - return err - } - - existingHashes[hashStr] = true - } - - return nil -} - -func (h *NodeUSBDeviceHandler) createNodeUSBDevice(ctx context.Context, attributes v1alpha2.NodeUSBDeviceAttributes, hashStr string) error { - name := generateName(hashStr, attributes.NodeName) - - existing := &v1alpha2.NodeUSBDevice{} - err := h.client.Get(ctx, client.ObjectKey{Name: name}, existing) - if err == nil { - return nil - } - if !apierrors.IsNotFound(err) { - return fmt.Errorf("check NodeUSBDevice %s: %w", name, err) - } - - nodeUSBDevice := &v1alpha2.NodeUSBDevice{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - }, - Spec: v1alpha2.NodeUSBDeviceSpec{ - AssignedNamespace: "", - }, - } - - if err := h.client.Create(ctx, nodeUSBDevice); err != nil { - if apierrors.IsAlreadyExists(err) { - return nil - } - return fmt.Errorf("create NodeUSBDevice %s: %w", name, err) - } - - attributes.Hash = hashStr - nodeUSBDevice.Status = v1alpha2.NodeUSBDeviceStatus{ - Attributes: attributes, - NodeName: attributes.NodeName, - Conditions: []metav1.Condition{ - { - Type: string(nodeusbdevicecondition.ReadyType), - Status: metav1.ConditionTrue, - Reason: string(nodeusbdevicecondition.Ready), - Message: "Device is ready to use", - LastTransitionTime: metav1.Now(), - }, - { - Type: string(nodeusbdevicecondition.AssignedType), - Status: metav1.ConditionFalse, - Reason: string(nodeusbdevicecondition.Available), - Message: "No namespace is assigned for the device", - LastTransitionTime: metav1.Now(), - }, - }, - } - - if err := h.client.Status().Update(ctx, nodeUSBDevice); err != nil { - return fmt.Errorf("update NodeUSBDevice %s status: %w", name, err) - } - - return nil -} - -func convertDeviceToAttributes(device resourcev1beta1.Device, nodeName string) v1alpha2.NodeUSBDeviceAttributes { - attrs := v1alpha2.NodeUSBDeviceAttributes{ - NodeName: nodeName, - Name: device.Name, - } - - if device.Basic == nil { - return attrs - } - - for key, attr := range device.Basic.Attributes { - switch string(key) { - case "name": - if attr.StringValue != nil { - attrs.Name = *attr.StringValue - } - case "manufacturer": - if attr.StringValue != nil { - attrs.Manufacturer = *attr.StringValue - } - case "product": - if attr.StringValue != nil { - attrs.Product = *attr.StringValue - } - case "vendorID": - if attr.StringValue != nil { - attrs.VendorID = *attr.StringValue - } - case "productID": - if attr.StringValue != nil { - attrs.ProductID = *attr.StringValue - } - case "bcd": - if attr.StringValue != nil { - attrs.BCD = *attr.StringValue - } - case "bus": - if attr.StringValue != nil { - attrs.Bus = *attr.StringValue - } - case "deviceNumber": - if attr.StringValue != nil { - attrs.DeviceNumber = *attr.StringValue - } - case "serial": - if attr.StringValue != nil { - attrs.Serial = *attr.StringValue - } - case "devicePath": - if attr.StringValue != nil { - attrs.DevicePath = *attr.StringValue - } - case "major": - if attr.IntValue != nil { - attrs.Major = int(*attr.IntValue) - } - case "minor": - if attr.IntValue != nil { - attrs.Minor = int(*attr.IntValue) - } - } - } - - return attrs -} - -func generateName(hashStr, nodeName string) string { - nodeNameSanitized := strings.ToLower(strings.ReplaceAll(nodeName, ".", "-")) - return fmt.Sprintf("nusb-%s-%s", hashStr[:8], nodeNameSanitized) -} diff --git a/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_controller.go b/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_controller.go deleted file mode 100644 index 490fcac72c..0000000000 --- a/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_controller.go +++ /dev/null @@ -1,73 +0,0 @@ -/* -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 resourceslice - -import ( - "context" - "fmt" - "time" - - resourcev1beta1 "k8s.io/api/resource/v1beta1" - "k8s.io/utils/ptr" - "sigs.k8s.io/controller-runtime/pkg/controller" - "sigs.k8s.io/controller-runtime/pkg/handler" - "sigs.k8s.io/controller-runtime/pkg/manager" - "sigs.k8s.io/controller-runtime/pkg/source" - - "github.com/deckhouse/deckhouse/pkg/log" - "github.com/deckhouse/virtualization-controller/pkg/controller/resourceslice/internal" - "github.com/deckhouse/virtualization-controller/pkg/logger" -) - -const ( - ControllerName = "resourceslice-controller" -) - -func NewController( - ctx context.Context, - mgr manager.Manager, - log *log.Logger, -) (controller.Controller, error) { - client := mgr.GetClient() - - handlers := []Handler{ - internal.NewNodeUSBDeviceHandler(client), - } - r := NewReconciler(client, handlers...) - - c, err := controller.New(ControllerName, mgr, controller.Options{ - Reconciler: r, - RecoverPanic: ptr.To(true), - LogConstructor: logger.NewConstructor(log), - CacheSyncTimeout: 10 * time.Minute, - }) - if err != nil { - return nil, err - } - - if err := c.Watch( - source.Kind(mgr.GetCache(), - &resourcev1beta1.ResourceSlice{}, - &handler.TypedEnqueueRequestForObject[*resourcev1beta1.ResourceSlice]{}, - ), - ); err != nil { - return nil, fmt.Errorf("watch ResourceSlice: %w", err) - } - - log.Info("Initialized ResourceSlice controller") - return c, nil -} diff --git a/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_reconciler.go b/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_reconciler.go deleted file mode 100644 index 38f93226dc..0000000000 --- a/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_reconciler.go +++ /dev/null @@ -1,70 +0,0 @@ -/* -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 resourceslice - -import ( - "context" - "fmt" - - resourcev1beta1 "k8s.io/api/resource/v1beta1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/reconcile" -) - -const ( - draDriverName = "virtualization-dra" -) - -type Handler interface { - Handle(ctx context.Context, slice *resourcev1beta1.ResourceSlice) error - Name() string -} - -type Reconciler struct { - client client.Client - handlers []Handler -} - -func NewReconciler(client client.Client, handlers ...Handler) *Reconciler { - return &Reconciler{ - client: client, - handlers: handlers, - } -} - -func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { - slice := &resourcev1beta1.ResourceSlice{} - if err := r.client.Get(ctx, client.ObjectKey{Name: req.Name}, slice); err != nil { - if apierrors.IsNotFound(err) { - return reconcile.Result{}, nil - } - return reconcile.Result{}, fmt.Errorf("get ResourceSlice %s: %w", req.Name, err) - } - - if slice.Spec.Driver != draDriverName { - return reconcile.Result{}, nil - } - - for _, h := range r.handlers { - if err := h.Handle(ctx, slice); err != nil { - return reconcile.Result{}, err - } - } - - return reconcile.Result{}, nil -} From c2b9fdbf029727d42cc9f954f1f00fe3286cfbd6 Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Thu, 29 Jan 2026 18:27:14 +0200 Subject: [PATCH 52/53] return sync Signed-off-by: Daniil Antoshin --- .../nodeusbdevice/internal/discovery.go | 63 +----- .../controller/nodeusbdevice/internal/sync.go | 190 ++++++++++++++++++ .../nodeusbdevice/nodeusbdevice_controller.go | 3 +- 3 files changed, 193 insertions(+), 63 deletions(-) create mode 100644 images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/sync.go diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go index 1d0d64a451..542fc5b801 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go @@ -28,7 +28,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/deckhouse/pkg/log" - "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/hash" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" @@ -57,9 +56,7 @@ func (h *DiscoveryHandler) Name() string { } func (h *DiscoveryHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) (reconcile.Result, error) { - nodeUSBDevice := s.NodeUSBDevice() - - // Get ResourceSlices once for both discovery and update + // Get ResourceSlices resourceSlices, err := s.ResourceSlices(ctx) if err != nil { return reconcile.Result{}, fmt.Errorf("failed to get resource slices: %w", err) @@ -73,32 +70,6 @@ func (h *DiscoveryHandler) Handle(ctx context.Context, s state.NodeUSBDeviceStat log.Error("failed to discover and create NodeUSBDevice", log.Err(err)) } - if nodeUSBDevice.IsEmpty() { - // Resource doesn't exist - nothing to update - return reconcile.Result{}, nil - } - - current := nodeUSBDevice.Current() - changed := nodeUSBDevice.Changed() - - // Update attributes from ResourceSlice if needed - deviceInfo, found := h.findDeviceInSlices(resourceSlices, current.Status.Attributes.Hash, current.Status.NodeName) - if !found { - // Device not found in slices - mark as NotFound - cb := conditions.NewConditionBuilder(nodeusbdevicecondition.ReadyType). - Generation(current.GetGeneration()). - Status(metav1.ConditionFalse). - Reason(nodeusbdevicecondition.NotFound). - Message("Device not found in ResourceSlice") - conditions.SetCondition(cb, &changed.Status.Conditions) - return reconcile.Result{}, nil - } - - // Update attributes if they changed - if !h.attributesEqual(current.Status.Attributes, deviceInfo) { - changed.Status.Attributes = deviceInfo - } - return reconcile.Result{}, nil } @@ -262,29 +233,6 @@ func (h *DiscoveryHandler) createNodeUSBDevice(ctx context.Context, attributes v return nil } -func (h *DiscoveryHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice, searchedHash, nodeName string) (v1alpha2.NodeUSBDeviceAttributes, bool) { - for _, slice := range slices { - if slice.Spec.Pool.Name != nodeName { - continue - } - - for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "usb-") { - continue - } - - attributes := h.convertDeviceToAttributes(device, nodeName) - deviceHash := hash.CalculateHash(attributes) - - if deviceHash == searchedHash { - return attributes, true - } - } - } - - return v1alpha2.NodeUSBDeviceAttributes{}, false -} - func (h *DiscoveryHandler) convertDeviceToAttributes(device resourcev1beta1.Device, nodeName string) v1alpha2.NodeUSBDeviceAttributes { attrs := v1alpha2.NodeUSBDeviceAttributes{ NodeName: nodeName, @@ -358,12 +306,3 @@ func (h *DiscoveryHandler) generateName(hash, nodeName string) string { nodeNameSanitized := strings.ToLower(strings.ReplaceAll(nodeName, ".", "-")) return fmt.Sprintf("nusb-%s-%s", hash[:8], nodeNameSanitized) } - -func (h *DiscoveryHandler) attributesEqual(a, b v1alpha2.NodeUSBDeviceAttributes) bool { - return a.Hash == b.Hash && - a.NodeName == b.NodeName && - a.VendorID == b.VendorID && - a.ProductID == b.ProductID && - a.Bus == b.Bus && - a.DeviceNumber == b.DeviceNumber -} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/sync.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/sync.go new file mode 100644 index 0000000000..a2c1a37b39 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/sync.go @@ -0,0 +1,190 @@ +/* +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 internal + +import ( + "context" + "fmt" + "strings" + + resourcev1beta1 "k8s.io/api/resource/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/hash" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +const ( + nameSyncHandler = "SyncHandler" +) + +func NewSyncHandler(recorder eventrecord.EventRecorderLogger) *SyncHandler { + return &SyncHandler{ + recorder: recorder, + } +} + +type SyncHandler struct { + recorder eventrecord.EventRecorderLogger +} + +func (h *SyncHandler) Name() string { + return nameSyncHandler +} + +// Handle synchronizes NodeUSBDevice attributes from ResourceSlice. +// This handler updates dynamic attributes that may change without changing the device hash. +func (h *SyncHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) (reconcile.Result, error) { + nodeUSBDevice := s.NodeUSBDevice() + + if nodeUSBDevice.IsEmpty() { + return reconcile.Result{}, nil + } + + current := nodeUSBDevice.Current() + changed := nodeUSBDevice.Changed() + + // Get ResourceSlices + resourceSlices, err := s.ResourceSlices(ctx) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to get resource slices: %w", err) + } + + // Find device in ResourceSlices and get updated attributes + updatedAttrs, found := h.findDeviceInSlices(resourceSlices, current.Status.Attributes.Hash, current.Status.NodeName) + if !found { + // Device not found - ReadyHandler will handle this case + return reconcile.Result{}, nil + } + + // Check if any attributes changed and update + if h.attributesChanged(current.Status.Attributes, updatedAttrs) { + changed.Status.Attributes = updatedAttrs + } + + return reconcile.Result{}, nil +} + +func (h *SyncHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice, searchedHash, nodeName string) (v1alpha2.NodeUSBDeviceAttributes, bool) { + for _, slice := range slices { + if slice.Spec.Pool.Name != nodeName { + continue + } + + for _, device := range slice.Spec.Devices { + if !strings.HasPrefix(device.Name, "usb-") { + continue + } + + attrs := h.convertDeviceToAttributes(device, nodeName) + deviceHash := hash.CalculateHash(attrs) + + if deviceHash == searchedHash { + // Set hash in attributes + attrs.Hash = searchedHash + return attrs, true + } + } + } + + return v1alpha2.NodeUSBDeviceAttributes{}, false +} + +func (h *SyncHandler) convertDeviceToAttributes(device resourcev1beta1.Device, nodeName string) v1alpha2.NodeUSBDeviceAttributes { + attrs := v1alpha2.NodeUSBDeviceAttributes{ + NodeName: nodeName, + Name: device.Name, + } + + if device.Basic == nil { + return attrs + } + + for key, attr := range device.Basic.Attributes { + switch string(key) { + case "name": + if attr.StringValue != nil { + attrs.Name = *attr.StringValue + } + case "manufacturer": + if attr.StringValue != nil { + attrs.Manufacturer = *attr.StringValue + } + case "product": + if attr.StringValue != nil { + attrs.Product = *attr.StringValue + } + case "vendorID": + if attr.StringValue != nil { + attrs.VendorID = *attr.StringValue + } + case "productID": + if attr.StringValue != nil { + attrs.ProductID = *attr.StringValue + } + case "bcd": + if attr.StringValue != nil { + attrs.BCD = *attr.StringValue + } + case "bus": + if attr.StringValue != nil { + attrs.Bus = *attr.StringValue + } + case "deviceNumber": + if attr.StringValue != nil { + attrs.DeviceNumber = *attr.StringValue + } + case "serial": + if attr.StringValue != nil { + attrs.Serial = *attr.StringValue + } + case "devicePath": + if attr.StringValue != nil { + attrs.DevicePath = *attr.StringValue + } + case "major": + if attr.IntValue != nil { + attrs.Major = int(*attr.IntValue) + } + case "minor": + if attr.IntValue != nil { + attrs.Minor = int(*attr.IntValue) + } + } + } + + return attrs +} + +// attributesChanged compares attributes to check if they need updating. +// This compares all attributes, not just the ones used for hash calculation. +func (h *SyncHandler) attributesChanged(current, updated v1alpha2.NodeUSBDeviceAttributes) bool { + return current.Name != updated.Name || + current.Manufacturer != updated.Manufacturer || + current.Product != updated.Product || + current.BCD != updated.BCD || + current.Major != updated.Major || + current.Minor != updated.Minor || + current.VendorID != updated.VendorID || + current.ProductID != updated.ProductID || + current.Bus != updated.Bus || + current.DeviceNumber != updated.DeviceNumber || + current.Serial != updated.Serial || + current.DevicePath != updated.DevicePath +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go index 8bb43916d3..c1434c7b2d 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go @@ -46,9 +46,10 @@ func NewController( handlers := []Handler{ internal.NewDeletionHandler(client, recorder), + internal.NewDiscoveryHandler(client, recorder), + internal.NewSyncHandler(recorder), internal.NewReadyHandler(recorder), internal.NewAssignedHandler(client, recorder), - internal.NewDiscoveryHandler(client, recorder), } r := NewReconciler(client, handlers...) From a34bcdba69b8f291c79f210a4b9a4be1152344be Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Thu, 29 Jan 2026 18:33:48 +0200 Subject: [PATCH 53/53] refactor Signed-off-by: Daniil Antoshin --- .../nodeusbdevice/internal/device.go | 135 ++++++++++++++++++ .../nodeusbdevice/internal/discovery.go | 77 +--------- .../nodeusbdevice/internal/ready.go | 27 +--- .../controller/nodeusbdevice/internal/sync.go | 96 +------------ .../usbdevice/internal/sync_ready.go | 10 +- .../vm/internal/usb_device_handler.go | 11 +- 6 files changed, 147 insertions(+), 209 deletions(-) create mode 100644 images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/device.go diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/device.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/device.go new file mode 100644 index 0000000000..b3efdd8d0d --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/device.go @@ -0,0 +1,135 @@ +/* +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 internal + +import ( + "strings" + + resourcev1beta1 "k8s.io/api/resource/v1beta1" + + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/hash" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +const ( + // USBDeviceNamePrefix is the prefix for USB device names in ResourceSlice. + USBDeviceNamePrefix = "usb-" +) + +// IsUSBDevice checks if the device name has USB device prefix. +func IsUSBDevice(device resourcev1beta1.Device) bool { + return strings.HasPrefix(device.Name, USBDeviceNamePrefix) +} + +// ConvertDeviceToAttributes converts ResourceSlice device to NodeUSBDeviceAttributes. +func ConvertDeviceToAttributes(device resourcev1beta1.Device, nodeName string) v1alpha2.NodeUSBDeviceAttributes { + attrs := v1alpha2.NodeUSBDeviceAttributes{ + NodeName: nodeName, + Name: device.Name, + } + + if device.Basic == nil { + return attrs + } + + for key, attr := range device.Basic.Attributes { + switch string(key) { + case "name": + if attr.StringValue != nil { + attrs.Name = *attr.StringValue + } + case "manufacturer": + if attr.StringValue != nil { + attrs.Manufacturer = *attr.StringValue + } + case "product": + if attr.StringValue != nil { + attrs.Product = *attr.StringValue + } + case "vendorID": + if attr.StringValue != nil { + attrs.VendorID = *attr.StringValue + } + case "productID": + if attr.StringValue != nil { + attrs.ProductID = *attr.StringValue + } + case "bcd": + if attr.StringValue != nil { + attrs.BCD = *attr.StringValue + } + case "bus": + if attr.StringValue != nil { + attrs.Bus = *attr.StringValue + } + case "deviceNumber": + if attr.StringValue != nil { + attrs.DeviceNumber = *attr.StringValue + } + case "serial": + if attr.StringValue != nil { + attrs.Serial = *attr.StringValue + } + case "devicePath": + if attr.StringValue != nil { + attrs.DevicePath = *attr.StringValue + } + case "major": + if attr.IntValue != nil { + attrs.Major = int(*attr.IntValue) + } + case "minor": + if attr.IntValue != nil { + attrs.Minor = int(*attr.IntValue) + } + } + } + + return attrs +} + +// FindDeviceInSlices searches for a device with the given hash in ResourceSlices. +// Returns the device attributes and true if found, empty attributes and false otherwise. +func FindDeviceInSlices(slices []resourcev1beta1.ResourceSlice, searchedHash, nodeName string) (v1alpha2.NodeUSBDeviceAttributes, bool) { + for _, slice := range slices { + if slice.Spec.Pool.Name != nodeName { + continue + } + + for _, device := range slice.Spec.Devices { + if !IsUSBDevice(device) { + continue + } + + attrs := ConvertDeviceToAttributes(device, nodeName) + deviceHash := hash.CalculateHash(attrs) + + if deviceHash == searchedHash { + attrs.Hash = searchedHash + return attrs, true + } + } + } + + return v1alpha2.NodeUSBDeviceAttributes{}, false +} + +// DeviceExistsInSlices checks if a device with the given hash exists in ResourceSlices. +func DeviceExistsInSlices(slices []resourcev1beta1.ResourceSlice, searchedHash, nodeName string) bool { + _, found := FindDeviceInSlices(slices, searchedHash, nodeName) + return found +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go index 542fc5b801..ca1141a82c 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/discovery.go @@ -83,7 +83,7 @@ func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context, s state.NodeUS deviceHashesInSlices := make(map[string]bool) for _, slice := range resourceSlices { for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "usb-") { + if !IsUSBDevice(device) { continue } hash := hash.CalculateHashFromDevice(device, slice.Spec.Pool.Name) @@ -114,11 +114,11 @@ func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context, s state.NodeUS // Only create devices that are in slices but not in existing for _, slice := range resourceSlices { for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "usb-") { + if !IsUSBDevice(device) { continue } - attributes := h.convertDeviceToAttributes(device, slice.Spec.Pool.Name) + attributes := ConvertDeviceToAttributes(device, slice.Spec.Pool.Name) hash := hash.CalculateHash(attributes) if !existingHashes[hash] { @@ -149,11 +149,11 @@ func (h *DiscoveryHandler) discoverAndCreate(ctx context.Context, s state.NodeUS // Note: resourceSlices are already filtered by draDriverName in state.ResourceSlices for _, slice := range resourceSlices { for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "usb-") { + if !IsUSBDevice(device) { continue } - attributes := h.convertDeviceToAttributes(device, slice.Spec.Pool.Name) + attributes := ConvertDeviceToAttributes(device, slice.Spec.Pool.Name) hash := hash.CalculateHash(attributes) if existingHashes[hash] { @@ -233,73 +233,6 @@ func (h *DiscoveryHandler) createNodeUSBDevice(ctx context.Context, attributes v return nil } -func (h *DiscoveryHandler) convertDeviceToAttributes(device resourcev1beta1.Device, nodeName string) v1alpha2.NodeUSBDeviceAttributes { - attrs := v1alpha2.NodeUSBDeviceAttributes{ - NodeName: nodeName, - Name: device.Name, - } - - if device.Basic == nil { - return attrs - } - - for key, attr := range device.Basic.Attributes { - switch string(key) { - case "name": - if attr.StringValue != nil { - attrs.Name = *attr.StringValue - } - case "manufacturer": - if attr.StringValue != nil { - attrs.Manufacturer = *attr.StringValue - } - case "product": - if attr.StringValue != nil { - attrs.Product = *attr.StringValue - } - case "vendorID": - if attr.StringValue != nil { - attrs.VendorID = *attr.StringValue - } - case "productID": - if attr.StringValue != nil { - attrs.ProductID = *attr.StringValue - } - case "bcd": - if attr.StringValue != nil { - attrs.BCD = *attr.StringValue - } - case "bus": - if attr.StringValue != nil { - attrs.Bus = *attr.StringValue - } - case "deviceNumber": - if attr.StringValue != nil { - attrs.DeviceNumber = *attr.StringValue - } - case "serial": - if attr.StringValue != nil { - attrs.Serial = *attr.StringValue - } - case "devicePath": - if attr.StringValue != nil { - attrs.DevicePath = *attr.StringValue - } - case "major": - if attr.IntValue != nil { - attrs.Major = int(*attr.IntValue) - } - case "minor": - if attr.IntValue != nil { - attrs.Minor = int(*attr.IntValue) - } - } - } - - attrs.Hash = hash.CalculateHash(attrs) - return attrs -} - func (h *DiscoveryHandler) generateName(hash, nodeName string) string { // Generate name based on hash and node name // Format: nusb-- diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go index 890bae401d..69d3182b92 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go @@ -19,14 +19,11 @@ package internal import ( "context" "fmt" - "strings" - resourcev1beta1 "k8s.io/api/resource/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" - "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/hash" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" @@ -62,7 +59,7 @@ func (h *ReadyHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) ( return reconcile.Result{}, fmt.Errorf("failed to get resource slices: %w", err) } - deviceFound := h.findDeviceInSlices(resourceSlices, current.Status.Attributes.Hash, current.Status.NodeName) + deviceFound := DeviceExistsInSlices(resourceSlices, current.Status.Attributes.Hash, current.Status.NodeName) var reason nodeusbdevicecondition.ReadyReason var message string @@ -92,28 +89,6 @@ func (h *ReadyHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) ( return reconcile.Result{}, nil } -func (h *ReadyHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice, searchedHash, nodeName string) bool { - for _, slice := range slices { - if slice.Spec.Pool.Name != nodeName { - continue - } - - for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "usb-") { - continue - } - - // Calculate hash for this device and compare - deviceHash := hash.CalculateHashFromDevice(device, nodeName) - if deviceHash == searchedHash { - return true - } - } - } - - return false -} - func (h *ReadyHandler) Name() string { return nameReadyHandler } diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/sync.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/sync.go index a2c1a37b39..89e5306d97 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/sync.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/sync.go @@ -19,12 +19,9 @@ package internal import ( "context" "fmt" - "strings" - resourcev1beta1 "k8s.io/api/resource/v1beta1" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/hash" "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization/api/core/v1alpha2" @@ -67,7 +64,7 @@ func (h *SyncHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) (r } // Find device in ResourceSlices and get updated attributes - updatedAttrs, found := h.findDeviceInSlices(resourceSlices, current.Status.Attributes.Hash, current.Status.NodeName) + updatedAttrs, found := FindDeviceInSlices(resourceSlices, current.Status.Attributes.Hash, current.Status.NodeName) if !found { // Device not found - ReadyHandler will handle this case return reconcile.Result{}, nil @@ -81,97 +78,6 @@ func (h *SyncHandler) Handle(ctx context.Context, s state.NodeUSBDeviceState) (r return reconcile.Result{}, nil } -func (h *SyncHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice, searchedHash, nodeName string) (v1alpha2.NodeUSBDeviceAttributes, bool) { - for _, slice := range slices { - if slice.Spec.Pool.Name != nodeName { - continue - } - - for _, device := range slice.Spec.Devices { - if !strings.HasPrefix(device.Name, "usb-") { - continue - } - - attrs := h.convertDeviceToAttributes(device, nodeName) - deviceHash := hash.CalculateHash(attrs) - - if deviceHash == searchedHash { - // Set hash in attributes - attrs.Hash = searchedHash - return attrs, true - } - } - } - - return v1alpha2.NodeUSBDeviceAttributes{}, false -} - -func (h *SyncHandler) convertDeviceToAttributes(device resourcev1beta1.Device, nodeName string) v1alpha2.NodeUSBDeviceAttributes { - attrs := v1alpha2.NodeUSBDeviceAttributes{ - NodeName: nodeName, - Name: device.Name, - } - - if device.Basic == nil { - return attrs - } - - for key, attr := range device.Basic.Attributes { - switch string(key) { - case "name": - if attr.StringValue != nil { - attrs.Name = *attr.StringValue - } - case "manufacturer": - if attr.StringValue != nil { - attrs.Manufacturer = *attr.StringValue - } - case "product": - if attr.StringValue != nil { - attrs.Product = *attr.StringValue - } - case "vendorID": - if attr.StringValue != nil { - attrs.VendorID = *attr.StringValue - } - case "productID": - if attr.StringValue != nil { - attrs.ProductID = *attr.StringValue - } - case "bcd": - if attr.StringValue != nil { - attrs.BCD = *attr.StringValue - } - case "bus": - if attr.StringValue != nil { - attrs.Bus = *attr.StringValue - } - case "deviceNumber": - if attr.StringValue != nil { - attrs.DeviceNumber = *attr.StringValue - } - case "serial": - if attr.StringValue != nil { - attrs.Serial = *attr.StringValue - } - case "devicePath": - if attr.StringValue != nil { - attrs.DevicePath = *attr.StringValue - } - case "major": - if attr.IntValue != nil { - attrs.Major = int(*attr.IntValue) - } - case "minor": - if attr.IntValue != nil { - attrs.Minor = int(*attr.IntValue) - } - } - } - - return attrs -} - // attributesChanged compares attributes to check if they need updating. // This compares all attributes, not just the ones used for hash calculation. func (h *SyncHandler) attributesChanged(current, updated v1alpha2.NodeUSBDeviceAttributes) bool { diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_ready.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_ready.go index 97af12efe8..a923f15b22 100644 --- a/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_ready.go +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_ready.go @@ -20,6 +20,7 @@ import ( "context" "reflect" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -82,14 +83,7 @@ func (h *SyncReadyHandler) Handle(ctx context.Context, s state.USBDeviceState) ( } // Sync Ready condition from NodeUSBDevice - var readyCondition *metav1.Condition - for i := range nodeUSBDevice.Status.Conditions { - if nodeUSBDevice.Status.Conditions[i].Type == string(nodeusbdevicecondition.ReadyType) { - readyCondition = &nodeUSBDevice.Status.Conditions[i] - break - } - } - + readyCondition := meta.FindStatusCondition(nodeUSBDevice.Status.Conditions, string(nodeusbdevicecondition.ReadyType)) if readyCondition == nil { // No Ready condition in NodeUSBDevice - mark as NotReady cb := conditions.NewConditionBuilder(usbdevicecondition.ReadyType). diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go index 28847a09fc..c7fee48453 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go @@ -22,6 +22,7 @@ import ( resourcev1beta1 "k8s.io/api/resource/v1beta1" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" @@ -316,14 +317,8 @@ func (h *USBDeviceHandler) isUSBDeviceReady(usbDevice *v1alpha2.USBDevice) bool } // Check Ready condition - for _, condition := range usbDevice.Status.Conditions { - if condition.Type == string(usbdevicecondition.ReadyType) { - return condition.Status == metav1.ConditionTrue - } - } - - // If no Ready condition found, device is not ready - return false + readyCondition := meta.FindStatusCondition(usbDevice.Status.Conditions, string(usbdevicecondition.ReadyType)) + return readyCondition != nil && readyCondition.Status == metav1.ConditionTrue } func (h *USBDeviceHandler) attachUSBDevice(