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 +}