Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/core/v1alpha2/node_device_usb.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions api/core/v1alpha2/nodeusbdevicecondition/condition.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 (
Expand All @@ -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 {
Expand All @@ -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)
}
8 changes: 8 additions & 0 deletions crds/doc-ru-nodeusbdevices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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_])?$
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crds/nodeusbdevices.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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."),
)
})
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading