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..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 @@ -29,6 +29,8 @@ import ( type VirtualizationV1alpha2Interface interface { RESTClient() rest.Interface ClusterVirtualImagesGetter + NodeUSBDevicesGetter + USBDevicesGetter VirtualDisksGetter VirtualDiskSnapshotsGetter VirtualImagesGetter @@ -54,6 +56,14 @@ func (c *VirtualizationV1alpha2Client) ClusterVirtualImages() ClusterVirtualImag return newClusterVirtualImages(c) } +func (c *VirtualizationV1alpha2Client) NodeUSBDevices(namespace string) NodeUSBDeviceInterface { + 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 816406b63d..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 @@ -32,6 +32,14 @@ func (c *FakeVirtualizationV1alpha2) ClusterVirtualImages() v1alpha2.ClusterVirt return newFakeClusterVirtualImages(c) } +func (c *FakeVirtualizationV1alpha2) NodeUSBDevices(namespace string) v1alpha2.NodeUSBDeviceInterface { + 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_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/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 944819c8d7..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 @@ -20,6 +20,10 @@ package v1alpha2 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/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/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 c3126a2c07..a9cc967102 100644 --- a/api/client/generated/informers/externalversions/core/v1alpha2/interface.go +++ b/api/client/generated/informers/externalversions/core/v1alpha2/interface.go @@ -26,6 +26,10 @@ import ( type Interface interface { // ClusterVirtualImages returns a ClusterVirtualImageInformer. ClusterVirtualImages() ClusterVirtualImageInformer + // NodeUSBDevices returns a NodeUSBDeviceInformer. + NodeUSBDevices() NodeUSBDeviceInformer + // USBDevices returns a USBDeviceInformer. + USBDevices() USBDeviceInformer // VirtualDisks returns a VirtualDiskInformer. VirtualDisks() VirtualDiskInformer // VirtualDiskSnapshots returns a VirtualDiskSnapshotInformer. @@ -72,6 +76,16 @@ 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} +} + +// 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/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/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 e2b56006b0..e8663a0736 100644 --- a/api/client/generated/informers/externalversions/generic.go +++ b/api/client/generated/informers/externalversions/generic.go @@ -56,6 +56,10 @@ 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("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 c3daaded06..834035d1f2 100644 --- a/api/client/generated/listers/core/v1alpha2/expansion_generated.go +++ b/api/client/generated/listers/core/v1alpha2/expansion_generated.go @@ -22,6 +22,22 @@ 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{} + +// 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/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/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 e2038aff5f..50c932d959 100644 --- a/api/core/v1alpha2/finalizers.go +++ b/api/core/v1alpha2/finalizers.go @@ -41,4 +41,6 @@ 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" + FinalizerUSBDeviceCleanup = "virtualization.deckhouse.io/usbdevice-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..418380f551 --- /dev/null +++ b/api/core/v1alpha2/node_device_usb.go @@ -0,0 +1,107 @@ +/* +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 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=Cluster,shortName={nusb},singular=nodeusbdevice +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Node",type=string,JSONPath=`.status.nodeName` +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +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"` + // Resource generation last processed by the controller. + ObservedGeneration int64 `json:"observedGeneration,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/api/core/v1alpha2/nodeusbdevicecondition/condition.go b/api/core/v1alpha2/nodeusbdevicecondition/condition.go new file mode 100644 index 0000000000..35813d4520 --- /dev/null +++ b/api/core/v1alpha2/nodeusbdevicecondition/condition.go @@ -0,0 +1,62 @@ +/* +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 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/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..dc76f3d32e --- /dev/null +++ b/api/core/v1alpha2/usb_device.go @@ -0,0 +1,74 @@ +/* +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 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..c00a29975a --- /dev/null +++ b/api/core/v1alpha2/usbdevicecondition/condition.go @@ -0,0 +1,60 @@ +/* +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 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/virtual_machine.go b/api/core/v1alpha2/virtual_machine.go index b7895501f5..6e24f88d06 100644 --- a/api/core/v1alpha2/virtual_machine.go +++ b/api/core/v1alpha2/virtual_machine.go @@ -114,6 +114,10 @@ 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. + // +kubebuilder:validation:MaxItems:=8 + USBDevices []USBDeviceSpecRef `json:"usbDevices,omitempty"` } // RunPolicy parameter defines the VM startup policy @@ -315,6 +319,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 +485,33 @@ 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 device is ready to use. + Ready bool `json:"ready"` + // USB address inside the virtual machine. + Address *USBAddress `json:"address,omitempty"` + // USB device is attached via hot plug connection. + Hotplugged bool `json:"hotplugged,omitempty"` + // Conditions for this USB device. + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// USBAddress represents the USB bus address inside the virtual machine. +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/vmcondition/condition.go b/api/core/v1alpha2/vmcondition/condition.go index a3f1352fcf..75361a3a31 100644 --- a/api/core/v1alpha2/vmcondition/condition.go +++ b/api/core/v1alpha2/vmcondition/condition.go @@ -46,6 +46,8 @@ const ( TypeNeedsEvict Type = "NeedsEvict" // TypeNetworkReady indicates the state of additional network interfaces inside the virtual machine pod TypeNetworkReady Type = "NetworkReady" + // TypeUSBDeviceReady indicates the state of USB devices attached to the virtual machine. + TypeUSBDeviceReady Type = "USBDeviceReady" // TypeMaintenance indicates that the VirtualMachine is in maintenance mode. // During this condition, the VM remains stopped and no changes are allowed. @@ -282,3 +284,16 @@ func (r MaintenanceReason) String() string { const ( ReasonMaintenanceRestore MaintenanceReason = "RestoreInProgress" ) + +type USBDeviceReadyReason string + +func (r USBDeviceReadyReason) String() string { + return string(r) +} + +const ( + // ReasonUSBDeviceReady indicates that all USB devices are ready. + ReasonUSBDeviceReady USBDeviceReadyReason = "USBDeviceReady" + // ReasonSomeDevicesNotReady indicates that some USB devices are not ready. + ReasonSomeDevicesNotReady USBDeviceReadyReason = "SomeDevicesNotReady" +) diff --git a/api/core/v1alpha2/zz_generated.deepcopy.go b/api/core/v1alpha2/zz_generated.deepcopy.go index e1ad5e58e5..cbca747bb5 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 @@ -908,6 +1025,150 @@ 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 + 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 *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 + 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 *USBDeviceStatusRef) DeepCopyInto(out *USBDeviceStatusRef) { + *out = *in + if in.Address != nil { + in, out := &in.Address, &out.Address + *out = new(USBAddress) + **out = **in + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// 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 @@ -3153,6 +3414,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 } @@ -3235,6 +3501,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/api/scripts/update-codegen.sh b/api/scripts/update-codegen.sh index 8214b2f44b..905b665369 100755 --- a/api/scripts/update-codegen.sh +++ b/api/scripts/update-codegen.sh @@ -40,7 +40,9 @@ function source::settings { "VirtualMachineSnapshotOperation" "VirtualDisk" "VirtualImage" - "ClusterVirtualImage") + "ClusterVirtualImage" + "NodeUSBDevice" + "USBDevice") source "${CODEGEN_PKG}/kube_codegen.sh" } 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 diff --git a/crds/nodeusbdevices.yaml b/crds/nodeusbdevices.yaml new file mode 100644 index 0000000000..197d501db8 --- /dev/null +++ b/crds/nodeusbdevices.yaml @@ -0,0 +1,198 @@ +--- +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: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .status.nodeName + name: Node + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Assigned")].status + name: Assigned + type: string + - jsonPath: .spec.assignedNamespace + name: Namespace + 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. + 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 + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} 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/crds/virtualmachines.yaml b/crds/virtualmachines.yaml index c674078a22..60b0a4b23d 100644 --- a/crds/virtualmachines.yaml +++ b/crds/virtualmachines.yaml @@ -987,6 +987,22 @@ spec: type: string description: | The name of the `VirtualMachineMACAddress` resource that is associated with the network interface. + usbDevices: + type: array + maxItems: 8 + description: | + List of USB devices to attach to the virtual machine. + Devices are referenced by name of USBDevice resource in the same namespace. + items: + type: object + required: + - name + properties: + name: + minLength: 1 + type: string + description: | + The name of USBDevice resource in the same namespace. status: type: object properties: @@ -1306,6 +1322,84 @@ spec: - size type: object type: object + usbDevices: + type: array + description: | + List of USB devices attached to the virtual machine. + items: + type: object + required: + - name + - attached + - ready + properties: + name: + type: string + description: | + The name of USBDevice resource. + attached: + type: boolean + description: | + The USB device is attached to the virtual machine. + ready: + type: boolean + description: | + USB device is ready to use. + address: + type: object + description: | + USB address inside the virtual machine. + properties: + bus: + type: integer + description: | + USB bus number (always 0 for the main USB controller). + port: + type: integer + description: | + USB port number on the selected bus. + hotplugged: + type: boolean + description: | + USB device is attached via hot plug connection. + conditions: + type: array + description: | + Conditions for this USB device. + items: + type: object + required: + - lastTransitionTime + - message + - reason + - status + - type + properties: + lastTransitionTime: + format: date-time + type: string + message: + maxLength: 32768 + type: string + observedGeneration: + format: int64 + minimum: 0 + type: integer + reason: + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + enum: + - "True" + - "False" + - Unknown + type: string + type: + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string networks: type: array description: | diff --git a/images/virtualization-artifact/cmd/virtualization-controller/main.go b/images/virtualization-artifact/cmd/virtualization-controller/main.go index bee96df3e9..9dd77f4bdf 100644 --- a/images/virtualization-artifact/cmd/virtualization-controller/main.go +++ b/images/virtualization-artifact/cmd/virtualization-controller/main.go @@ -47,6 +47,9 @@ 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/resourceslice" + "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice" "github.com/deckhouse/virtualization-controller/pkg/controller/vd" "github.com/deckhouse/virtualization-controller/pkg/controller/vdsnapshot" "github.com/deckhouse/virtualization-controller/pkg/controller/vi" @@ -342,7 +345,7 @@ func main() { } vmLogger := logger.NewControllerLogger(vm.ControllerName, logLevel, logOutput, logDebugVerbosity, logDebugControllerList) - if err = vm.SetupController(ctx, mgr, vmLogger, dvcrSettings, firmwareImage); err != nil { + if err = vm.SetupController(ctx, mgr, virtClient, vmLogger, dvcrSettings, firmwareImage); err != nil { log.Error(err.Error()) os.Exit(1) } @@ -375,6 +378,24 @@ func main() { os.Exit(1) } + resourcesliceLogger := logger.NewControllerLogger(resourceslice.ControllerName, logLevel, logOutput, logDebugVerbosity, logDebugControllerList) + if _, err = resourceslice.NewController(ctx, mgr, resourcesliceLogger); err != nil { + log.Error(err.Error()) + os.Exit(1) + } + + nodeusbdeviceLogger := logger.NewControllerLogger(nodeusbdevice.ControllerName, logLevel, logOutput, logDebugVerbosity, logDebugControllerList) + if _, err = nodeusbdevice.NewController(ctx, mgr, nodeusbdeviceLogger); err != nil { + log.Error(err.Error()) + 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/common/hash/hash.go b/images/virtualization-artifact/pkg/common/hash/hash.go new file mode 100644 index 0000000000..586cc32a6e --- /dev/null +++ b/images/virtualization-artifact/pkg/common/hash/hash.go @@ -0,0 +1,91 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package hash + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + + resourcev1beta1 "k8s.io/api/resource/v1beta1" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +// CalculateHash calculates hash for USB device attributes. +func CalculateHash(attrs v1alpha2.NodeUSBDeviceAttributes) string { + hashInput := fmt.Sprintf("%s:%s:%s:%s:%s:%s:%s", + attrs.NodeName, + attrs.VendorID, + attrs.ProductID, + attrs.Bus, + attrs.DeviceNumber, + attrs.Serial, + attrs.DevicePath, + ) + + hash := sha256.Sum256([]byte(hashInput)) + return hex.EncodeToString(hash[:])[:16] // Use first 16 characters +} + +// CalculateHashFromDevice calculates hash from ResourceSlice Device. +func CalculateHashFromDevice(device resourcev1beta1.Device, nodeName string) string { + var vendorID, productID, bus, deviceNumber, serial, devicePath string + + if device.Basic != nil { + for key, attr := range device.Basic.Attributes { + switch string(key) { + case "vendorID": + if attr.StringValue != nil { + vendorID = *attr.StringValue + } + case "productID": + if attr.StringValue != nil { + productID = *attr.StringValue + } + case "bus": + if attr.StringValue != nil { + bus = *attr.StringValue + } + case "deviceNumber": + if attr.StringValue != nil { + deviceNumber = *attr.StringValue + } + case "serial": + if attr.StringValue != nil { + serial = *attr.StringValue + } + case "devicePath": + if attr.StringValue != nil { + devicePath = *attr.StringValue + } + } + } + } + + attrs := v1alpha2.NodeUSBDeviceAttributes{ + NodeName: nodeName, + VendorID: vendorID, + ProductID: productID, + Bus: bus, + DeviceNumber: deviceNumber, + Serial: serial, + DevicePath: devicePath, + } + + return CalculateHash(attrs) +} diff --git a/images/virtualization-artifact/pkg/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-artifact/pkg/controller/indexer/indexer.go b/images/virtualization-artifact/pkg/controller/indexer/indexer.go index 5f01c64b7e..8e761cd418 100644 --- a/images/virtualization-artifact/pkg/controller/indexer/indexer.go +++ b/images/virtualization-artifact/pkg/controller/indexer/indexer.go @@ -31,11 +31,12 @@ const ( ) const ( - IndexFieldVMByClass = "spec.virtualMachineClassName" - IndexFieldVMByVD = "spec.blockDeviceRefs.VirtualDisk" - IndexFieldVMByVI = "spec.blockDeviceRefs.VirtualImage" - IndexFieldVMByCVI = "spec.blockDeviceRefs.ClusterVirtualImage" - IndexFieldVMByNode = "status.node" + IndexFieldVMByClass = "spec.virtualMachineClassName" + 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" IndexFieldVIByVDSnapshot = "vi,spec.DataSource.ObjectRef.Name,.Kind=VirtualDiskSnapshot" @@ -65,6 +66,8 @@ const ( IndexFieldVMIPLeaseByVMIP = "spec.virtualMachineIPAddressRef" IndexFieldVMByProvisioningSecret = "spec.provisioning.secretRef" + + IndexFieldNodeUSBDeviceByName = "metadata.name" ) var IndexGetters = []IndexGetter{ @@ -72,6 +75,7 @@ var IndexGetters = []IndexGetter{ IndexVMByVD, IndexVMByVI, IndexVMByCVI, + IndexVMByUSBDevice, IndexVMByNode, IndexVMByProvisioningSecret, IndexVMSnapshotByVM, @@ -91,6 +95,7 @@ var IndexGetters = []IndexGetter{ IndexVMMACByAddress, IndexVMMACLeaseByVMMAC, IndexVMIPLeaseByVMIP, + IndexNodeUSBDeviceByName, } type IndexGetter func() (obj client.Object, field string, extractValue client.IndexerFunc) @@ -191,3 +196,34 @@ 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 + } +} + +func IndexNodeUSBDeviceByName() (obj client.Object, field string, extractValue client.IndexerFunc) { + return &v1alpha2.NodeUSBDevice{}, IndexFieldNodeUSBDeviceByName, func(object client.Object) []string { + nodeUSBDevice, ok := object.(*v1alpha2.NodeUSBDevice) + if !ok || nodeUSBDevice == nil { + return nil + } + return []string{nodeUSBDevice.Name} + } +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go new file mode 100644 index 0000000000..c97c95a82a --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned.go @@ -0,0 +1,241 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package internal + +import ( + "context" + "fmt" + "reflect" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "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 ( + 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) Name() string { + return nameAssignedHandler +} + +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() + + // Do not create or update USBDevice when NodeUSBDevice is being deleted — cleanup is done by DeletionHandler + if !current.GetDeletionTimestamp().IsZero() { + return reconcile.Result{}, nil + } + + assignedNamespace := current.Spec.AssignedNamespace + + // Check previous assignedNamespace if it changed + // Try to find previous USBDevice to delete it if namespace changed + var usbDeviceList v1alpha2.USBDeviceList + if err := h.client.List(ctx, &usbDeviceList); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to list USBDevices: %w", err) + } + for _, 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 + var status metav1.ConditionStatus + + if assignedNamespace != "" { + // 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 - 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 usbDeviceListForDelete v1alpha2.USBDeviceList + if err := h.client.List(ctx, &usbDeviceListForDelete); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to list USBDevices: %w", err) + } + for _, usbDevice := range usbDeviceListForDelete.Items { + if usbDevice.Name == current.Name { + if err := h.deleteUSBDevice(ctx, usbDevice.Namespace, usbDevice.Name); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to delete USBDevice: %w", err) + } + } + } + + reason = nodeusbdevicecondition.Available + message = "No namespace is assigned for the device" + status = metav1.ConditionFalse + } + + cb := conditions.NewConditionBuilder(nodeusbdevicecondition.AssignedType). + Generation(current.GetGeneration()). + Status(status). + Reason(reason). + Message(message) + + conditions.SetCondition(cb, &changed.Status.Conditions) + + return reconcile.Result{}, nil +} + +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, + } + + err := h.client.Get(ctx, key, usbDevice) + if err == nil { + // USBDevice exists - check if update is needed + needsUpdate := !reflect.DeepEqual(usbDevice.Status.Attributes, nodeUSBDevice.Status.Attributes) || + usbDevice.Status.NodeName != nodeUSBDevice.Status.NodeName + + if needsUpdate { + usbDevice.Status.Attributes = nodeUSBDevice.Status.Attributes + usbDevice.Status.NodeName = nodeUSBDevice.Status.NodeName + if err := h.client.Status().Update(ctx, usbDevice); err != nil { + return nil, fmt.Errorf("failed to update USBDevice status: %w", err) + } + } + return usbDevice, nil + } + + if !errors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get USBDevice: %w", err) + } + + // USBDevice doesn't exist - create it + // Create USBDevice without status (status is a subresource) + usbDevice = &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: nodeUSBDevice.Name, + Namespace: namespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: v1alpha2.SchemeGroupVersion.String(), + Kind: "NodeUSBDevice", + Name: nodeUSBDevice.Name, + UID: nodeUSBDevice.UID, + Controller: ptr.To(true), + }, + }, + }, + } + + if err := h.client.Create(ctx, usbDevice); err != nil { + return nil, fmt.Errorf("failed to create USBDevice: %w", err) + } + + // Update status separately (status is a subresource) + usbDevice.Status = v1alpha2.USBDeviceStatus{ + Attributes: nodeUSBDevice.Status.Attributes, + NodeName: nodeUSBDevice.Status.NodeName, + } + + if err := h.client.Status().Update(ctx, usbDevice); err != nil { + return nil, fmt.Errorf("failed to update USBDevice status: %w", err) + } + + return usbDevice, nil +} + +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/assigned_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go new file mode 100644 index 0000000000..c665c158a3 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/assigned_test.go @@ -0,0 +1,284 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package internal + +import ( + "context" + "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/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("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", + UID: types.UID("node-usb-device-uid-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). + WithStatusSubresource(&v1alpha2.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 = 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). + WithStatusSubresource(&v1alpha2.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 = 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")) + Expect(usbDevice.Status.Attributes.ProductID).To(Equal("5678")) + Expect(usbDevice.Status.NodeName).To(Equal("node-1")) + }) + }) + + 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.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go new file mode 100644 index 0000000000..695b7893c6 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion.go @@ -0,0 +1,88 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package internal + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "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 + } + + // Resource is being deleted - clean up all USBDevice resources owned by this NodeUSBDevice (in any namespace) + var usbDeviceList v1alpha2.USBDeviceList + if err := h.client.List(ctx, &usbDeviceList); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to list USBDevices: %w", err) + } + for i := range usbDeviceList.Items { + usbDevice := &usbDeviceList.Items[i] + if metav1.IsControlledBy(usbDevice, current) { + if err := h.client.Delete(ctx, usbDevice); err != nil && !apierrors.IsNotFound(err) { + return reconcile.Result{}, fmt.Errorf("failed to delete USBDevice %s/%s: %w", usbDevice.Namespace, usbDevice.Name, err) + } + } + } + + // Remove finalizer + controllerutil.RemoveFinalizer(changed, v1alpha2.FinalizerNodeUSBDeviceCleanup) + + return reconcile.Result{}, nil +} + +func (h *DeletionHandler) Name() string { + return nameDeletionHandler +} 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..2e3b2ddff2 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/deletion_test.go @@ -0,0 +1,241 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package internal + +import ( + "context" + "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" + "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/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" +) + +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() + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + UID: "node-usb-device-uid", + Finalizers: []string{v1alpha2.FinalizerNodeUSBDeviceCleanup}, + DeletionTimestamp: &now, + }, + Spec: v1alpha2.NodeUSBDeviceSpec{ + AssignedNamespace: "test-namespace", + }, + } + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "test-namespace", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: v1alpha2.SchemeGroupVersion.String(), + Kind: "NodeUSBDevice", + Name: nodeUSBDevice.Name, + UID: nodeUSBDevice.UID, + Controller: ptr.To(true), + }, + }, + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + 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)) + }) + + It("should delete USBDevice by OwnerReference even when in different namespace than AssignedNamespace", func() { + now := metav1.Now() + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + UID: "node-usb-device-uid", + Finalizers: []string{v1alpha2.FinalizerNodeUSBDeviceCleanup}, + DeletionTimestamp: &now, + }, + Spec: v1alpha2.NodeUSBDeviceSpec{ + AssignedNamespace: "other-namespace", // different from where USBDevice actually is + }, + } + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: "previous-namespace", // e.g. spec was changed but AssignedHandler did not run yet + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: v1alpha2.SchemeGroupVersion.String(), + Kind: "NodeUSBDevice", + Name: nodeUSBDevice.Name, + UID: nodeUSBDevice.UID, + Controller: ptr.To(true), + }, + }, + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + fakeClient = fake.NewClientBuilder().WithScheme(scheme).WithObjects(nodeUSBDevice, usbDevice).Build() + + nodeUSBDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: nodeUSBDevice.Name}, + fakeClient, + func() *v1alpha2.NodeUSBDevice { return &v1alpha2.NodeUSBDevice{} }, + func(obj *v1alpha2.NodeUSBDevice) v1alpha2.NodeUSBDeviceStatus { return obj.Status }, + ) + Expect(nodeUSBDeviceResource.Fetch(ctx)).To(Succeed()) + + nodeUSBDeviceState = state.New(fakeClient, nodeUSBDeviceResource) + recorder := &eventrecord.EventRecorderLoggerMock{} + handler = NewDeletionHandler(fakeClient, recorder) + + result, err := handler.Handle(ctx, nodeUSBDeviceState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // USBDevice in previous-namespace must be deleted (found by OwnerReference) + deletedUSBDevice := &v1alpha2.USBDevice{} + err = fakeClient.Get(ctx, types.NamespacedName{Name: "usb-device-1", Namespace: "previous-namespace"}, deletedUSBDevice) + Expect(err).To(HaveOccurred()) + + Expect(nodeUSBDeviceResource.Changed().GetFinalizers()).NotTo(ContainElement(v1alpha2.FinalizerNodeUSBDeviceCleanup)) + }) + }) +}) 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..4338f08993 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready.go @@ -0,0 +1,119 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package internal + +import ( + "context" + "fmt" + "strings" + + resourcev1beta1 "k8s.io/api/resource/v1beta1" + 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/common/hash" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" +) + +const ( + nameReadyHandler = "ReadyHandler" +) + +func NewReadyHandler(recorder eventrecord.EventRecorderLogger) *ReadyHandler { + return &ReadyHandler{ + recorder: recorder, + } +} + +type ReadyHandler struct { + 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 := s.ResourceSlices(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 nodeusbdevicecondition.ReadyReason + var message string + var status metav1.ConditionStatus + + if !deviceFound { + // Device not found - mark as 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 = nodeusbdevicecondition.Ready + message = "Device is ready to use" + status = metav1.ConditionTrue + } + + cb := conditions.NewConditionBuilder(nodeusbdevicecondition.ReadyType). + Generation(current.GetGeneration()). + Status(status). + Reason(reason). + Message(message) + + conditions.SetCondition(cb, &changed.Status.Conditions) + + return reconcile.Result{}, nil +} + +func (h *ReadyHandler) findDeviceInSlices(slices []resourcev1beta1.ResourceSlice, searchedHash, nodeName string) bool { + for _, slice := range slices { + if slice.Spec.Pool.Name != nodeName { + continue + } + + for _, device := range slice.Spec.Devices { + if !strings.HasPrefix(device.Name, "usb-") { + continue + } + + // Calculate hash for this device and compare + deviceHash := hash.CalculateHashFromDevice(device, nodeName) + if deviceHash == searchedHash { + return true + } + } + } + + return false +} + +func (h *ReadyHandler) Name() string { + return nameReadyHandler +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready_test.go new file mode 100644 index 0000000000..3b9cfd5e12 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/ready_test.go @@ -0,0 +1,204 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package internal + +import ( + "context" + "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: "virtualization-dra", + 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(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(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/state/state.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go new file mode 100644 index 0000000000..60f58208f8 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go @@ -0,0 +1,69 @@ +/* +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 state + +import ( + "context" + "fmt" + + resourcev1beta1 "k8s.io/api/resource/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +const ( + draDriverName = "virtualization-dra" +) + +type NodeUSBDeviceState interface { + NodeUSBDevice() *reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus] + ResourceSlices(ctx context.Context) ([]resourcev1beta1.ResourceSlice, error) +} + +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) ([]resourcev1beta1.ResourceSlice, error) { + var slices resourcev1beta1.ResourceSliceList + if err := s.client.List(ctx, &slices, client.MatchingLabels{}); err != nil { + return nil, fmt.Errorf("failed to list ResourceSlices: %w", err) + } + + result := make([]resourcev1beta1.ResourceSlice, 0) + for _, slice := range slices.Items { + if slice.Spec.Driver == draDriverName { + result = append(result, slice) + } + } + + return result, nil +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/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/nodeusbdevice/internal/watcher/resourceslice_watcher.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go new file mode 100644 index 0000000000..7b51a3e8ef --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go @@ -0,0 +1,86 @@ +/* +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" + "strings" + + resourcev1beta1 "k8s.io/api/resource/v1beta1" + "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/deckhouse/pkg/log" + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +const ( + draDriverName = "virtualization-dra" +) + +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 != draDriverName { + return nil + } + + // Check if ResourceSlice contains USB devices + hasUSBDevices := false + for _, device := range slice.Spec.Devices { + if strings.HasPrefix(device.Name, "usb-") { + hasUSBDevices = true + break + } + } + if !hasUSBDevices { + return nil + } + + // Enqueue NodeUSBDevices on this node so they can sync attributes from the updated slice + deviceList := &v1alpha2.NodeUSBDeviceList{} + if err := mgr.GetClient().List(ctx, deviceList); err != nil { + log.Error("failed to list NodeUSBDevices in ResourceSliceWatcher", log.Err(err)) + return nil + } + + var result []reconcile.Request + for _, device := range deviceList.Items { + if device.Status.NodeName == slice.Spec.Pool.Name { + result = append(result, reconcile.Request{ + NamespacedName: object.NamespacedName(&device), + }) + } + } + 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..f328209945 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_controller.go @@ -0,0 +1,79 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package nodeusbdevice + +import ( + "context" + "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(recorder), + internal.NewAssignedHandler(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 + } + + if err = builder.WebhookManagedBy(mgr). + For(&v1alpha2.NodeUSBDevice{}). + WithValidator(NewValidator(log)). + Complete(); err != nil { + return nil, err + } + + log.Info("Initialized NodeUSBDevice controller") + return c, nil +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go new file mode 100644 index 0000000000..411052f80f --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go @@ -0,0 +1,114 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package nodeusbdevice + +import ( + "context" + "fmt" + "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/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) { + nodeUSBDevice := reconciler.NewResource(req.NamespacedName, r.client, r.factory, r.statusGetter) + + err := nodeUSBDevice.Fetch(ctx) + if err != nil { + return reconcile.Result{}, err + } + + s := state.New(r.client, nodeUSBDevice) + + if nodeUSBDevice.IsEmpty() { + // NodeUSBDevice is created by the ResourceSlice controller + return reconcile.Result{}, nil + } + + 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 +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go new file mode 100644 index 0000000000..5bd1b3b131 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_webhook.go @@ -0,0 +1,107 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package nodeusbdevice + +import ( + "context" + "fmt" + "reflect" + "strings" + + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func NewValidator(log *log.Logger) *Validator { + return &Validator{ + log: log.With("webhook", "validation"), + } +} + +type Validator struct { + log *log.Logger +} + +// ValidateCreate validates NodeUSBDevice creation. +// NodeUSBDevice resources can only be created by system service accounts (controllers). +func (v *Validator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + if isSystemServiceAccount(ctx) { + return nil, nil + } + return nil, fmt.Errorf("NodeUSBDevice can only be created by system service accounts") +} + +// ValidateUpdate validates NodeUSBDevice updates. +// Only spec can be changed by administrators. Metadata cannot be modified. +// Status updates are performed by the controller via subresource. +func (v *Validator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + // System service accounts can change anything + if isSystemServiceAccount(ctx) { + return nil, nil + } + + oldNodeUSBDevice, ok := oldObj.(*v1alpha2.NodeUSBDevice) + if !ok { + return nil, fmt.Errorf("expected an old NodeUSBDevice but got a %T", oldObj) + } + + newNodeUSBDevice, ok := newObj.(*v1alpha2.NodeUSBDevice) + if !ok { + return nil, fmt.Errorf("expected a new NodeUSBDevice but got a %T", newObj) + } + + // Spec changes are only allowed + if !reflect.DeepEqual(oldNodeUSBDevice.Spec, newNodeUSBDevice.Spec) { + return nil, nil + } + + return nil, fmt.Errorf("only spec.assignedNamespace can be changed") +} + +// ValidateDelete validates NodeUSBDevice deletion. +// NodeUSBDevice resources can be deleted by administrators. +func (v *Validator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + nodeUSBDevice, ok := obj.(*v1alpha2.NodeUSBDevice) + if !ok { + return nil, fmt.Errorf("expected a NodeUSBDevice but got a %T", obj) + } + + v.log.Info("Validate NodeUSBDevice deleting", "name", nodeUSBDevice.Name) + + // NodeUSBDevice can be deleted by administrators + // The controller will clean up associated USBDevice resources via finalizer + return nil, nil +} + +// isSystemServiceAccount checks if the request is made by a system service account. +func isSystemServiceAccount(ctx context.Context) bool { + req, err := admission.RequestFromContext(ctx) + if err != nil { + return false + } + + if strings.HasPrefix(req.UserInfo.Username, "system:serviceaccount:kube-system:") || + strings.HasPrefix(req.UserInfo.Username, "system:serviceaccount:d8-system:") || + strings.HasPrefix(req.UserInfo.Username, "system:serviceaccount:d8-virtualization:") { + return true + } + + return false +} diff --git a/images/virtualization-artifact/pkg/controller/resourceslice/internal/nodeusbdevice_handler.go b/images/virtualization-artifact/pkg/controller/resourceslice/internal/nodeusbdevice_handler.go new file mode 100644 index 0000000000..1e813fe79e --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/resourceslice/internal/nodeusbdevice_handler.go @@ -0,0 +1,224 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package internal + +import ( + "context" + "fmt" + "strings" + + resourcev1beta1 "k8s.io/api/resource/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/common/hash" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" +) + +const ( + nameNodeUSBDeviceHandler = "NodeUSBDeviceHandler" +) + +func NewNodeUSBDeviceHandler(client client.Client) *NodeUSBDeviceHandler { + return &NodeUSBDeviceHandler{client: client} +} + +type NodeUSBDeviceHandler struct { + client client.Client +} + +func (h *NodeUSBDeviceHandler) Name() string { + return nameNodeUSBDeviceHandler +} + +func (h *NodeUSBDeviceHandler) Handle(ctx context.Context, slice *resourcev1beta1.ResourceSlice) error { + hasUSBDevices := false + for _, device := range slice.Spec.Devices { + if strings.HasPrefix(device.Name, "usb-") { + hasUSBDevices = true + break + } + } + if !hasUSBDevices { + return nil + } + + nodeName := slice.Spec.Pool.Name + + var existingDevices v1alpha2.NodeUSBDeviceList + if err := h.client.List(ctx, &existingDevices); err != nil { + return fmt.Errorf("list NodeUSBDevices: %w", err) + } + + existingHashes := make(map[string]bool) + for _, device := range existingDevices.Items { + if device.Status.Attributes.Hash != "" { + existingHashes[device.Status.Attributes.Hash] = true + } + } + + for _, device := range slice.Spec.Devices { + if !strings.HasPrefix(device.Name, "usb-") { + continue + } + + attributes := convertDeviceToAttributes(device, nodeName) + hashStr := hash.CalculateHash(attributes) + + if existingHashes[hashStr] { + continue + } + + if err := h.createNodeUSBDevice(ctx, attributes, hashStr); err != nil { + return err + } + + existingHashes[hashStr] = true + } + + return nil +} + +func (h *NodeUSBDeviceHandler) createNodeUSBDevice(ctx context.Context, attributes v1alpha2.NodeUSBDeviceAttributes, hashStr string) error { + name := generateName(hashStr, attributes.NodeName) + + existing := &v1alpha2.NodeUSBDevice{} + err := h.client.Get(ctx, client.ObjectKey{Name: name}, existing) + if err == nil { + return nil + } + if !apierrors.IsNotFound(err) { + return fmt.Errorf("check NodeUSBDevice %s: %w", name, err) + } + + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: v1alpha2.NodeUSBDeviceSpec{ + AssignedNamespace: "", + }, + } + + if err := h.client.Create(ctx, nodeUSBDevice); err != nil { + if apierrors.IsAlreadyExists(err) { + return nil + } + return fmt.Errorf("create NodeUSBDevice %s: %w", name, err) + } + + attributes.Hash = hashStr + nodeUSBDevice.Status = v1alpha2.NodeUSBDeviceStatus{ + Attributes: attributes, + NodeName: attributes.NodeName, + Conditions: []metav1.Condition{ + { + Type: string(nodeusbdevicecondition.ReadyType), + Status: metav1.ConditionTrue, + Reason: string(nodeusbdevicecondition.Ready), + Message: "Device is ready to use", + LastTransitionTime: metav1.Now(), + }, + { + Type: string(nodeusbdevicecondition.AssignedType), + Status: metav1.ConditionFalse, + Reason: string(nodeusbdevicecondition.Available), + Message: "No namespace is assigned for the device", + LastTransitionTime: metav1.Now(), + }, + }, + } + + if err := h.client.Status().Update(ctx, nodeUSBDevice); err != nil { + return fmt.Errorf("update NodeUSBDevice %s status: %w", name, err) + } + + return nil +} + +func convertDeviceToAttributes(device resourcev1beta1.Device, nodeName string) v1alpha2.NodeUSBDeviceAttributes { + attrs := v1alpha2.NodeUSBDeviceAttributes{ + NodeName: nodeName, + Name: device.Name, + } + + if device.Basic == nil { + return attrs + } + + for key, attr := range device.Basic.Attributes { + switch string(key) { + case "name": + if attr.StringValue != nil { + attrs.Name = *attr.StringValue + } + case "manufacturer": + if attr.StringValue != nil { + attrs.Manufacturer = *attr.StringValue + } + case "product": + if attr.StringValue != nil { + attrs.Product = *attr.StringValue + } + case "vendorID": + if attr.StringValue != nil { + attrs.VendorID = *attr.StringValue + } + case "productID": + if attr.StringValue != nil { + attrs.ProductID = *attr.StringValue + } + case "bcd": + if attr.StringValue != nil { + attrs.BCD = *attr.StringValue + } + case "bus": + if attr.StringValue != nil { + attrs.Bus = *attr.StringValue + } + case "deviceNumber": + if attr.StringValue != nil { + attrs.DeviceNumber = *attr.StringValue + } + case "serial": + if attr.StringValue != nil { + attrs.Serial = *attr.StringValue + } + case "devicePath": + if attr.StringValue != nil { + attrs.DevicePath = *attr.StringValue + } + case "major": + if attr.IntValue != nil { + attrs.Major = int(*attr.IntValue) + } + case "minor": + if attr.IntValue != nil { + attrs.Minor = int(*attr.IntValue) + } + } + } + + return attrs +} + +func generateName(hashStr, nodeName string) string { + nodeNameSanitized := strings.ToLower(strings.ReplaceAll(nodeName, ".", "-")) + return fmt.Sprintf("nusb-%s-%s", hashStr[:8], nodeNameSanitized) +} diff --git a/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_controller.go b/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_controller.go new file mode 100644 index 0000000000..490fcac72c --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_controller.go @@ -0,0 +1,73 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package resourceslice + +import ( + "context" + "fmt" + "time" + + resourcev1beta1 "k8s.io/api/resource/v1beta1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/virtualization-controller/pkg/controller/resourceslice/internal" + "github.com/deckhouse/virtualization-controller/pkg/logger" +) + +const ( + ControllerName = "resourceslice-controller" +) + +func NewController( + ctx context.Context, + mgr manager.Manager, + log *log.Logger, +) (controller.Controller, error) { + client := mgr.GetClient() + + handlers := []Handler{ + internal.NewNodeUSBDeviceHandler(client), + } + r := NewReconciler(client, handlers...) + + c, err := controller.New(ControllerName, mgr, controller.Options{ + Reconciler: r, + RecoverPanic: ptr.To(true), + LogConstructor: logger.NewConstructor(log), + CacheSyncTimeout: 10 * time.Minute, + }) + if err != nil { + return nil, err + } + + if err := c.Watch( + source.Kind(mgr.GetCache(), + &resourcev1beta1.ResourceSlice{}, + &handler.TypedEnqueueRequestForObject[*resourcev1beta1.ResourceSlice]{}, + ), + ); err != nil { + return nil, fmt.Errorf("watch ResourceSlice: %w", err) + } + + log.Info("Initialized ResourceSlice controller") + return c, nil +} diff --git a/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_reconciler.go b/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_reconciler.go new file mode 100644 index 0000000000..38f93226dc --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_reconciler.go @@ -0,0 +1,70 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package resourceslice + +import ( + "context" + "fmt" + + resourcev1beta1 "k8s.io/api/resource/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +const ( + draDriverName = "virtualization-dra" +) + +type Handler interface { + Handle(ctx context.Context, slice *resourcev1beta1.ResourceSlice) error + Name() string +} + +type Reconciler struct { + client client.Client + handlers []Handler +} + +func NewReconciler(client client.Client, handlers ...Handler) *Reconciler { + return &Reconciler{ + client: client, + handlers: handlers, + } +} + +func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + slice := &resourcev1beta1.ResourceSlice{} + if err := r.client.Get(ctx, client.ObjectKey{Name: req.Name}, slice); err != nil { + if apierrors.IsNotFound(err) { + return reconcile.Result{}, nil + } + return reconcile.Result{}, fmt.Errorf("get ResourceSlice %s: %w", req.Name, err) + } + + if slice.Spec.Driver != draDriverName { + return reconcile.Result{}, nil + } + + for _, h := range r.handlers { + if err := h.Handle(ctx, slice); err != nil { + return reconcile.Result{}, err + } + } + + return reconcile.Result{}, nil +} 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..ccf32ec342 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/attached.go @@ -0,0 +1,95 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package internal + +import ( + "context" + "fmt" + + 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/usbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2/usbdevicecondition" +) + +const ( + nameAttachedHandler = "AttachedHandler" +) + +func NewAttachedHandler(recorder eventrecord.EventRecorderLogger) *AttachedHandler { + return &AttachedHandler{ + recorder: recorder, + } +} + +type AttachedHandler struct { + 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() + + // Check if device is attached to a VM + vms, err := s.VirtualMachinesUsingDevice(ctx) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to find VirtualMachines using USBDevice: %w", err) + } + + var reason usbdevicecondition.AttachedReason + var status metav1.ConditionStatus + var message string + + if len(vms) > 0 { + // Device is attached to at least one VM + reason = usbdevicecondition.AttachedToVirtualMachine + status = metav1.ConditionTrue + if len(vms) == 1 { + message = fmt.Sprintf("Device is attached to VirtualMachine %s/%s", vms[0].Namespace, vms[0].Name) + } else { + message = fmt.Sprintf("Device is attached to %d VirtualMachines", len(vms)) + } + } else { + // Device is available for attachment + reason = usbdevicecondition.Available + status = metav1.ConditionFalse + message = "Device is available for attachment to a virtual machine" + } + + cb := conditions.NewConditionBuilder(usbdevicecondition.AttachedType). + Generation(current.GetGeneration()). + 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..56515f5ef5 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion.go @@ -0,0 +1,107 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package internal + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + subv1alpha2 "github.com/deckhouse/virtualization/api/subresources/v1alpha2" +) + +const ( + nameDeletionHandler = "DeletionHandler" +) + +func NewDeletionHandler(client client.Client, virtClient versioned.Interface, recorder eventrecord.EventRecorderLogger) *DeletionHandler { + return &DeletionHandler{ + client: client, + virtClient: virtClient, + recorder: recorder, + } +} + +type DeletionHandler struct { + client client.Client + virtClient versioned.Interface + 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 and perform hot unplug if needed + vms, err := s.VirtualMachinesUsingDevice(ctx) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to find VirtualMachines using USBDevice: %w", err) + } + + if len(vms) > 0 { + // Device is attached to one or more VMs - perform hot unplug + h.recorder.Eventf(changed, "Normal", "Deletion", "Device is attached to VM(s), performing hot unplug") + + for _, vm := range vms { + // Remove ResourceClaim from VM + opts := subv1alpha2.VirtualMachineRemoveResourceClaim{ + Name: current.Name, + } + if err := h.virtClient.VirtualizationV1alpha2().VirtualMachines(vm.Namespace).RemoveResourceClaim(ctx, vm.Name, opts); err != nil { + // Ignore NotFound - VM or ResourceClaim may have been deleted already + if !apierrors.IsNotFound(err) { + h.recorder.Eventf(changed, "Warning", "Deletion", "Failed to remove ResourceClaim from VM %s/%s: %v", vm.Namespace, vm.Name, err) + return reconcile.Result{Requeue: true}, fmt.Errorf("failed to remove ResourceClaim from VM %s/%s: %w", vm.Namespace, vm.Name, err) + } + } else { + h.recorder.Eventf(changed, "Normal", "Deletion", "Removed ResourceClaim from VM %s/%s", vm.Namespace, vm.Name) + } + } + + // Requeue to verify that device is no longer attached + return reconcile.Result{Requeue: true}, nil + } + + // 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/deletion_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go new file mode 100644 index 0000000000..08f7a3b944 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/deletion_test.go @@ -0,0 +1,229 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package internal + +import ( + "context" + "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/indexer" + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization-controller/pkg/logger" + fakeversioned "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/fake" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/usbdevicecondition" +) + +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()) + + vmObj, vmField, vmExtractValue := indexer.IndexVMByUSBDevice() + nodeUSBDeviceObj, nodeUSBDeviceField, nodeUSBDeviceExtractValue := indexer.IndexNodeUSBDeviceByName() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(usbDevice). + WithIndex(vmObj, vmField, vmExtractValue). + WithIndex(nodeUSBDeviceObj, nodeUSBDeviceField, nodeUSBDeviceExtractValue). + Build() + + usbDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + 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{} + fakeVirtClient := fakeversioned.NewSimpleClientset() + handler = NewDeletionHandler(fakeClient, fakeVirtClient, 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()) + + vmObj, vmField, vmExtractValue := indexer.IndexVMByUSBDevice() + nodeUSBDeviceObj, nodeUSBDeviceField, nodeUSBDeviceExtractValue := indexer.IndexNodeUSBDeviceByName() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(usbDevice). + WithIndex(vmObj, vmField, vmExtractValue). + WithIndex(nodeUSBDeviceObj, nodeUSBDeviceField, nodeUSBDeviceExtractValue). + Build() + + usbDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + 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{} + fakeVirtClient := fakeversioned.NewSimpleClientset() + handler = NewDeletionHandler(fakeClient, fakeVirtClient, 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), + }, + }, + }, + } + + // Create a VM that uses this USB device + vm := &v1alpha2.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-vm", + Namespace: "default", + }, + Spec: v1alpha2.VirtualMachineSpec{ + USBDevices: []v1alpha2.USBDeviceSpecRef{ + { + Name: "usb-device-1", + }, + }, + }, + Status: v1alpha2.VirtualMachineStatus{ + USBDevices: []v1alpha2.USBDeviceStatusRef{ + { + Name: "usb-device-1", + Attached: true, + }, + }, + }, + } + + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + vmObj, vmField, vmExtractValue := indexer.IndexVMByUSBDevice() + nodeUSBDeviceObj, nodeUSBDeviceField, nodeUSBDeviceExtractValue := indexer.IndexNodeUSBDeviceByName() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(usbDevice, vm). + WithIndex(vmObj, vmField, vmExtractValue). + WithIndex(nodeUSBDeviceObj, nodeUSBDeviceField, nodeUSBDeviceExtractValue). + Build() + + usbDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + 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 ...any) {}, + } + fakeVirtClient := fakeversioned.NewSimpleClientset() + handler = NewDeletionHandler(fakeClient, fakeVirtClient, recorder) + + result, err := handler.Handle(ctx, usbDeviceState) + // Should requeue to verify device is no longer attached + Expect(err).NotTo(HaveOccurred()) + Expect(result.Requeue).To(BeTrue()) + + // Verify finalizer was not removed yet + 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..fc2a4eb948 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/ready_test.go @@ -0,0 +1,269 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package internal + +import ( + "context" + "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/indexer" + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "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("SyncReadyHandler - Ready condition", func() { + var ctx context.Context + var fakeClient client.WithWatch + var handler *SyncReadyHandler + 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()) + + obj, field, extractValue := indexer.IndexNodeUSBDeviceByName() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(usbDevice, nodeUSBDevice). + WithIndex(obj, field, extractValue). + Build() + + usbDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + 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 = NewSyncReadyHandler(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()) + + obj, field, extractValue := indexer.IndexNodeUSBDeviceByName() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(usbDevice, nodeUSBDevice). + WithIndex(obj, field, extractValue). + Build() + + usbDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + 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 = NewSyncReadyHandler(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()) + + obj, field, extractValue := indexer.IndexNodeUSBDeviceByName() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(usbDevice). + WithIndex(obj, field, extractValue). + Build() + + usbDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + 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 = NewSyncReadyHandler(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()) + + obj, field, extractValue := indexer.IndexNodeUSBDeviceByName() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(usbDevice, nodeUSBDevice). + WithIndex(obj, field, extractValue). + Build() + + usbDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + 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 = NewSyncReadyHandler(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/state/state.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go new file mode 100644 index 0000000000..9e20c54b24 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go @@ -0,0 +1,102 @@ +/* +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 state + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" + "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) + VirtualMachinesUsingDevice(ctx context.Context) ([]*v1alpha2.VirtualMachine, 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 + // Use indexer to find NodeUSBDevice by name + usbDevice := s.usbDevice.Current() + if usbDevice == nil { + return nil, nil + } + + var nodeUSBDeviceList v1alpha2.NodeUSBDeviceList + if err := s.client.List(ctx, &nodeUSBDeviceList, client.MatchingFields{ + indexer.IndexFieldNodeUSBDeviceByName: usbDevice.Name, + }); err != nil { + return nil, err + } + + if len(nodeUSBDeviceList.Items) > 0 { + return &nodeUSBDeviceList.Items[0], nil + } + + return nil, nil +} + +func (s *usbDeviceState) VirtualMachinesUsingDevice(ctx context.Context) ([]*v1alpha2.VirtualMachine, error) { + usbDevice := s.usbDevice.Current() + if usbDevice == nil { + return nil, nil + } + + var vmList v1alpha2.VirtualMachineList + if err := s.client.List(ctx, &vmList, client.MatchingFields{ + indexer.IndexFieldVMByUSBDevice: usbDevice.Name, + }); err != nil { + return nil, err + } + + var result []*v1alpha2.VirtualMachine + for i := range vmList.Items { + vm := &vmList.Items[i] + // Check if VM is in the same namespace as USBDevice + if vm.Namespace == usbDevice.Namespace { + // Verify that device is actually attached in VM status + for _, usbStatus := range vm.Status.USBDevices { + if usbStatus.Name == usbDevice.Name && usbStatus.Attached { + result = append(result, vm) + break + } + } + } + } + + return result, nil +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/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_ready.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_ready.go new file mode 100644 index 0000000000..97af12efe8 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_ready.go @@ -0,0 +1,138 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package internal + +import ( + "context" + "reflect" + + 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/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 ( + nameSyncReadyHandler = "SyncReadyHandler" +) + +func NewSyncReadyHandler(recorder eventrecord.EventRecorderLogger) *SyncReadyHandler { + return &SyncReadyHandler{ + recorder: recorder, + } +} + +type SyncReadyHandler struct { + recorder eventrecord.EventRecorderLogger +} + +func (h *SyncReadyHandler) 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 once + 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 + } + + // Sync attributes and nodeName from NodeUSBDevice (only if changed) + needsSync := !reflect.DeepEqual(changed.Status.Attributes, nodeUSBDevice.Status.Attributes) || + changed.Status.NodeName != nodeUSBDevice.Status.NodeName + + if needsSync { + changed.Status.Attributes = nodeUSBDevice.Status.Attributes + changed.Status.NodeName = nodeUSBDevice.Status.NodeName + } + + // Sync Ready condition from NodeUSBDevice + var readyCondition *metav1.Condition + for i := range nodeUSBDevice.Status.Conditions { + if nodeUSBDevice.Status.Conditions[i].Type == string(nodeusbdevicecondition.ReadyType) { + 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 *SyncReadyHandler) Name() string { + return nameSyncReadyHandler +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go new file mode 100644 index 0000000000..9271d91498 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/sync_test.go @@ -0,0 +1,160 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package internal + +import ( + "context" + "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/indexer" + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +var _ = Describe("SyncReadyHandler", func() { + var ctx context.Context + var fakeClient client.WithWatch + var handler *SyncReadyHandler + 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()) + + obj, field, extractValue := indexer.IndexNodeUSBDeviceByName() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(usbDevice, nodeUSBDevice). + WithIndex(obj, field, extractValue). + Build() + + usbDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + 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 = NewSyncReadyHandler(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()) + + obj, field, extractValue := indexer.IndexNodeUSBDeviceByName() + fakeClient = fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(usbDevice). + WithIndex(obj, field, extractValue). + Build() + + usbDeviceResource = reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + 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 = NewSyncReadyHandler(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")) + }) + }) +}) 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..e2601534cc --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/nodeusbdevice_watcher.go @@ -0,0 +1,70 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package 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..0b42d813f7 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go @@ -0,0 +1,76 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package usbdevice + +import ( + "context" + "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" + "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned" +) + +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() + + virtClient, err := versioned.NewForConfig(mgr.GetConfig()) + if err != nil { + return nil, err + } + + handlers := []Handler{ + internal.NewDeletionHandler(client, virtClient, recorder), + internal.NewSyncReadyHandler(recorder), + internal.NewAttachedHandler(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..79fb92a937 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_reconciler.go @@ -0,0 +1,117 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package usbdevice + +import ( + "context" + "fmt" + "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 +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_webhook.go b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_webhook.go new file mode 100644 index 0000000000..425ae9886a --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_webhook.go @@ -0,0 +1,89 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package usbdevice + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/deckhouse/deckhouse/pkg/log" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type Validator struct { + log *log.Logger +} + +func NewValidator(log *log.Logger) *Validator { + return &Validator{ + log: log.With("webhook", "validation"), + } +} + +// ValidateCreate validates USBDevice creation. +// Access control is handled by RBAC - only the controller ServiceAccount has create permissions. +func (v *Validator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + usbDevice, ok := obj.(*v1alpha2.USBDevice) + if !ok { + return nil, fmt.Errorf("expected a new USBDevice but got a %T", obj) + } + + v.log.Info("Validate USBDevice creating", "name", usbDevice.Name, "namespace", usbDevice.Namespace) + + // RBAC controls access - only the controller ServiceAccount can create USBDevice + // No additional validation needed here + return nil, nil +} + +// ValidateUpdate validates USBDevice updates. +// Only status updates are allowed (performed by the controller). +func (v *Validator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + _, ok := oldObj.(*v1alpha2.USBDevice) + if !ok { + return nil, fmt.Errorf("expected an old USBDevice but got a %T", oldObj) + } + + newUSBDevice, ok := newObj.(*v1alpha2.USBDevice) + if !ok { + return nil, fmt.Errorf("expected a new USBDevice but got a %T", newObj) + } + + v.log.Info("Validate USBDevice updating", "name", newUSBDevice.Name, "namespace", newUSBDevice.Namespace) + + // USBDevice has no spec, only status + // Status updates are allowed (performed by the controller) + // Users should not modify USBDevice resources + return nil, nil +} + +// ValidateDelete validates USBDevice deletion. +// Access control is handled by RBAC - only the controller ServiceAccount has delete permissions. +func (v *Validator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + usbDevice, ok := obj.(*v1alpha2.USBDevice) + if !ok { + return nil, fmt.Errorf("expected a USBDevice but got a %T", obj) + } + + v.log.Info("Validate USBDevice deleting", "name", usbDevice.Name, "namespace", usbDevice.Namespace) + + // RBAC controls access - only the controller ServiceAccount can delete USBDevice + // No additional validation needed here + return nil, nil +} 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..28847a09fc --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go @@ -0,0 +1,462 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package internal + +import ( + "context" + "fmt" + + resourcev1beta1 "k8s.io/api/resource/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "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/vm/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/logger" + virtualizationv1alpha2 "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/typed/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/usbdevicecondition" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vmcondition" + subv1alpha2 "github.com/deckhouse/virtualization/api/subresources/v1alpha2" +) + +// VirtClient is an interface for accessing VirtualMachine resources with subresource operations. +type VirtClient interface { + VirtualMachines(namespace string) virtualizationv1alpha2.VirtualMachineInterface +} + +const nameUSBDeviceHandler = "USBDeviceHandler" + +func NewUSBDeviceHandler(cl client.Client, virtClient VirtClient) *USBDeviceHandler { + return &USBDeviceHandler{ + client: cl, + virtClient: virtClient, + } +} + +type USBDeviceHandler struct { + client client.Client + virtClient VirtClient +} + +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, + Ready: false, + } + statusRefs = append(statusRefs, statusRef) + continue + } + + // Get device ready status and conditions + isReady := h.isUSBDeviceReady(usbDevice) + deviceConditions := h.getDeviceConditions(usbDevice) + + // Get or create ResourceClaimTemplate + templateName := h.getResourceClaimTemplateName(vm, usbDeviceRef.Name) + _, err := h.getOrCreateResourceClaimTemplate(ctx, vm, usbDevice, templateName) + 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, + Ready: isReady, + Conditions: deviceConditions, + } + statusRefs = append(statusRefs, statusRef) + continue + } + + // Check if device is ready + if !isReady { + log.Info("USB device not ready", "usbDevice", usbDeviceRef.Name) + // Keep existing status if available, but update ready and conditions + if existingStatus, ok := currentStatusMap[usbDeviceRef.Name]; ok { + existingStatus.Ready = isReady + existingStatus.Conditions = deviceConditions + statusRefs = append(statusRefs, *existingStatus) + } else { + statusRefs = append(statusRefs, v1alpha2.USBDeviceStatusRef{ + Name: usbDeviceRef.Name, + Attached: false, + Ready: isReady, + Conditions: deviceConditions, + }) + } + continue + } + + // Check if already attached + existingStatus, alreadyAttached := currentStatusMap[usbDeviceRef.Name] + if alreadyAttached && existingStatus.Attached { + // Device already attached, keep status but update ready and conditions + existingStatus.Ready = isReady + existingStatus.Conditions = deviceConditions + 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 && !apierrors.IsAlreadyExists(err) { + log.Error("failed to attach USB device", "error", err, "usbDevice", usbDeviceRef.Name) + // Keep existing status or create new one, but update ready and conditions + if existingStatus != nil { + existingStatus.Ready = isReady + existingStatus.Conditions = deviceConditions + statusRefs = append(statusRefs, *existingStatus) + } else { + statusRefs = append(statusRefs, v1alpha2.USBDeviceStatusRef{ + Name: usbDeviceRef.Name, + Attached: false, + Ready: isReady, + Conditions: deviceConditions, + }) + } + 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, vm) + + statusRef := v1alpha2.USBDeviceStatusRef{ + Name: usbDeviceRef.Name, + Attached: true, + Ready: isReady, + Address: address, + Hotplugged: isHotplugged, + Conditions: deviceConditions, + } + statusRefs = append(statusRefs, statusRef) + } + + // Remove devices that are no longer in 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 && !apierrors.IsNotFound(err) { + log.Error("failed to detach USB device", "error", err, "usbDevice", existingStatus.Name) + // Keep status but mark as not attached + existingStatus.Attached = false + statusRefs = append(statusRefs, *existingStatus) + } + // If detach succeeded or NotFound, device is removed from status (not added to statusRefs) + } + } + + changed.Status.USBDevices = statusRefs + + // Update USBDeviceReady condition + h.updateUSBDeviceReadyCondition(vm, changed, 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) != nil { + 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: v1alpha2.SchemeGroupVersion.String(), + Kind: v1alpha2.VirtualMachineKind, + Name: vm.Name, + UID: vm.UID, + Controller: ptr.To(true), + }, + }, + }, + Spec: resourcev1beta1.ResourceClaimTemplateSpec{ + Spec: resourcev1beta1.ResourceClaimSpec{ + Devices: resourcev1beta1.DeviceClaim{ + Requests: []resourcev1beta1.DeviceRequest{ + { + Name: "req-0", + AllocationMode: resourcev1beta1.DeviceAllocationModeExactCount, + Count: 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 + } + + // Check Ready condition + for _, condition := range usbDevice.Status.Conditions { + if condition.Type == string(usbdevicecondition.ReadyType) { + return condition.Status == metav1.ConditionTrue + } + } + + // If no Ready condition found, device is not ready + return false +} + +func (h *USBDeviceHandler) attachUSBDevice( + ctx context.Context, + vm *v1alpha2.VirtualMachine, + usbDeviceName string, + templateName string, + requestName string, +) error { + // Call addResourceClaim API + opts := subv1alpha2.VirtualMachineAddResourceClaim{ + Name: usbDeviceName, + ResourceClaimTemplateName: templateName, + RequestName: requestName, + } + + return h.virtClient.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.VirtualMachines(vm.Namespace).RemoveResourceClaim(ctx, vm.Name, opts) +} + +func (h *USBDeviceHandler) getOrAssignUSBAddress( + existingStatus *v1alpha2.USBDeviceStatusRef, + isHotplugged bool, + vm *v1alpha2.VirtualMachine, +) *v1alpha2.USBAddress { + // If device was already attached, keep the same address + if existingStatus != nil && existingStatus.Address != nil { + return existingStatus.Address + } + + if isHotplugged { + // For hotplugged devices, we don't assign a fixed address + // The address will be assigned dynamically by the hypervisor + return nil + } + + // Assign new address for cold-plugged devices + // Bus is always 0 for main USB controller + // Port should be assigned based on available ports + usedPorts := make(map[int]bool) + for _, usbStatus := range vm.Status.USBDevices { + if usbStatus.Address != nil && usbStatus.Address.Bus == 0 { + usedPorts[usbStatus.Address.Port] = true + } + } + + // Find the first available port starting from 1 + // USB ports typically range from 1 to 127, but we'll use a reasonable limit + port := 1 + for port <= 127 { + if !usedPorts[port] { + break + } + port++ + } + + if port > 127 { + // All ports are used, fallback to port 1 (should not happen in practice) + port = 1 + } + + return &v1alpha2.USBAddress{ + Bus: 0, + Port: port, + } +} + +func (h *USBDeviceHandler) getDeviceConditions(usbDevice *v1alpha2.USBDevice) []metav1.Condition { + // Copy conditions from USBDevice + conditions := make([]metav1.Condition, 0, len(usbDevice.Status.Conditions)) + for _, cond := range usbDevice.Status.Conditions { + conditions = append(conditions, *cond.DeepCopy()) + } + return conditions +} + +func (h *USBDeviceHandler) updateUSBDeviceReadyCondition( + vm *v1alpha2.VirtualMachine, + changed *v1alpha2.VirtualMachine, + statusRefs []v1alpha2.USBDeviceStatusRef, +) { + // Check if all USB devices are ready + allReady := true + var notReadyDevices []string + + for _, statusRef := range statusRefs { + if !statusRef.Ready { + allReady = false + notReadyDevices = append(notReadyDevices, statusRef.Name) + } + } + + var reason vmcondition.USBDeviceReadyReason + var status metav1.ConditionStatus + var message string + + if len(statusRefs) == 0 { + // No USB devices specified, remove condition + conditions.RemoveCondition(vmcondition.TypeUSBDeviceReady, &changed.Status.Conditions) + return + } + + if allReady { + reason = vmcondition.ReasonUSBDeviceReady + status = metav1.ConditionTrue + message = "All USB devices are ready" + } else { + reason = vmcondition.ReasonSomeDevicesNotReady + status = metav1.ConditionFalse + if len(notReadyDevices) == 1 { + message = fmt.Sprintf("USB device '%s' is not ready", notReadyDevices[0]) + } else { + message = fmt.Sprintf("USB devices '%v' are not ready", notReadyDevices) + } + } + + cb := conditions.NewConditionBuilder(vmcondition.TypeUSBDeviceReady). + Generation(vm.GetGeneration()). + Status(status). + Reason(reason). + Message(message) + + conditions.SetCondition(cb, &changed.Status.Conditions) +} 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..7ed7611e9e --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler_test.go @@ -0,0 +1,459 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package internal + +import ( + "context" + "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" + virtualizationv1alpha2 "github.com/deckhouse/virtualization/api/client/generated/clientset/versioned/typed/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + subv1alpha2 "github.com/deckhouse/virtualization/api/subresources/v1alpha2" +) + +// mockVirtClient implements kubeclient.Client interface for testing +type mockVirtClient struct { + vmClients map[string]*mockVirtualMachines +} + +func newMockVirtClient() *mockVirtClient { + return &mockVirtClient{ + vmClients: make(map[string]*mockVirtualMachines), + } +} + +func (m *mockVirtClient) VirtualMachines(namespace string) virtualizationv1alpha2.VirtualMachineInterface { + if _, ok := m.vmClients[namespace]; !ok { + m.vmClients[namespace] = &mockVirtualMachines{ + addResourceClaimCalls: make([]subv1alpha2.VirtualMachineAddResourceClaim, 0), + removeResourceClaimCalls: make([]subv1alpha2.VirtualMachineRemoveResourceClaim, 0), + } + } + return m.vmClients[namespace] +} + +// mockVirtualMachines implements VirtualMachineInterface for testing +type mockVirtualMachines struct { + virtualizationv1alpha2.VirtualMachineInterface + addResourceClaimCalls []subv1alpha2.VirtualMachineAddResourceClaim + removeResourceClaimCalls []subv1alpha2.VirtualMachineRemoveResourceClaim + addResourceClaimErr error + removeResourceClaimErr error +} + +func (m *mockVirtualMachines) AddResourceClaim(_ context.Context, _ string, opts subv1alpha2.VirtualMachineAddResourceClaim) error { + m.addResourceClaimCalls = append(m.addResourceClaimCalls, opts) + return m.addResourceClaimErr +} + +func (m *mockVirtualMachines) RemoveResourceClaim(_ context.Context, _ string, opts subv1alpha2.VirtualMachineRemoveResourceClaim) error { + m.removeResourceClaimCalls = append(m.removeResourceClaimCalls, opts) + return m.removeResourceClaimErr +} + +var _ = Describe("USBDeviceHandler", func() { + var ctx context.Context + var fakeClient client.WithWatch + var mockVirtCl *mockVirtClient + var handler *USBDeviceHandler + var vmState state.VirtualMachineState + var vmResource *reconciler.Resource[*v1alpha2.VirtualMachine, v1alpha2.VirtualMachineStatus] + + BeforeEach(func() { + ctx = logger.ToContext(context.TODO(), slog.Default()) + mockVirtCl = newMockVirtClient() + }) + + 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, mockVirtCl) + + 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", + Conditions: []metav1.Condition{ + { + Type: "Ready", + Status: metav1.ConditionTrue, + }, + }, + }, + } + + 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, mockVirtCl) + + result, err := handler.Handle(ctx, vmState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify AddResourceClaim was called + mockVM := mockVirtCl.vmClients["default"] + Expect(mockVM.addResourceClaimCalls).To(HaveLen(1)) + Expect(mockVM.addResourceClaimCalls[0].Name).To(Equal("usb-device-1")) + + // Verify status was updated + Expect(vmResource.Changed().Status.USBDevices).To(HaveLen(1)) + 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()) + // Hotplugged devices don't get a fixed address - it's assigned dynamically by hypervisor + Expect(vmResource.Changed().Status.USBDevices[0].Address).To(BeNil()) + }) + + It("should not attach USB device when not ready", func() { + 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, mockVirtCl) + + 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, mockVirtCl) + + 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, mockVirtCl) + + result, err := handler.Handle(ctx, vmState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + // Verify RemoveResourceClaim was called + mockVM := mockVirtCl.vmClients["default"] + Expect(mockVM.removeResourceClaimCalls).To(HaveLen(1)) + Expect(mockVM.removeResourceClaimCalls[0].Name).To(Equal("usb-device-1")) + + // Verify device was removed from status + Expect(vmResource.Changed().Status.USBDevices).To(BeEmpty()) + }) + + 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", + Conditions: []metav1.Condition{ + { + Type: "Ready", + Status: metav1.ConditionTrue, + }, + }, + }, + } + + 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, mockVirtCl) + + 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)) + }) + }) +}) 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..9e1d11fca5 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/watcher/usbdevice_watcher.go @@ -0,0 +1,76 @@ +/* +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/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..f6471902b9 100644 --- a/images/virtualization-artifact/pkg/controller/vm/vm_controller.go +++ b/images/virtualization-artifact/pkg/controller/vm/vm_controller.go @@ -36,6 +36,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/featuregates" "github.com/deckhouse/virtualization-controller/pkg/logger" vmmetrics "github.com/deckhouse/virtualization-controller/pkg/monitoring/metrics/virtualmachine" + "github.com/deckhouse/virtualization/api/client/kubeclient" "github.com/deckhouse/virtualization/api/core/v1alpha2" ) @@ -46,6 +47,7 @@ const ( func SetupController( ctx context.Context, mgr manager.Manager, + virtClient kubeclient.Client, log *log.Logger, dvcrSettings *dvcr.Settings, firmwareImage string, @@ -56,6 +58,7 @@ func SetupController( blockDeviceService := service.NewBlockDeviceService(client) vmClassService := service.NewVirtualMachineClassService(client) + migrateVolumesService := vmservice.NewMigrationVolumesService(client, internal.MakeKVVMFromVMSpec, 10*time.Second) handlers := []Handler{ @@ -65,6 +68,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(), 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 }) } diff --git a/templates/admission-policy.yaml b/templates/admission-policy.yaml index 597b40fb55..32c3eeae40 100644 --- a/templates/admission-policy.yaml +++ b/templates/admission-policy.yaml @@ -28,6 +28,7 @@ spec: - "pool.internal.virtualization.deckhouse.io" - "snapshot.internal.virtualization.deckhouse.io" - "migrations.internal.virtualization.deckhouse.io" + - "nodeusbdevice.internal.virtualization.deckhouse.io" apiVersions: ["*"] operations: - "CREATE" @@ -68,4 +69,56 @@ spec: matchResources: namespaceSelector: {} objectSelector: {} +--- +apiVersion: {{ $apiVersion }} +kind: ValidatingAdmissionPolicy +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: virtualization-usbdevice-access-policy +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: + - "virtualization.deckhouse.io" + apiVersions: ["v1alpha2"] + operations: + - "CREATE" + resources: + - "nodeusbdevices" + - apiGroups: + - "virtualization.deckhouse.io" + apiVersions: ["v1alpha2"] + operations: + - "CREATE" + - "DELETE" + resources: + - "usbdevices" + - apiGroups: + - "virtualization.deckhouse.io" + apiVersions: ["v1alpha2"] + operations: + - "UPDATE" + resources: + - "nodeusbdevices/status" + - "usbdevices/status" + validations: + - expression: | + request.userInfo.username.startsWith("system:serviceaccount:kube-system:") || + request.userInfo.username.startsWith("system:serviceaccount:d8-system:") || + request.userInfo.username.startsWith("system:serviceaccount:d8-virtualization:") + message: "NodeUSBDevice and USBDevice resources can only be created by ServiceAccounts with 'd8' prefix. USBDevice DELETE is allowed only for ServiceAccounts with 'd8' prefix. NodeUSBDevice DELETE is controlled via RBAC (allowed for cluster-admin role). Status updates are allowed only for ServiceAccounts with 'd8' prefix." +--- +apiVersion: {{ $apiVersion }} +kind: ValidatingAdmissionPolicyBinding +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: virtualization-usbdevice-access-policy-binding +spec: + policyName: virtualization-usbdevice-access-policy + validationActions: + - "Deny" + matchResources: + namespaceSelector: {} + objectSelector: {} {{- end }} diff --git a/templates/user-authz-cluster-roles.yaml b/templates/user-authz-cluster-roles.yaml index d574566298..9d181c2a48 100644 --- a/templates/user-authz-cluster-roles.yaml +++ b/templates/user-authz-cluster-roles.yaml @@ -152,3 +152,10 @@ rules: - deletecollection - patch - update +- apiGroups: + - virtualization.deckhouse.io + resources: + - nodeusbdevices + verbs: + - delete + - update 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: diff --git a/templates/virtualization-controller/rbac-for-us.yaml b/templates/virtualization-controller/rbac-for-us.yaml index 73acbabd5a..a26393280c 100644 --- a/templates/virtualization-controller/rbac-for-us.yaml +++ b/templates/virtualization-controller/rbac-for-us.yaml @@ -22,6 +22,7 @@ rules: - services - secrets - configmaps + - namespaces verbs: - get - create @@ -214,6 +215,8 @@ rules: - virtualdisksnapshots - virtualmachinesnapshots - virtualmachinerestores + - nodeusbdevices + - usbdevices verbs: - create - delete @@ -240,6 +243,8 @@ rules: - virtualdisksnapshots/finalizers - virtualmachinesnapshots/finalizers - virtualmachinerestores/finalizers + - nodeusbdevices/finalizers + - usbdevices/finalizers - virtualmachineipaddresses/status - virtualmachineipaddressleases/status - virtualmachinemacaddresses/status @@ -255,6 +260,8 @@ rules: - virtualdisksnapshots/status - virtualmachinesnapshots/status - virtualmachinerestores/status + - nodeusbdevices/status + - usbdevices/status verbs: - patch - update @@ -281,6 +288,19 @@ rules: - get - list - watch +- apiGroups: + - resource.k8s.io + resources: + - resourceslices + - resourceclaimtemplates + verbs: + - get + - list + - watch + - create + - update + - patch + - delete - apiGroups: - apiextensions.k8s.io resources: diff --git a/templates/virtualization-controller/validation-webhook.yaml b/templates/virtualization-controller/validation-webhook.yaml index 9ca31adaa8..5c20c11827 100644 --- a/templates/virtualization-controller/validation-webhook.yaml +++ b/templates/virtualization-controller/validation-webhook.yaml @@ -267,4 +267,21 @@ webhooks: - name: 'match-virtualization' expression: 'request.name == "virtualization"' {{- end }} + - name: "nodeusbdevice.virtualization-controller.validate.d8-virtualization" + rules: + - apiGroups: ["virtualization.deckhouse.io"] + apiVersions: ["v1alpha2"] + operations: ["UPDATE"] + resources: ["nodeusbdevices"] + scope: "Cluster" + clientConfig: + service: + namespace: d8-{{ .Chart.Name }} + name: virtualization-controller + path: /validate-virtualization-deckhouse-io-v1alpha2-nodeusbdevice + port: 443 + caBundle: | + {{ .Values.virtualization.internal.controller.cert.ca | b64enc }} + admissionReviewVersions: ["v1"] + sideEffects: None {{- end }}