From e84c3294396fe0f9e7074c6e9e971caf81de9d2f Mon Sep 17 00:00:00 2001 From: Daniil Antoshin Date: Fri, 23 Jan 2026 18:51:10 +0200 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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.