diff --git a/api/core/v1alpha2/node_device_usb.go b/api/core/v1alpha2/node_device_usb.go index 4cb037ea4c..ebab981c8a 100644 --- a/api/core/v1alpha2/node_device_usb.go +++ b/api/core/v1alpha2/node_device_usb.go @@ -36,6 +36,7 @@ const ( // +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="Attached",type=string,JSONPath=`.status.conditions[?(@.type=="Attached")].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 diff --git a/api/core/v1alpha2/nodeusbdevicecondition/condition.go b/api/core/v1alpha2/nodeusbdevicecondition/condition.go index 35813d4520..b851e4d9e4 100644 --- a/api/core/v1alpha2/nodeusbdevicecondition/condition.go +++ b/api/core/v1alpha2/nodeusbdevicecondition/condition.go @@ -24,6 +24,8 @@ const ( AssignedType Type = "Assigned" // 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 { @@ -35,6 +37,8 @@ type ( AssignedReason string // 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 ( @@ -51,6 +55,15 @@ const ( 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" + // AttachedAvailable signifies that device is available for attachment to a virtual machine. + AttachedAvailable AttachedReason = "Available" + // DetachedForMigration signifies that device was detached for migration (e.g. live migration). + DetachedForMigration AttachedReason = "DetachedForMigration" + // NoFreeUSBIPPort signifies that device cannot be attached because there are no free USBIP ports on the target node. + NoFreeUSBIPPort AttachedReason = "NoFreeUSBIPPort" ) func (r AssignedReason) String() string { @@ -60,3 +73,7 @@ func (r AssignedReason) String() string { func (r ReadyReason) String() string { return string(r) } + +func (r AttachedReason) String() string { + return string(r) +} diff --git a/crds/doc-ru-nodeusbdevices.yaml b/crds/doc-ru-nodeusbdevices.yaml index 12c83ec23a..53874bbecd 100644 --- a/crds/doc-ru-nodeusbdevices.yaml +++ b/crds/doc-ru-nodeusbdevices.yaml @@ -126,6 +126,12 @@ spec: * `Assigned` — неймспейс назначен для устройства и создан соответствующий ресурс USBDevice в этом неймспейсе; * `Available` — для устройства не назначен неймспейс; * `InProgress` — подключение устройства к неймспейсу выполняется (создание ресурса USBDevice). + + Для типа условия Attached возможные значения: + * `AttachedToVirtualMachine` — устройство подключено к виртуальной машине; + * `Available` — устройство не подключено к виртуальной машине; + * `DetachedForMigration` — устройство было отключено для миграции; + * `NoFreeUSBIPPort` — устройство не может быть подключено, так как на целевом узле нет свободных USBIP-портов. maxLength: 1024 minLength: 1 pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ @@ -147,6 +153,8 @@ spec: может быть реализован Garbage Collector для автоматической очистки. * `Assigned` — указывает, назначен ли неймспейс для устройства. Когда reason — "Assigned", status — "True". Когда reason — "Available" или "InProgress", status — "False". + * `Attached` — указывает, подключено ли устройство к виртуальной машине. Когда reason — "AttachedToVirtualMachine", + status — "True". Когда reason — "Available", "DetachedForMigration" или "NoFreeUSBIPPort", 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 diff --git a/crds/nodeusbdevices.yaml b/crds/nodeusbdevices.yaml index 403212cc12..64abd5d2aa 100644 --- a/crds/nodeusbdevices.yaml +++ b/crds/nodeusbdevices.yaml @@ -31,6 +31,9 @@ spec: - jsonPath: .status.conditions[?(@.type=="Assigned")].status name: Assigned type: string + - jsonPath: .status.conditions[?(@.type=="Attached")].status + name: Attached + type: string - jsonPath: .spec.assignedNamespace name: Namespace type: string diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/attached.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/attached.go new file mode 100644 index 0000000000..38cf92a61a --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/attached.go @@ -0,0 +1,108 @@ +/* +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 handler + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "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/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" + "github.com/deckhouse/virtualization/api/core/v1alpha2/usbdevicecondition" +) + +const nameAttachedHandler = "AttachedHandler" + +func NewAttachedHandler(client client.Client) *AttachedHandler { + return &AttachedHandler{client: client} +} + +type AttachedHandler struct { + client client.Client +} + +func (h *AttachedHandler) Name() string { + return nameAttachedHandler +} + +func (h *AttachedHandler) 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() + + if !current.GetDeletionTimestamp().IsZero() { + return reconcile.Result{}, nil + } + + assignedNamespace := current.Spec.AssignedNamespace + if assignedNamespace == "" { + setAttachedCondition(current, &changed.Status.Conditions, metav1.ConditionFalse, nodeusbdevicecondition.AttachedAvailable, "Device is not assigned to any namespace and is not attached to a virtual machine.") + return reconcile.Result{}, nil + } + + usbDevice := &v1alpha2.USBDevice{} + err := h.client.Get(ctx, types.NamespacedName{Namespace: assignedNamespace, Name: current.Name}, usbDevice) + if err != nil { + if errors.IsNotFound(err) { + setAttachedCondition(current, &changed.Status.Conditions, metav1.ConditionFalse, nodeusbdevicecondition.AttachedAvailable, fmt.Sprintf("Corresponding USBDevice %s/%s not found.", assignedNamespace, current.Name)) + return reconcile.Result{}, nil + } + + return reconcile.Result{}, fmt.Errorf("failed to get USBDevice %s/%s: %w", assignedNamespace, current.Name, err) + } + + attachedCondition := meta.FindStatusCondition(usbDevice.Status.Conditions, string(usbdevicecondition.AttachedType)) + if attachedCondition == nil { + setAttachedCondition(current, &changed.Status.Conditions, metav1.ConditionFalse, nodeusbdevicecondition.AttachedAvailable, fmt.Sprintf("Attached condition not found in USBDevice %s/%s.", usbDevice.Namespace, usbDevice.Name)) + return reconcile.Result{}, nil + } + + setAttachedCondition( + current, + &changed.Status.Conditions, + attachedCondition.Status, + mapAttachedReason(attachedCondition.Reason), + attachedCondition.Message, + ) + + return reconcile.Result{}, nil +} + +func mapAttachedReason(reason string) nodeusbdevicecondition.AttachedReason { + switch reason { + case string(usbdevicecondition.AttachedToVirtualMachine): + return nodeusbdevicecondition.AttachedToVirtualMachine + case string(usbdevicecondition.DetachedForMigration): + return nodeusbdevicecondition.DetachedForMigration + case string(usbdevicecondition.NoFreeUSBIPPort): + return nodeusbdevicecondition.NoFreeUSBIPPort + default: + return nodeusbdevicecondition.AttachedAvailable + } +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/attached_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/attached_test.go new file mode 100644 index 0000000000..5d1002543d --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/attached_test.go @@ -0,0 +1,111 @@ +/* +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 handler + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/meta" + 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" + + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "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("AttachedHandler", func() { + DescribeTable("Handle", + func(assignedNamespace string, usbDevice *v1alpha2.USBDevice, expectedStatus metav1.ConditionStatus, expectedReason, expectedMessage string) { + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + node := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1", Generation: 1}, + Spec: v1alpha2.NodeUSBDeviceSpec{AssignedNamespace: assignedNamespace}, + } + + objects := []client.Object{node} + if usbDevice != nil { + objects = append(objects, usbDevice) + } + + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + Build() + + res := reconciler.NewResource( + types.NamespacedName{Name: node.Name}, + cl, + func() *v1alpha2.NodeUSBDevice { return &v1alpha2.NodeUSBDevice{} }, + func(obj *v1alpha2.NodeUSBDevice) v1alpha2.NodeUSBDeviceStatus { return obj.Status }, + ) + Expect(res.Fetch(context.Background())).To(Succeed()) + + h := NewAttachedHandler(cl) + st := state.New(cl, res) + _, err := h.Handle(context.Background(), st) + Expect(err).NotTo(HaveOccurred()) + + attached := meta.FindStatusCondition(res.Changed().Status.Conditions, string(nodeusbdevicecondition.AttachedType)) + Expect(attached).NotTo(BeNil()) + Expect(attached.Status).To(Equal(expectedStatus)) + Expect(attached.Reason).To(Equal(expectedReason)) + Expect(attached.Message).To(Equal(expectedMessage)) + }, + Entry("unassigned device is not attached", "", nil, metav1.ConditionFalse, string(nodeusbdevicecondition.AttachedAvailable), "Device is not assigned to any namespace and is not attached to a virtual machine."), + Entry("missing USBDevice returns available", "test-ns", nil, metav1.ConditionFalse, string(nodeusbdevicecondition.AttachedAvailable), "Corresponding USBDevice test-ns/usb-device-1 not found."), + Entry("mirrors attached USBDevice condition", "test-ns", &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1", Namespace: "test-ns"}, + Status: v1alpha2.USBDeviceStatus{Conditions: []metav1.Condition{{ + Type: string(usbdevicecondition.AttachedType), + Status: metav1.ConditionTrue, + Reason: string(usbdevicecondition.AttachedToVirtualMachine), + Message: "Device is attached to VirtualMachine test-ns/vm-1.", + }}}, + }, metav1.ConditionTrue, string(nodeusbdevicecondition.AttachedToVirtualMachine), "Device is attached to VirtualMachine test-ns/vm-1."), + Entry("mirrors detached for migration", "test-ns", &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1", Namespace: "test-ns"}, + Status: v1alpha2.USBDeviceStatus{Conditions: []metav1.Condition{{ + Type: string(usbdevicecondition.AttachedType), + Status: metav1.ConditionFalse, + Reason: string(usbdevicecondition.DetachedForMigration), + Message: "Device was detached for migration.", + }}}, + }, metav1.ConditionFalse, string(nodeusbdevicecondition.DetachedForMigration), "Device was detached for migration."), + Entry("mirrors no free port condition", "test-ns", &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1", Namespace: "test-ns"}, + Status: v1alpha2.USBDeviceStatus{Conditions: []metav1.Condition{{ + Type: string(usbdevicecondition.AttachedType), + Status: metav1.ConditionFalse, + Reason: string(usbdevicecondition.NoFreeUSBIPPort), + Message: "No free USBIP ports are available.", + }}}, + }, metav1.ConditionFalse, string(nodeusbdevicecondition.NoFreeUSBIPPort), "No free USBIP ports are available."), + Entry("missing attached condition falls back to available", "test-ns", &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1", Namespace: "test-ns"}, + }, metav1.ConditionFalse, string(nodeusbdevicecondition.AttachedAvailable), "Attached condition not found in USBDevice test-ns/usb-device-1."), + ) +}) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/conditions.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/conditions.go index b164379c6d..4dc573c548 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/conditions.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/conditions.go @@ -56,6 +56,22 @@ func setAssignedCondition( conditions.SetCondition(cb, target) } +func setAttachedCondition( + nodeUSBDevice *v1alpha2.NodeUSBDevice, + target *[]metav1.Condition, + status metav1.ConditionStatus, + reason nodeusbdevicecondition.AttachedReason, + message string, +) { + cb := conditions.NewConditionBuilder(nodeusbdevicecondition.AttachedType). + Generation(nodeUSBDevice.GetGeneration()). + Status(status). + Reason(reason). + Message(message) + + conditions.SetCondition(cb, target) +} + func isDeviceAbsentOnHost(conditions []metav1.Condition) bool { readyCondition := meta.FindStatusCondition(conditions, string(nodeusbdevicecondition.ReadyType)) if readyCondition == nil { diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/usbdevice_watcher.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/usbdevice_watcher.go new file mode 100644 index 0000000000..26d5d85dfe --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/usbdevice_watcher.go @@ -0,0 +1,91 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package watcher + +import ( + "context" + + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func NewUSBDeviceWatcher() *USBDeviceWatcher { + return &USBDeviceWatcher{} +} + +type USBDeviceWatcher struct{} + +func (w *USBDeviceWatcher) Watch(mgr manager.Manager, ctr controller.Controller) error { + return ctr.Watch( + source.Kind(mgr.GetCache(), + &v1alpha2.USBDevice{}, + handler.TypedEnqueueRequestsFromMapFunc(func(_ context.Context, usbDevice *v1alpha2.USBDevice) []reconcile.Request { + return requestsByUSBDevice(usbDevice) + }), + predicate.TypedFuncs[*v1alpha2.USBDevice]{ + CreateFunc: func(e event.TypedCreateEvent[*v1alpha2.USBDevice]) bool { + return e.Object != nil + }, + DeleteFunc: func(e event.TypedDeleteEvent[*v1alpha2.USBDevice]) bool { + return e.Object != nil + }, + UpdateFunc: func(e event.TypedUpdateEvent[*v1alpha2.USBDevice]) bool { + return shouldProcessUSBDeviceUpdate(e.ObjectOld, e.ObjectNew) + }, + }, + ), + ) +} + +func requestsByUSBDevice(usbDevice *v1alpha2.USBDevice) []reconcile.Request { + if usbDevice == nil { + return nil + } + + for _, ownerRef := range usbDevice.OwnerReferences { + if ownerRef.APIVersion != v1alpha2.SchemeGroupVersion.String() || ownerRef.Kind != v1alpha2.NodeUSBDeviceKind || ownerRef.Name == "" { + continue + } + + return []reconcile.Request{{NamespacedName: types.NamespacedName{Name: ownerRef.Name}}} + } + + if usbDevice.Name == "" { + return nil + } + + return []reconcile.Request{{NamespacedName: types.NamespacedName{Name: usbDevice.Name}}} +} + +func shouldProcessUSBDeviceUpdate(oldObj, newObj *v1alpha2.USBDevice) bool { + if oldObj == nil || newObj == nil { + return false + } + + return !equality.Semantic.DeepEqual(oldObj.Status.Conditions, newObj.Status.Conditions) || + !equality.Semantic.DeepEqual(oldObj.OwnerReferences, newObj.OwnerReferences) || + !equality.Semantic.DeepEqual(oldObj.GetDeletionTimestamp(), newObj.GetDeletionTimestamp()) +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/usbdevice_watcher_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/usbdevice_watcher_test.go new file mode 100644 index 0000000000..9331f15945 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/usbdevice_watcher_test.go @@ -0,0 +1,97 @@ +/* +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 watcher + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func TestRequestsByUSBDevice(t *testing.T) { + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "test-ns", + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: v1alpha2.SchemeGroupVersion.String(), + Kind: v1alpha2.NodeUSBDeviceKind, + Name: "node-usb-device-1", + }}, + }, + } + + requests := requestsByUSBDevice(usbDevice) + if len(requests) != 1 { + t.Fatalf("expected 1 request, got %d", len(requests)) + } + if requests[0].Name != "node-usb-device-1" { + t.Fatalf("unexpected request name %q", requests[0].Name) + } + if requests[0].Namespace != "" { + t.Fatalf("expected cluster-scoped request, got namespace %q", requests[0].Namespace) + } +} + +func TestRequestsByUSBDeviceFallsBackToName(t *testing.T) { + usbDevice := &v1alpha2.USBDevice{ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1", Namespace: "test-ns"}} + + requests := requestsByUSBDevice(usbDevice) + if len(requests) != 1 { + t.Fatalf("expected 1 request, got %d", len(requests)) + } + if requests[0].Name != "usb-device-1" { + t.Fatalf("unexpected fallback request name %q", requests[0].Name) + } +} + +func TestShouldProcessUSBDeviceUpdate(t *testing.T) { + oldObj := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1"}, + Status: v1alpha2.USBDeviceStatus{Conditions: []metav1.Condition{{ + Type: "Attached", + Status: metav1.ConditionFalse, + Reason: "Available", + }}}, + } + + sameObj := oldObj.DeepCopy() + if shouldProcessUSBDeviceUpdate(oldObj, sameObj) { + t.Fatal("expected unchanged object update to be ignored") + } + + changedConditions := oldObj.DeepCopy() + changedConditions.Status.Conditions[0].Status = metav1.ConditionTrue + if !shouldProcessUSBDeviceUpdate(oldObj, changedConditions) { + t.Fatal("expected conditions update to be processed") + } + + changedOwners := oldObj.DeepCopy() + changedOwners.OwnerReferences = []metav1.OwnerReference{{Name: "node-usb-device-1"}} + if !shouldProcessUSBDeviceUpdate(oldObj, changedOwners) { + t.Fatal("expected owner references update to be processed") + } + + if shouldProcessUSBDeviceUpdate(nil, changedConditions) { + t.Fatal("expected nil old object to be ignored") + } + if shouldProcessUSBDeviceUpdate(oldObj, nil) { + t.Fatal("expected nil new object to be ignored") + } +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go index a67f363dc1..31666f30c9 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go @@ -49,6 +49,7 @@ func NewController( handler.NewDeletionHandler(client), handler.NewReadyHandler(client), handler.NewAssignedHandler(client), + handler.NewAttachedHandler(client), } r := NewReconciler(client, handlers...) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go index a283879b2c..81dfe09239 100644 --- a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go @@ -69,6 +69,7 @@ func (r *Reconciler) SetupController(_ context.Context, mgr manager.Manager, ctr for _, w := range []Watcher{ watcher.NewResourceSliceWatcher(), watcher.NewNamespaceWatcher(), + watcher.NewUSBDeviceWatcher(), } { err := w.Watch(mgr, ctr) if err != nil { diff --git a/test/e2e/vm/usb.go b/test/e2e/vm/usb.go index 977abc50e5..f7b176e07d 100644 --- a/test/e2e/vm/usb.go +++ b/test/e2e/vm/usb.go @@ -23,6 +23,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" @@ -69,6 +70,15 @@ var _ = Describe("VirtualMachineUSB", func() { util.UntilSSHReady(f, t.VM, framework.MiddleTimeout) }) + By("Verifying NodeUSBDevice is not attached before VM attachment", func() { + Eventually(func(g Gomega) { + nodeUSBDevice, err := t.Framework.VirtClient().NodeUSBDevices().Get(t.ctx, t.NodeUSBDevice.Name, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(nodeUSBAttachedCondition(nodeUSBDevice)).NotTo(BeNil()) + g.Expect(nodeUSBAttachedCondition(nodeUSBDevice).Status).To(Equal(metav1.ConditionFalse)) + }).WithTimeout(framework.MaxTimeout).WithPolling(time.Second).Should(Succeed()) + }) + By("Waiting for USB device to be attached and ready", func() { Eventually(func() error { vm, err := t.Framework.VirtClient().VirtualMachines(t.VM.Namespace).Get(t.ctx, t.VM.Name, metav1.GetOptions{}) @@ -85,6 +95,15 @@ var _ = Describe("VirtualMachineUSB", func() { }).WithTimeout(framework.MaxTimeout).WithPolling(time.Second).Should(Succeed()) }) + By("Verifying NodeUSBDevice is attached", func() { + Eventually(func(g Gomega) { + nodeUSBDevice, err := t.Framework.VirtClient().NodeUSBDevices().Get(t.ctx, t.NodeUSBDevice.Name, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(nodeUSBAttachedCondition(nodeUSBDevice)).NotTo(BeNil()) + g.Expect(nodeUSBAttachedCondition(nodeUSBDevice).Status).To(Equal(metav1.ConditionTrue)) + }).WithTimeout(framework.MaxTimeout).WithPolling(time.Second).Should(Succeed()) + }) + By("Mounting USB device", func() { t.mountUSB() }) @@ -119,6 +138,15 @@ var _ = Describe("VirtualMachineUSB", func() { }).WithTimeout(framework.MaxTimeout).WithPolling(time.Second).Should(Succeed()) }) + By("Verifying NodeUSBDevice is attached after migration", func() { + Eventually(func(g Gomega) { + nodeUSBDevice, err := t.Framework.VirtClient().NodeUSBDevices().Get(t.ctx, t.NodeUSBDevice.Name, metav1.GetOptions{}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(nodeUSBAttachedCondition(nodeUSBDevice)).NotTo(BeNil()) + g.Expect(nodeUSBAttachedCondition(nodeUSBDevice).Status).To(Equal(metav1.ConditionTrue)) + }).WithTimeout(framework.MaxTimeout).WithPolling(time.Second).Should(Succeed()) + }) + By("Remounting USB device after migration", func() { t.mountUSB() }) @@ -238,6 +266,14 @@ func (t *VMUSBTest) mountUSB() { Expect(err).NotTo(HaveOccurred()) } +func nodeUSBAttachedCondition(nodeUSBDevice *v1alpha2.NodeUSBDevice) *metav1.Condition { + if nodeUSBDevice == nil { + return nil + } + + return meta.FindStatusCondition(nodeUSBDevice.Status.Conditions, "Attached") +} + func (t *VMUSBTest) unassignNodeUSB() { GinkgoHelper()