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/client/kubeclient/client.go b/api/client/kubeclient/client.go index bb233e5eac..1d11d9cba4 100644 --- a/api/client/kubeclient/client.go +++ b/api/client/kubeclient/client.go @@ -63,6 +63,8 @@ type Client interface { VirtualMachineClasses() virtualizationv1alpha2.VirtualMachineClassInterface VirtualMachineMACAddresses(namespace string) virtualizationv1alpha2.VirtualMachineMACAddressInterface VirtualMachineMACAddressLeases() virtualizationv1alpha2.VirtualMachineMACAddressLeaseInterface + NodeUSBDevices(namespace string) virtualizationv1alpha2.NodeUSBDeviceInterface + USBDevices(namespace string) virtualizationv1alpha2.USBDeviceInterface } type client struct { kubernetes.Interface @@ -121,3 +123,11 @@ func (c client) VirtualMachineMACAddresses(namespace string) virtualizationv1alp func (c client) VirtualMachineMACAddressLeases() virtualizationv1alpha2.VirtualMachineMACAddressLeaseInterface { return c.virtClient.VirtualizationV1alpha2().VirtualMachineMACAddressLeases() } + +func (c client) NodeUSBDevices(namespace string) virtualizationv1alpha2.NodeUSBDeviceInterface { + return c.virtClient.VirtualizationV1alpha2().NodeUSBDevices(namespace) +} + +func (c client) USBDevices(namespace string) virtualizationv1alpha2.USBDeviceInterface { + return c.virtClient.VirtualizationV1alpha2().USBDevices(namespace) +} diff --git a/api/core/v1alpha2/events.go b/api/core/v1alpha2/events.go index f0c3dfc2dc..7262ad7390 100644 --- a/api/core/v1alpha2/events.go +++ b/api/core/v1alpha2/events.go @@ -170,4 +170,7 @@ const ( // ReasonVolumeMigrationCannotBeProcessed is event reason indicating that volume migration cannot be processed. ReasonVolumeMigrationCannotBeProcessed = "VolumeMigrationCannotBeProcessed" + + // ReasonDeleted is event reason that Object is deleted. + ReasonDeleted = "Deleted" ) 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..eb102a6ade --- /dev/null +++ b/api/core/v1alpha2/node_device_usb.go @@ -0,0 +1,106 @@ +/* +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 ( + NodeUSBDeviceKind = "NodeUSBDevice" +) + +// 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 { + // AssignedNamespace 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"` +} 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..c8a9a1c2cf --- /dev/null +++ b/api/core/v1alpha2/usbdevicecondition/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 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" + // DetachedForMigration signifies that device was detached for migration (e.g. live migration). + DetachedForMigration AttachedReason = "DetachedForMigration" +) + +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..d1aff42062 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,31 @@ 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"` +} + +// 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 8b44e8a60e..fa4b44209d 100644 --- a/api/core/v1alpha2/vmcondition/condition.go +++ b/api/core/v1alpha2/vmcondition/condition.go @@ -253,10 +253,12 @@ func (r MigratableReason) String() string { } const ( - ReasonMigratable MigratableReason = "VirtualMachineMigratable" - ReasonNonMigratable MigratableReason = "VirtualMachineNonMigratable" - ReasonDisksNotMigratable MigratableReason = "VirtualMachineDisksNotMigratable" - ReasonDisksShouldBeMigrating MigratableReason = "VirtualMachineDisksShouldBeMigrating" + ReasonMigratable MigratableReason = "VirtualMachineMigratable" + ReasonNonMigratable MigratableReason = "VirtualMachineNonMigratable" + ReasonDisksNotMigratable MigratableReason = "VirtualMachineDisksNotMigratable" + ReasonDisksShouldBeMigrating MigratableReason = "VirtualMachineDisksShouldBeMigrating" + ReasonHostDevicesNotMigratable MigratableReason = "VirtualMachineHostDevicesNotMigratable" + ReasonUSBShouldBeMigrating MigratableReason = "VirtualMachineUSBShouldBeMigrating" ) type MigratingReason string @@ -266,9 +268,9 @@ func (r MigratingReason) String() string { } const ( - ReasonMigratingPending MigratingReason = "Pending" - ReasonReadyToMigrate MigratingReason = "ReadyToMigrate" - ReasonMigratingInProgress MigratingReason = "InProgress" + ReasonMigratingPending MigratingReason = "Pending" + ReasonReadyToMigrate MigratingReason = "ReadyToMigrate" + ReasonMigratingInProgress MigratingReason = "InProgress" ) type MaintenanceReason string diff --git a/api/core/v1alpha2/zz_generated.deepcopy.go b/api/core/v1alpha2/zz_generated.deepcopy.go index c64f428875..09418f9713 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,143 @@ 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 + } + 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 @@ -3181,6 +3435,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 } @@ -3263,6 +3522,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..a7197f6e3c --- /dev/null +++ b/crds/doc-ru-nodeusbdevices.yaml @@ -0,0 +1,166 @@ +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 + 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 + observedGeneration: + description: Поколение ресурса, которое в последний раз обрабатывалось контроллером. + format: int64 + type: integer + 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..25b96fc28c --- /dev/null +++ b/crds/doc-ru-usbdevices.yaml @@ -0,0 +1,141 @@ +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 + 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/doc-ru-virtualmachines.yaml b/crds/doc-ru-virtualmachines.yaml index 1979a58ba6..55aa7e0e09 100644 --- a/crds/doc-ru-virtualmachines.yaml +++ b/crds/doc-ru-virtualmachines.yaml @@ -565,6 +565,15 @@ spec: type: string description: | Имя ресурса `VirtualMachineMACAddress`, связанного с сетевым интерфейсом. + usbDevices: + description: | + Список USB-устройств для подключения к виртуальной машине. + Устройства указываются по имени ресурса `USBDevice` в том же пространстве имен. + items: + properties: + name: + description: | + Имя ресурса `USBDevice` в том же пространстве имен. status: properties: blockDeviceRefs: @@ -738,6 +747,33 @@ spec: description: Накладные расходы на память во время выполнения. size: description: Текущий размер памяти виртуальной машины. + usbDevices: + description: | + Список USB-устройств, подключенных к виртуальной машине. + items: + properties: + name: + description: | + Имя ресурса `USBDevice`. + attached: + description: | + USB-устройство подключено к виртуальной машине. + ready: + description: | + USB-устройство готово к использованию. + address: + description: | + USB-адрес внутри виртуальной машины. + properties: + bus: + description: | + Номер USB-шины (всегда `0` для основного USB-контроллера). + port: + description: | + Номер USB-порта на выбранной шине. + hotplugged: + description: | + USB-устройство подключено через горячее подключение. networks: description: | Список сетевых интерфейсов, подключенных к ВМ. diff --git a/crds/nodeusbdevices.yaml b/crds/nodeusbdevices.yaml new file mode 100644 index 0000000000..93f96a6165 --- /dev/null +++ b/crds/nodeusbdevices.yaml @@ -0,0 +1,193 @@ +--- +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: |- + AssignedNamespace 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 + 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..93ab80394f --- /dev/null +++ b/crds/usbdevices.yaml @@ -0,0 +1,202 @@ +--- +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 + 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 c8234cf73f..885c5f23cb 100644 --- a/crds/virtualmachines.yaml +++ b/crds/virtualmachines.yaml @@ -1005,6 +1005,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: @@ -1324,6 +1340,46 @@ 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. networks: type: array description: | diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 49b6c6dd64..d2b3493a1c 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -3519,6 +3519,197 @@ spec: As a result, a VM named `clone-database-prod` and a disk named `clone-database-root-prod` will be created. +## USB Devices + +{{< alert level="warning">}} +USB device passthrough is only available in the **Enterprise Edition (EE)** of the **Deckhouse Virtualization Platform**. +{{< /alert >}} + +The virtualization module supports USB device passthrough to virtual machines using DRA (Dynamic Resource Allocation). This section describes how to use USB devices with virtual machines. + +### Overview + +The module provides two custom resources for USB device management: + +- **NodeUSBDevice** (cluster-scoped) — represents a USB device discovered on a specific node. Created automatically by the DRA system when a USB device is detected on a node. +- **USBDevice** (namespace-scoped) — represents a USB device available for attachment to virtual machines in a given namespace. + +### How It Works + +1. **Device Discovery**: The DRA (Dynamic Resource Allocation) driver automatically discovers USB devices on cluster nodes and creates `NodeUSBDevice` resources. + +2. **Namespace Assignment**: An administrator can assign a namespace to a `NodeUSBDevice` by setting the `.spec.assignedNamespace` field. This makes the device available in that namespace. + +3. **Device Exposure**: When a namespace is assigned, the controller automatically creates a corresponding `USBDevice` resource in that namespace. + +4. **VM Attachment**: The `USBDevice` can be attached to a virtual machine using `VirtualMachineBlockDeviceAttachment` resource. + +### NodeUSBDevice + +`NodeUSBDevice` is a cluster-scoped resource that represents a physical USB device on a node. It is created automatically by the DRA system. + +Example of viewing all discovered USB devices: + +```bash +d8 k get nodeusbdevice +``` + +Example output: + +```txt +NAME NODE READY ASSIGNED NAMESPACE AGE +usb-flash-drive node-1 True False 10m +logitech-webcam node-2 True True my-project 15m +``` + +#### NodeUSBDevice Conditions + +- **Ready**: Indicates whether the device is ready to use. + - `Ready` — device is ready to use. + - `NotReady` — device exists but is not ready. + - `NotFound` — device is absent on the host. + +- **Assigned**: Indicates whether a namespace is assigned for the device. + - `Assigned` — namespace is assigned and USBDevice resource is created. + - `Available` — no namespace is assigned for the device. + - `InProgress` — device connection to namespace is in progress. + +#### Assigning a Namespace + +To make a USB device available in a specific namespace, set the `.spec.assignedNamespace` field: + +```yaml +d8 k apply -f - <}} +The virtual machine must be running on the same node where the USB device is physically connected. +{{< /alert >}} + +### Viewing USB Device Details + +To view detailed information about a USB device: + +```bash +d8 k describe nodeusbdevice +``` + +Example output: + +```txt +Name: logitech-webcam +Namespace: +Labels: +Annotations: +API Version: virtualization.deckhouse.io/v1alpha2 +Kind: NodeUSBDevice +Metadata: + Creation Timestamp: 2024-01-15T10:30:00Z + Generation: 1 + UID: abc123-def456-ghi789 +Spec: + Assigned Namespace: my-project +Status: + Node Name: node-2 + Attributes: + Bus: 1 + Device Number: 2 + Manufacturer: Logitech + Name: Webcam C920 + Product: Webcam C920 + Product ID: 082d + Serial: ABC123456 + Vendor ID: 046d + Conditions: + Type: Ready + Status: True + Reason: Ready + Message: Device is ready to use + Type: Assigned + Status: True + Reason: Assigned + Message: Namespace is assigned for the device + Observed Generation: 1 +``` + +### Requirements and Limitations + +- The DRA driver must be installed on nodes where USB devices are to be discovered. +- USB devices can only be attached to virtual machines running on the same node where the device is physically connected. +- Hot-plug of USB devices is not supported — the VM must be stopped before detaching the device. +- USB device passthrough requires proper kernel modules on the node. + ## Data export You can export virtual machine disks and disk snapshots using the `d8` utility (version 0.20.7 and above). For this function to work, the module [`storage-volume-data-manager`](/modules/storage-volume-data-manager/) must be enabled. diff --git a/docs/USER_GUIDE.ru.md b/docs/USER_GUIDE.ru.md index 3e199f8479..10028a09e8 100644 --- a/docs/USER_GUIDE.ru.md +++ b/docs/USER_GUIDE.ru.md @@ -3555,6 +3555,197 @@ spec: В результате будет создана ВМ с именем `clone-database-prod` и диск с именем `clone-database-root-prod`. +## USB-устройства + +{{< alert level="warning">}} +Проброс USB-устройств доступен только в **Enterprise Edition (EE)** платформы **Deckhouse Virtualization Platform**. +{{< /alert >}} + +Модуль виртуализации поддерживает проброс USB-устройств в виртуальные машины с использованием DRA (Dynamic Resource Allocation). В этом разделе описано, как использовать USB-устройства с виртуальными машинами. + +### Обзор + +Модуль предоставляет два пользовательских ресурса для управления USB-устройствами: + +- **NodeUSBDevice** (кластерная область) — представляет USB-устройство, обнаруженное на конкретном узле. Создаётся автоматически системой DRA при обнаружении USB-устройства на узле. +- **USBDevice** (область имён) — представляет USB-устройство, доступное для подключения к виртуальным машинам в заданном пространстве имён. + +### Принцип работы + +1. **Обнаружение устройств**: Драйвер DRA автоматически обнаруживает USB-устройства на узлах кластера и создаёт ресурсы `NodeUSBDevice`. + +2. **Назначение пространства имён**: Администратор может назначить пространство имён для `NodeUSBDevice`, установив поле `.spec.assignedNamespace`. Это делает устройство доступным в этом пространстве имён. + +3. **Предоставление устройства**: После назначения пространства имён контроллер автоматически создаёт соответствующий ресурс `USBDevice` в этом пространстве имён. + +4. **Подключение к ВМ**: Устройство `USBDevice` может быть подключено к виртуальной машине с помощью ресурса `VirtualMachineBlockDeviceAttachment`. + +### NodeUSBDevice + +`NodeUSBDevice` — это ресурс с областью видимости кластера, представляющий физическое USB-устройство на узле. Он создаётся автоматически системой DRA. + +Пример просмотра всех обнаруженных USB-устройств: + +```bash +d8 k get nodeusbdevice +``` + +Пример вывода: + +```txt +NAME NODE READY ASSIGNED NAMESPACE AGE +usb-flash-drive node-1 True False 10m +logitech-webcam node-2 True True my-project 15m +``` + +#### Условия NodeUSBDevice + +- **Ready**: Указывает, готово ли устройство к использованию. + - `Ready` — устройство готово к использованию. + - `NotReady` — устройство существует, но не готово. + - `NotFound` — устройство отсутствует на хосте. + +- **Assigned**: Указывает, назначено ли пространство имён для устройства. + - `Assigned` — пространство имён назначено и ресурс USBDevice создан. + - `Available` — для устройства не назначено пространство имён. + - `InProgress` — подключение устройства к пространству имён выполняется. + +#### Назначение пространства имён + +Чтобы сделать USB-устройство доступным в определённом пространстве имён, установите поле `.spec.assignedNamespace`: + +```yaml +d8 k apply -f - <}} +Виртуальная машина должна запускаться на том же узле, к которому физически подключено USB-устройство. +{{< /alert >}} + +### Просмотр информации об USB-устройстве + +Для просмотра подробной информации об USB-устройстве: + +```bash +d8 k describe nodeusbdevice +``` + +Пример вывода: + +```txt +Name: logitech-webcam +Namespace: +Labels: +Annotations: +API Version: virtualization.deckhouse.io/v1alpha2 +Kind: NodeUSBDevice +Metadata: + Creation Timestamp: 2024-01-15T10:30:00Z + Generation: 1 + UID: abc123-def456-ghi789 +Spec: + Assigned Namespace: my-project +Status: + Node Name: node-2 + Attributes: + Bus: 1 + Device Number: 2 + Manufacturer: Logitech + Name: Webcam C920 + Product: Webcam C920 + Product ID: 082d + Serial: ABC123456 + Vendor ID: 046d + Conditions: + Type: Ready + Status: True + Reason: Ready + Message: Device is ready to use + Type: Assigned + Status: True + Reason: Assigned + Message: Namespace is assigned for the device + Observed Generation: 1 +``` + +### Требования и ограничения + +- Драйвер DRA должен быть установлен на узлах, где требуется обнаружение USB-устройств. +- USB-устройства могут быть подключены только к виртуальным машинам, работающим на том же узле, где устройство физически подключено. +- Горячее подключение USB-устройств не поддерживается — ВМ должна быть остановлена перед отключением устройства. +- Для проброса USB-устройств требуются соответствующие_kernel_модули на узле. + ## Экспорт данных Экспортировать диски и снимки дисков виртуальных машин можно с помощью утилиты `d8` (версия 0.20.7 и выше). Для работы этой функции должен быть включен модуль [`storage-volume-data-manager`](/modules/storage-volume-data-manager/). diff --git a/images/hooks/go.mod b/images/hooks/go.mod index e07f77c3ed..7effa92f62 100644 --- a/images/hooks/go.mod +++ b/images/hooks/go.mod @@ -33,7 +33,7 @@ require ( github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/ettle/strcase v0.2.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect @@ -83,8 +83,8 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/zmap/zcrypto v0.0.0-20230310154051-c8b263fd8300 // indirect github.com/zmap/zlint/v3 v3.5.0 // indirect - go.opentelemetry.io/otel v1.33.0 // indirect - go.opentelemetry.io/otel/trace v1.33.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect @@ -100,14 +100,14 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.33.3 // indirect - k8s.io/apiserver v0.33.3 // indirect + k8s.io/apiextensions-apiserver v0.34.2 // indirect + k8s.io/apiserver v0.34.2 // indirect k8s.io/client-go v0.34.2 // indirect - k8s.io/component-base v0.33.3 // indirect + k8s.io/component-base v0.34.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect kubevirt.io/api v1.6.2 // indirect - kubevirt.io/containerized-data-importer-api v1.60.3 // indirect + kubevirt.io/containerized-data-importer-api v1.63.1 // indirect kubevirt.io/controller-lifecycle-operator-sdk/api v0.0.0-20220329064328-f3cc58c6ed90 // indirect sigs.k8s.io/controller-runtime v0.21.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect diff --git a/images/hooks/go.sum b/images/hooks/go.sum index 1a7d56e5c6..858c546281 100644 --- a/images/hooks/go.sum +++ b/images/hooks/go.sum @@ -64,8 +64,8 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= @@ -340,8 +340,9 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/sylabs/oci-tools v0.7.0 h1:SIisUvcEL+Vpa9/kmQDy1W3AwV2XVGad83sgZmXLlb0= github.com/sylabs/oci-tools v0.7.0/go.mod h1:Ry6ngChflh20WPq6mLvCKSw2OTd9iDB5aR8OQzeq4hM= github.com/sylabs/sif/v2 v2.15.0 h1:Nv0tzksFnoQiQ2eUwpAis9nVqEu4c3RcNSxX8P3Cecw= @@ -377,10 +378,10 @@ github.com/zmap/zcrypto v0.0.0-20230310154051-c8b263fd8300/go.mod h1:mOd4yUMgn2f github.com/zmap/zlint/v3 v3.0.0/go.mod h1:paGwFySdHIBEMJ61YjoqT4h7Ge+fdYG4sUQhnTb1lJ8= github.com/zmap/zlint/v3 v3.5.0 h1:Eh2B5t6VKgVH0DFmTwOqE50POvyDhUaU9T2mJOe1vfQ= github.com/zmap/zlint/v3 v3.5.0/go.mod h1:JkNSrsDJ8F4VRtBZcYUQSvnWFL7utcjDIn+FE64mlBI= -go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= -go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= -go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= -go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -658,8 +659,8 @@ k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -kubevirt.io/containerized-data-importer-api v1.60.3 h1:kQEXi7scpzUa0RPf3/3MKk1Kmem0ZlqqiuK3kDF5L2I= -kubevirt.io/containerized-data-importer-api v1.60.3/go.mod h1:8mwrkZIdy8j/LmCyKt2wFXbiMavLUIqDaegaIF67CZs= +kubevirt.io/containerized-data-importer-api v1.63.1 h1:g2I9za0QEscRsQjOOK/MM0feywp1x9Gl8IyT6Egtg0g= +kubevirt.io/containerized-data-importer-api v1.63.1/go.mod h1:VGp35wxpLXU18b7cnEpmcThI3AjcZUSfg/Zfql44U4o= kubevirt.io/controller-lifecycle-operator-sdk/api v0.0.0-20220329064328-f3cc58c6ed90 h1:QMrd0nKP0BGbnxTqakhDZAUhGKxPiPiN5gSDqKUmGGc= kubevirt.io/controller-lifecycle-operator-sdk/api v0.0.0-20220329064328-f3cc58c6ed90/go.mod h1:018lASpFYBsYN6XwmA2TIrPCx6e0gviTd/ZNtSitKgc= sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= diff --git a/images/virtualization-artifact/cmd/virtualization-controller/main.go b/images/virtualization-artifact/cmd/virtualization-controller/main.go index bee96df3e9..5f536442f8 100644 --- a/images/virtualization-artifact/cmd/virtualization-controller/main.go +++ b/images/virtualization-artifact/cmd/virtualization-controller/main.go @@ -26,6 +26,7 @@ import ( vsv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" "github.com/spf13/pflag" + resourcev1 "k8s.io/api/resource/v1" extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apiruntime "k8s.io/apimachinery/pkg/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" @@ -47,6 +48,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" @@ -225,6 +229,7 @@ func main() { for _, f := range []func(*apiruntime.Scheme) error{ clientgoscheme.AddToScheme, extv1.AddToScheme, + resourcev1.AddToScheme, v1alpha2.AddToScheme, v1alpha3.AddToScheme, cdiv1beta1.AddToScheme, @@ -342,7 +347,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 +380,24 @@ func main() { os.Exit(1) } + nodeusbdeviceLogger := logger.NewControllerLogger(nodeusbdevice.ControllerName, logLevel, logOutput, logDebugVerbosity, logDebugControllerList) + if _, err = nodeusbdevice.NewController(ctx, mgr, nodeusbdeviceLogger); err != nil { + log.Error(err.Error()) + os.Exit(1) + } + + 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) + } + + 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/go.mod b/images/virtualization-artifact/go.mod index 04737b3b75..190302f7a4 100644 --- a/images/virtualization-artifact/go.mod +++ b/images/virtualization-artifact/go.mod @@ -12,7 +12,7 @@ require ( github.com/deckhouse/virtualization/api v0.0.0-00010101000000-000000000000 github.com/distribution/reference v0.6.0 github.com/docker/cli v28.2.2+incompatible - github.com/fsnotify/fsnotify v1.7.0 + github.com/fsnotify/fsnotify v1.9.0 github.com/go-logr/logr v1.4.3 github.com/google/go-containerregistry v0.20.3 github.com/google/go-containerregistry/pkg/authn/kubernetes v0.0.0-20251113035405-7471efdf9871 @@ -27,9 +27,9 @@ require ( k8s.io/api v0.34.2 k8s.io/apiextensions-apiserver v0.34.2 k8s.io/apimachinery v0.34.2 - k8s.io/apiserver v0.33.3 + k8s.io/apiserver v0.34.2 k8s.io/client-go v0.34.2 - k8s.io/component-base v0.33.3 + k8s.io/component-base v0.34.2 k8s.io/component-helpers v0.33.3 k8s.io/klog/v2 v2.130.1 k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b @@ -41,70 +41,58 @@ require ( ) require ( - cel.dev/expr v0.19.1 // indirect - github.com/DataDog/gostackparse v0.7.0 // indirect - github.com/antlr4-go/antlr/v4 v4.13.0 // indirect - github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect - github.com/docker/distribution v2.8.3+incompatible // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/google/btree v1.1.3 // indirect - github.com/klauspost/compress v1.18.0 // indirect - github.com/kylelemons/godebug v1.1.0 // indirect - github.com/matryer/moq v0.5.3 // indirect - github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/opencontainers/image-spec v1.1.0 // indirect - github.com/vbatts/tar-split v0.11.6 // indirect - github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.29.0 // indirect - gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect - sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect -) - -require ( + cel.dev/expr v0.24.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/DataDog/gostackparse v0.7.0 // indirect github.com/NYTimes/gziphandler v1.1.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/evanphx/json-patch v5.6.0+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gogo/protobuf v1.3.2 github.com/golang/protobuf v1.5.4 // indirect - github.com/google/cel-go v0.23.2 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/cel-go v0.26.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect + github.com/matryer/moq v0.5.3 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect github.com/openshift/custom-resource-status v1.1.2 // indirect github.com/pkg/errors v0.9.1 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -114,21 +102,27 @@ require ( github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.7 github.com/stoewer/go-strcase v1.3.0 // indirect - go.etcd.io/etcd/api/v3 v3.5.21 // indirect - go.etcd.io/etcd/client/pkg/v3 v3.5.21 // indirect - go.etcd.io/etcd/client/v3 v3.5.21 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 // indirect + github.com/vbatts/tar-split v0.11.6 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.etcd.io/etcd/api/v3 v3.6.4 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.6.4 // indirect + go.etcd.io/etcd/client/v3 v3.6.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect - go.opentelemetry.io/otel v1.33.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 // indirect - go.opentelemetry.io/otel/metric v1.33.0 // indirect - go.opentelemetry.io/otel/sdk v1.33.0 // indirect - go.opentelemetry.io/otel/trace v1.33.0 // indirect - go.opentelemetry.io/proto/otlp v1.4.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/sdk v1.34.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.opentelemetry.io/proto/otlp v1.5.0 // indirect go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.38.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/mod v0.29.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.18.0 // indirect @@ -138,30 +132,32 @@ require ( golang.org/x/time v0.11.0 // indirect golang.org/x/tools v0.38.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect - google.golang.org/grpc v1.68.1 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/grpc v1.72.1 // indirect google.golang.org/protobuf v1.36.6 + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.1 // indirect - k8s.io/kms v0.33.3 // indirect + k8s.io/kms v0.34.2 // indirect kubevirt.io/controller-lifecycle-operator-sdk/api v0.0.0-20220329064328-f3cc58c6ed90 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect ) replace github.com/deckhouse/virtualization/api => ./../../api replace ( - k8s.io/api => k8s.io/api v0.33.3 - k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.33.3 - k8s.io/apimachinery => k8s.io/apimachinery v0.33.3 - k8s.io/apiserver => k8s.io/apiserver v0.33.3 - k8s.io/client-go => k8s.io/client-go v0.33.3 - k8s.io/component-base => k8s.io/component-base v0.33.3 - k8s.io/kube-openapi => k8s.io/kube-openapi v0.0.0-20250701173324-9bd5c66d9911 + k8s.io/api => k8s.io/api v0.34.2 + k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.34.2 + k8s.io/apimachinery => k8s.io/apimachinery v0.34.2 + k8s.io/apiserver => k8s.io/apiserver v0.34.2 + k8s.io/client-go => k8s.io/client-go v0.34.2 + k8s.io/component-base => k8s.io/component-base v0.34.2 ) // CVE Replaces diff --git a/images/virtualization-artifact/go.sum b/images/virtualization-artifact/go.sum index a82e0e992b..b13976ee9a 100644 --- a/images/virtualization-artifact/go.sum +++ b/images/virtualization-artifact/go.sum @@ -1,5 +1,5 @@ -cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4= -cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= @@ -7,6 +7,7 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg6 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/DataDog/gostackparse v0.7.0 h1:i7dLkXHvYzHV308hnkvVGDL3BR4FWl7IsXNPz/IGQh4= github.com/DataDog/gostackparse v0.7.0/go.mod h1:lTfqcJKqS9KnXQGnyQMCugq3u1FP6UZMfWR0aitKFMM= +github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= @@ -14,6 +15,7 @@ github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdko github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -62,6 +64,7 @@ github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= github.com/emicklei/go-restful v2.15.0+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= @@ -77,11 +80,13 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/getkin/kin-openapi v0.76.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -96,10 +101,12 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg= github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= github.com/go-openapi/jsonreference v0.20.1/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= @@ -123,8 +130,8 @@ github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/K github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= -github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -143,9 +150,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/cel-go v0.23.2 h1:UdEe3CvQh3Nv+E/j9r1Y//WO0K0cSyD7/y0bzyLIMI4= -github.com/google/cel-go v0.23.2/go.mod h1:52Pb6QsDbC5kvgxvZhiL9QX1oZEkcUF/ZqaPx1J5Wwo= -github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= +github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -173,26 +179,29 @@ github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAx github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2cUuW7uA/OeU= github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97DwqyJO1AENw9kA= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw= -github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 h1:qnpSQwGEnkcRpTqNOIR6bJbR0gAorgP9CSALpRcKoAA= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0 h1:FbSCl+KggFl+Ocym490i/EyXF4lPgLoUtcSWquBM0Rs= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0/go.mod h1:qOchhhIlmRcqk/O9uCo/puJlyo07YINaIqdZfZG3Jkc= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20240312041847-bd984b5ce465/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= -github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= @@ -226,6 +235,7 @@ github.com/matryer/moq v0.5.3 h1:4femQCFmBUwFPYs8VfM5ID7AI67/DTEDRBbTtSWy7GU= github.com/matryer/moq v0.5.3/go.mod h1:8288Qkw7gMZhUP3cIN86GG7g5p9jRuZH8biXLW4RXvQ= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= @@ -236,6 +246,7 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= @@ -243,6 +254,7 @@ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= @@ -270,6 +282,7 @@ github.com/onsi/ginkgo/v2 v2.20.1/go.mod h1:lG9ey2Z29hR41WMVthyJBGUBcBhGOtoPF2VF github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0= github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= +github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= @@ -329,6 +342,7 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -365,45 +379,46 @@ github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 h1:S2dVYn90KE98chq github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= -go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= -go.etcd.io/etcd/api/v3 v3.5.21 h1:A6O2/JDb3tvHhiIz3xf9nJ7REHvtEFJJ3veW3FbCnS8= -go.etcd.io/etcd/api/v3 v3.5.21/go.mod h1:c3aH5wcvXv/9dqIw2Y810LDXJfhSYdHQ0vxmP3CCHVY= -go.etcd.io/etcd/client/pkg/v3 v3.5.21 h1:lPBu71Y7osQmzlflM9OfeIV2JlmpBjqBNlLtcoBqUTc= -go.etcd.io/etcd/client/pkg/v3 v3.5.21/go.mod h1:BgqT/IXPjK9NkeSDjbzwsHySX3yIle2+ndz28nVsjUs= -go.etcd.io/etcd/client/v2 v2.305.21 h1:eLiFfexc2mE+pTLz9WwnoEsX5JTTpLCYVivKkmVXIRA= -go.etcd.io/etcd/client/v2 v2.305.21/go.mod h1:OKkn4hlYNf43hpjEM3Ke3aRdUkhSl8xjKjSf8eCq2J8= -go.etcd.io/etcd/client/v3 v3.5.21 h1:T6b1Ow6fNjOLOtM0xSoKNQt1ASPCLWrF9XMHcH9pEyY= -go.etcd.io/etcd/client/v3 v3.5.21/go.mod h1:mFYy67IOqmbRf/kRUvsHixzo3iG+1OF2W2+jVIQRAnU= -go.etcd.io/etcd/pkg/v3 v3.5.21 h1:jUItxeKyrDuVuWhdh0HtjUANwyuzcb7/FAeUfABmQsk= -go.etcd.io/etcd/pkg/v3 v3.5.21/go.mod h1:wpZx8Egv1g4y+N7JAsqi2zoUiBIUWznLjqJbylDjWgU= -go.etcd.io/etcd/raft/v3 v3.5.21 h1:dOmE0mT55dIUsX77TKBLq+RgyumsQuYeiRQnW/ylugk= -go.etcd.io/etcd/raft/v3 v3.5.21/go.mod h1:fmcuY5R2SNkklU4+fKVBQi2biVp5vafMrWUEj4TJ4Cs= -go.etcd.io/etcd/server/v3 v3.5.21 h1:9w0/k12majtgarGmlMVuhwXRI2ob3/d1Ik3X5TKo0yU= -go.etcd.io/etcd/server/v3 v3.5.21/go.mod h1:G1mOzdwuzKT1VRL7SqRchli/qcFrtLBTAQ4lV20sXXo= +go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I= +go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM= +go.etcd.io/etcd/api/v3 v3.6.4 h1:7F6N7toCKcV72QmoUKa23yYLiiljMrT4xCeBL9BmXdo= +go.etcd.io/etcd/api/v3 v3.6.4/go.mod h1:eFhhvfR8Px1P6SEuLT600v+vrhdDTdcfMzmnxVXXSbk= +go.etcd.io/etcd/client/pkg/v3 v3.6.4 h1:9HBYrjppeOfFjBjaMTRxT3R7xT0GLK8EJMVC4xg6ok0= +go.etcd.io/etcd/client/pkg/v3 v3.6.4/go.mod h1:sbdzr2cl3HzVmxNw//PH7aLGVtY4QySjQFuaCgcRFAI= +go.etcd.io/etcd/client/v3 v3.6.4 h1:YOMrCfMhRzY8NgtzUsHl8hC2EBSnuqbR3dh84Uryl7A= +go.etcd.io/etcd/client/v3 v3.6.4/go.mod h1:jaNNHCyg2FdALyKWnd7hxZXZxZANb0+KGY+YQaEMISo= +go.etcd.io/etcd/pkg/v3 v3.6.4 h1:fy8bmXIec1Q35/jRZ0KOes8vuFxbvdN0aAFqmEfJZWA= +go.etcd.io/etcd/pkg/v3 v3.6.4/go.mod h1:kKcYWP8gHuBRcteyv6MXWSN0+bVMnfgqiHueIZnKMtE= +go.etcd.io/etcd/server/v3 v3.6.4 h1:LsCA7CzjVt+8WGrdsnh6RhC0XqCsLkBly3ve5rTxMAU= +go.etcd.io/etcd/server/v3 v3.6.4/go.mod h1:aYCL/h43yiONOv0QIR82kH/2xZ7m+IWYjzRmyQfnCAg= +go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ= +go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 h1:PS8wXpbyaDJQ2VDHHncMe9Vct0Zn1fEjpsjrLxGJoSc= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0/go.mod h1:HDBUsEjOuRC0EzKZ1bSaRGZWUBAzo+MhAcUUORSr4D0= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= -go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= -go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= -go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= -go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= -go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= -go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= -go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= -go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= -go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= -go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -483,6 +498,7 @@ golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -535,6 +551,7 @@ golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= @@ -567,6 +584,7 @@ golang.org/x/tools v0.0.0-20200505023115-26f46d2f7ef8/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.6-0.20210820212750-d4cc65f0b2ff/go.mod h1:YD9qOF0M9xpSpdWTBbzEl5e/RnCefISl8E5Noe10jFM= golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= @@ -606,17 +624,15 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 h1:KAeGQVN3M9nD0/bQXnr/ClcEMJ968gUXJQ9pwfSynuQ= -google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80/go.mod h1:cc8bqMqtv9gMOr0zHg2Vzff5ULhhL2IXP4sbcn32Dro= -google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= -google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= -google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= +google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= +google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -628,6 +644,7 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= @@ -649,6 +666,7 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -656,42 +674,46 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -k8s.io/api v0.33.3 h1:SRd5t//hhkI1buzxb288fy2xvjubstenEKL9K51KBI8= -k8s.io/api v0.33.3/go.mod h1:01Y/iLUjNBM3TAvypct7DIj0M0NIZc+PzAHCIo0CYGE= -k8s.io/apiextensions-apiserver v0.33.3 h1:qmOcAHN6DjfD0v9kxL5udB27SRP6SG/MTopmge3MwEs= -k8s.io/apiextensions-apiserver v0.33.3/go.mod h1:oROuctgo27mUsyp9+Obahos6CWcMISSAPzQ77CAQGz8= -k8s.io/apimachinery v0.33.3 h1:4ZSrmNa0c/ZpZJhAgRdcsFcZOw1PQU1bALVQ0B3I5LA= -k8s.io/apimachinery v0.33.3/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= -k8s.io/apiserver v0.33.3 h1:Wv0hGc+QFdMJB4ZSiHrCgN3zL3QRatu56+rpccKC3J4= -k8s.io/apiserver v0.33.3/go.mod h1:05632ifFEe6TxwjdAIrwINHWE2hLwyADFk5mBsQa15E= -k8s.io/client-go v0.33.3 h1:M5AfDnKfYmVJif92ngN532gFqakcGi6RvaOF16efrpA= -k8s.io/client-go v0.33.3/go.mod h1:luqKBQggEf3shbxHY4uVENAxrDISLOarxpTKMiUuujg= +k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY= +k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw= +k8s.io/apiextensions-apiserver v0.34.2 h1:WStKftnGeoKP4AZRz/BaAAEJvYp4mlZGN0UCv+uvsqo= +k8s.io/apiextensions-apiserver v0.34.2/go.mod h1:398CJrsgXF1wytdaanynDpJ67zG4Xq7yj91GrmYN2SE= +k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= +k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apiserver v0.34.2 h1:2/yu8suwkmES7IzwlehAovo8dDE07cFRC7KMDb1+MAE= +k8s.io/apiserver v0.34.2/go.mod h1:gqJQy2yDOB50R3JUReHSFr+cwJnL8G1dzTA0YLEqAPI= +k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M= +k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE= k8s.io/code-generator v0.23.3/go.mod h1:S0Q1JVA+kSzTI1oUvbKAxZY/DYbA/ZUb4Uknog12ETk= -k8s.io/component-base v0.33.3 h1:mlAuyJqyPlKZM7FyaoM/LcunZaaY353RXiOd2+B5tGA= -k8s.io/component-base v0.33.3/go.mod h1:ktBVsBzkI3imDuxYXmVxZ2zxJnYTZ4HAsVj9iF09qp4= +k8s.io/component-base v0.34.2 h1:HQRqK9x2sSAsd8+R4xxRirlTjowsg6fWCPwWYeSvogQ= +k8s.io/component-base v0.34.2/go.mod h1:9xw2FHJavUHBFpiGkZoKuYZ5pdtLKe97DEByaA+hHbM= k8s.io/component-helpers v0.33.3 h1:fjWVORSQfI0WKzPeIFSju/gMD9sybwXBJ7oPbqQu6eM= k8s.io/component-helpers v0.33.3/go.mod h1:7iwv+Y9Guw6X4RrnNQOyQlXcvJrVjPveHVqUA5dm31c= k8s.io/gengo v0.0.0-20210813121822-485abfe95c7c/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= k8s.io/gengo v0.0.0-20211129171323-c02415ce4185/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= -k8s.io/gengo/v2 v2.0.0-20240826214909-a7b603a56eb7/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= +k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= +k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= k8s.io/klog/v2 v2.30.0/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= k8s.io/klog/v2 v2.40.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kms v0.33.3 h1:7cQWC+GSH211NgY8LRKjBXNtkzra5SkpYzeZrOt5D+8= -k8s.io/kms v0.33.3/go.mod h1:C1I8mjFFBNzfUZXYt9FZVJ8MJl7ynFbGgZFbBzkBJ3E= -k8s.io/kube-openapi v0.0.0-20250701173324-9bd5c66d9911 h1:gAXU86Fmbr/ktY17lkHwSjw5aoThQvhnstGGIYKlKYc= -k8s.io/kube-openapi v0.0.0-20250701173324-9bd5c66d9911/go.mod h1:GLOk5B+hDbRROvt0X2+hqX64v/zO3vXN7J78OUmBSKw= +k8s.io/kms v0.34.2 h1:91rj4MDZLyIT9KxG8J5/CcMH666Z88CF/xJQeuPfJc8= +k8s.io/kms v0.34.2/go.mod h1:s1CFkLG7w9eaTYvctOxosx88fl4spqmixnNpys0JAtM= +k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65/go.mod h1:sX9MT8g7NVZM5lVL/j8QyCCJe8YSMW30QvGZWaCIDIk= +k8s.io/kube-openapi v0.0.0-20220124234850-424119656bbf/go.mod h1:sX9MT8g7NVZM5lVL/j8QyCCJe8YSMW30QvGZWaCIDIk= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20210802155522-efc7438f0176/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= kubevirt.io/containerized-data-importer-api v1.63.1 h1:g2I9za0QEscRsQjOOK/MM0feywp1x9Gl8IyT6Egtg0g= @@ -703,15 +725,16 @@ sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1 sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= -sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= sigs.k8s.io/structured-merge-diff/v4 v4.2.1/go.mod h1:j/nl6xW8vLS49O8YvXW1ocPhZawJtm+Yrr7PPRQ0Vg4= -sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= -sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= +sigs.k8s.io/structured-merge-diff/v6 v6.2.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/images/virtualization-artifact/pkg/common/annotations/annotations.go b/images/virtualization-artifact/pkg/common/annotations/annotations.go index 1d00d098ef..58399678fb 100644 --- a/images/virtualization-artifact/pkg/common/annotations/annotations.go +++ b/images/virtualization-artifact/pkg/common/annotations/annotations.go @@ -212,6 +212,16 @@ const ( // AnnDVCRGarbageCollectionResult is an annotation on deployment dvcr with last garbage collection result JSON. AnnDVCRGarbageCollectionResult = AnnAPIGroupV + "/dvcr-garbage-collection-result" + + // AnnUSBDeviceGroup is the annotation for device group in ResourceClaimTemplate. + AnnUSBDeviceGroup = "usb.virtualization.deckhouse.io/device-group" + // AnnUSBDeviceUser is the annotation for device user (owner) in ResourceClaimTemplate. + AnnUSBDeviceUser = "usb.virtualization.deckhouse.io/device-user" + + // DefaultUSBDeviceGroup is the default device group ID for USB devices. + DefaultUSBDeviceGroup = "107" + // DefaultUSBDeviceUser is the default device user ID for USB devices. + DefaultUSBDeviceUser = "107" ) // AddAnnotation adds an annotation to an object diff --git a/images/virtualization-artifact/pkg/controller/indexer/indexer.go b/images/virtualization-artifact/pkg/controller/indexer/indexer.go index 5f01c64b7e..3b94f62c2b 100644 --- a/images/virtualization-artifact/pkg/controller/indexer/indexer.go +++ b/images/virtualization-artifact/pkg/controller/indexer/indexer.go @@ -19,9 +19,11 @@ package indexer import ( "context" + resourcev1 "k8s.io/api/resource/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/manager" + "github.com/deckhouse/virtualization-controller/pkg/featuregates" "github.com/deckhouse/virtualization/api/core/v1alpha2" ) @@ -31,11 +33,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 +68,11 @@ const ( IndexFieldVMIPLeaseByVMIP = "spec.virtualMachineIPAddressRef" IndexFieldVMByProvisioningSecret = "spec.provisioning.secretRef" + + IndexFieldUSBDeviceByName = "metadata.name" + + IndexFieldResourceSliceByPoolName = "spec.pool.name" + IndexFieldResourceSliceByDriver = "spec.driver" ) var IndexGetters = []IndexGetter{ @@ -93,9 +101,25 @@ var IndexGetters = []IndexGetter{ IndexVMIPLeaseByVMIP, } +var IndexGettersUSB = []IndexGetter{ + IndexVMByUSBDevice, + IndexUSBDeviceByName, + IndexResourceSliceByPoolName, + IndexResourceSliceByDriver, +} + type IndexGetter func() (obj client.Object, field string, extractValue client.IndexerFunc) func IndexALL(ctx context.Context, mgr manager.Manager) error { + if featuregates.Default().Enabled(featuregates.USB) { + for _, fn := range IndexGettersUSB { + obj, field, indexFunc := fn() + if err := mgr.GetFieldIndexer().IndexField(ctx, obj, field, indexFunc); err != nil { + return err + } + } + } + for _, fn := range IndexGetters { obj, field, indexFunc := fn() if err := mgr.GetFieldIndexer().IndexField(ctx, obj, field, indexFunc); err != nil { @@ -191,3 +215,56 @@ 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 IndexUSBDeviceByName() (obj client.Object, field string, extractValue client.IndexerFunc) { + return &v1alpha2.USBDevice{}, IndexFieldUSBDeviceByName, func(object client.Object) []string { + usbDevice, ok := object.(*v1alpha2.USBDevice) + if !ok || usbDevice == nil { + return nil + } + return []string{usbDevice.Name} + } +} + +func IndexResourceSliceByPoolName() (obj client.Object, field string, extractValue client.IndexerFunc) { + return &resourcev1.ResourceSlice{}, IndexFieldResourceSliceByPoolName, func(object client.Object) []string { + resourceSlice, ok := object.(*resourcev1.ResourceSlice) + if !ok || resourceSlice == nil || resourceSlice.Spec.Pool.Name == "" { + return nil + } + + return []string{resourceSlice.Spec.Pool.Name} + } +} + +func IndexResourceSliceByDriver() (obj client.Object, field string, extractValue client.IndexerFunc) { + return &resourcev1.ResourceSlice{}, IndexFieldResourceSliceByDriver, func(object client.Object) []string { + resourceSlice, ok := object.(*resourcev1.ResourceSlice) + if !ok || resourceSlice == nil || resourceSlice.Spec.Driver == "" { + return nil + } + + return []string{resourceSlice.Spec.Driver} + } +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/assigned.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/assigned.go new file mode 100644 index 0000000000..a77da0b58f --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/assigned.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 handler + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" + "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/indexer" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +const ( + nameAssignedHandler = "AssignedHandler" +) + +func NewAssignedHandler(client client.Client) *AssignedHandler { + return &AssignedHandler{ + client: client, + } +} + +type AssignedHandler struct { + client client.Client +} + +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() + + if !current.GetDeletionTimestamp().IsZero() { + return reconcile.Result{}, nil + } + + assignedNamespace := current.Spec.AssignedNamespace + deviceAbsentOnHost := isDeviceAbsentOnHost(changed.Status.Conditions) + + switch { + case deviceAbsentOnHost: + if err := h.removeOrphanedUSBDevices(ctx, current.Name, assignedNamespace, true); err != nil { + return reconcile.Result{}, err + } + setAssignedInProgressCondition(current, &changed.Status.Conditions, "Device is absent on the host, USBDevice is removed.") + + case assignedNamespace == "": + if err := h.removeOrphanedUSBDevices(ctx, current.Name, assignedNamespace, false); err != nil { + return reconcile.Result{}, err + } + setAssignedAvailableCondition(current, &changed.Status.Conditions, "No namespace is assigned for the device.") + + default: + if err := h.removeOrphanedUSBDevices(ctx, current.Name, assignedNamespace, false); err != nil { + return reconcile.Result{}, err + } + + exists, err := h.namespaceExists(ctx, assignedNamespace) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to check namespace %s: %w", assignedNamespace, err) + } + if !exists { + setAssignedAvailableCondition(current, &changed.Status.Conditions, fmt.Sprintf("Namespace %s does not exist.", assignedNamespace)) + return reconcile.Result{}, nil + } + + if _, err := h.ensureUSBDevice(ctx, current, assignedNamespace); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to ensure USBDevice: %w", err) + } + + setAssignedReadyCondition(current, &changed.Status.Conditions, assignedNamespace) + } + + return reconcile.Result{}, nil +} + +func (h *AssignedHandler) namespaceExists(ctx context.Context, name string) (bool, error) { + var namespace corev1.Namespace + err := h.client.Get(ctx, types.NamespacedName{Name: name}, &namespace) + if err == nil { + return true, nil + } + if errors.IsNotFound(err) { + return false, nil + } + + return false, err +} + +func (h *AssignedHandler) removeOrphanedUSBDevices(ctx context.Context, deviceName, assignedNamespace string, deviceAbsentOnHost bool) error { + var usbDeviceList v1alpha2.USBDeviceList + if err := h.client.List(ctx, &usbDeviceList, client.MatchingFields{indexer.IndexFieldUSBDeviceByName: deviceName}); err != nil { + return fmt.Errorf("failed to list USBDevices: %w", err) + } + + for _, usbDevice := range usbDeviceList.Items { + if deviceAbsentOnHost || assignedNamespace == "" || usbDevice.Namespace != assignedNamespace { + if err := h.deleteUSBDevice(ctx, usbDevice.Namespace, usbDevice.Name); err != nil { + return fmt.Errorf("failed to delete USBDevice %s/%s: %w", usbDevice.Namespace, usbDevice.Name, err) + } + } + } + + return 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 { + if !equality.Semantic.DeepEqual(usbDevice.Status.Attributes, nodeUSBDevice.Status.Attributes) || usbDevice.Status.NodeName != nodeUSBDevice.Status.NodeName { + usbDevice.Status.Attributes = nodeUSBDevice.Status.Attributes + usbDevice.Status.NodeName = nodeUSBDevice.Status.NodeName + if err := h.client.Status().Update(ctx, usbDevice); err != nil { + return nil, fmt.Errorf("failed to update USBDevice status: %w", err) + } + } + return usbDevice, nil + } + + if !errors.IsNotFound(err) { + return nil, fmt.Errorf("failed to get USBDevice: %w", err) + } + + // USBDevice doesn't exist - create it + usbDevice = &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: nodeUSBDevice.Name, + Namespace: namespace, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: v1alpha2.SchemeGroupVersion.String(), + Kind: v1alpha2.NodeUSBDeviceKind, + Name: nodeUSBDevice.Name, + UID: nodeUSBDevice.UID, + Controller: ptr.To(true), + }, + }, + }, + Status: v1alpha2.USBDeviceStatus{ + Attributes: nodeUSBDevice.Status.Attributes, + NodeName: nodeUSBDevice.Status.NodeName, + }, + } + + if err := h.client.Create(ctx, usbDevice); err != nil { + if errors.IsAlreadyExists(err) { + if err := h.client.Get(ctx, key, usbDevice); err != nil { + return nil, fmt.Errorf("failed to get existing USBDevice: %w", err) + } + if !equality.Semantic.DeepEqual(usbDevice.Status.Attributes, nodeUSBDevice.Status.Attributes) || usbDevice.Status.NodeName != nodeUSBDevice.Status.NodeName { + 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 + } + return nil, fmt.Errorf("failed to create USBDevice: %w", err) + } + + return usbDevice, nil +} + +func (h *AssignedHandler) deleteUSBDevice(ctx context.Context, namespace, name string) error { + usbDevice := &v1alpha2.USBDevice{} + key := types.NamespacedName{ + Namespace: namespace, + Name: name, + } + + err := h.client.Get(ctx, key, usbDevice) + if err != nil { + if errors.IsNotFound(err) { + // USBDevice doesn't exist - nothing to delete + return nil + } + return fmt.Errorf("failed to get USBDevice: %w", err) + } + + if err := h.client.Delete(ctx, usbDevice); err != nil { + return fmt.Errorf("failed to delete USBDevice: %w", err) + } + + return nil +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/assigned_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/assigned_test.go new file mode 100644 index 0000000000..996295d8d2 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/assigned_test.go @@ -0,0 +1,126 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handler + +import ( + "context" + "log/slog" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apiruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "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 + + BeforeEach(func() { + ctx = logger.ToContext(context.TODO(), slog.Default()) + }) + + DescribeTable("Handle", + func(assignedNamespace string, namespaceExists, readyNotFound, seedUSB bool, expectReason string, expectUSBInAssignedNS bool) { + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + + node := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1", UID: types.UID("node-usb-uid")}, + Spec: v1alpha2.NodeUSBDeviceSpec{AssignedNamespace: assignedNamespace}, + Status: v1alpha2.NodeUSBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{VendorID: "1234", ProductID: "5678"}, + NodeName: "node-1", + }, + } + if readyNotFound { + node.Status.Conditions = []metav1.Condition{{ + Type: string(nodeusbdevicecondition.ReadyType), + Status: metav1.ConditionFalse, + Reason: string(nodeusbdevicecondition.NotFound), + }} + } + + objects := []client.Object{node} + if assignedNamespace != "" && namespaceExists { + objects = append(objects, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: assignedNamespace}}) + } + if seedUSB { + seedNS := assignedNamespace + if seedNS == "" { + seedNS = "stale-namespace" + } + objects = append(objects, &v1alpha2.USBDevice{ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1", Namespace: seedNS}}) + } + + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithStatusSubresource(&v1alpha2.USBDevice{}). + WithIndex(&v1alpha2.USBDevice{}, indexer.IndexFieldUSBDeviceByName, func(object client.Object) []string { + usbDevice, ok := object.(*v1alpha2.USBDevice) + if !ok || usbDevice == nil { + return nil + } + return []string{usbDevice.Name} + }). + Build() + res := reconciler.NewResource( + types.NamespacedName{Name: node.Name}, + cl, + func() *v1alpha2.NodeUSBDevice { return &v1alpha2.NodeUSBDevice{} }, + func(obj *v1alpha2.NodeUSBDevice) v1alpha2.NodeUSBDeviceStatus { return obj.Status }, + ) + Expect(res.Fetch(ctx)).To(Succeed()) + + h := NewAssignedHandler(cl) + st := state.New(cl, res) + _, err := h.Handle(ctx, st) + Expect(err).NotTo(HaveOccurred()) + + assigned := meta.FindStatusCondition(res.Changed().Status.Conditions, string(nodeusbdevicecondition.AssignedType)) + Expect(assigned).NotTo(BeNil()) + Expect(assigned.Reason).To(Equal(expectReason)) + + if assignedNamespace != "" { + usb := &v1alpha2.USBDevice{} + err = cl.Get(ctx, types.NamespacedName{Name: "usb-device-1", Namespace: assignedNamespace}, usb) + if expectUSBInAssignedNS { + Expect(err).NotTo(HaveOccurred()) + } else { + Expect(err).To(HaveOccurred()) + } + } + }, + Entry("assigned namespace exists creates/keeps USBDevice", "test-namespace", true, false, false, string(nodeusbdevicecondition.Assigned), true), + Entry("assigned namespace missing marks available", "missing-namespace", false, false, false, string(nodeusbdevicecondition.Available), false), + Entry("device absent on host removes USBDevice", "test-namespace", true, true, true, string(nodeusbdevicecondition.InProgress), false), + Entry("unassigned removes stale USBDevice", "", false, false, true, string(nodeusbdevicecondition.Available), false), + ) +}) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/conditions.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/conditions.go new file mode 100644 index 0000000000..b164379c6d --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/conditions.go @@ -0,0 +1,66 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handler + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" +) + +func setAssignedAvailableCondition(nodeUSBDevice *v1alpha2.NodeUSBDevice, target *[]metav1.Condition, message string) { + setAssignedCondition(nodeUSBDevice, target, metav1.ConditionFalse, nodeusbdevicecondition.Available, message) +} + +func setAssignedInProgressCondition(nodeUSBDevice *v1alpha2.NodeUSBDevice, target *[]metav1.Condition, message string) { + setAssignedCondition(nodeUSBDevice, target, metav1.ConditionFalse, nodeusbdevicecondition.InProgress, message) +} + +func setAssignedReadyCondition(nodeUSBDevice *v1alpha2.NodeUSBDevice, target *[]metav1.Condition, assignedNamespace string) { + message := fmt.Sprintf("The device is assigned to namespace %q, and the corresponding USBDevice has been created.", assignedNamespace) + setAssignedCondition(nodeUSBDevice, target, metav1.ConditionTrue, nodeusbdevicecondition.Assigned, message) +} + +func setAssignedCondition( + nodeUSBDevice *v1alpha2.NodeUSBDevice, + target *[]metav1.Condition, + status metav1.ConditionStatus, + reason nodeusbdevicecondition.AssignedReason, + message string, +) { + cb := conditions.NewConditionBuilder(nodeusbdevicecondition.AssignedType). + Generation(nodeUSBDevice.GetGeneration()). + Status(status). + Reason(reason). + Message(message) + + conditions.SetCondition(cb, target) +} + +func isDeviceAbsentOnHost(conditions []metav1.Condition) bool { + readyCondition := meta.FindStatusCondition(conditions, string(nodeusbdevicecondition.ReadyType)) + if readyCondition == nil { + return false + } + + return readyCondition.Reason == string(nodeusbdevicecondition.NotFound) +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/deletion.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/deletion.go new file mode 100644 index 0000000000..e8138f422a --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/deletion.go @@ -0,0 +1,94 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handler + +import ( + "context" + "fmt" + + 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/api/core/v1alpha2" +) + +const ( + nameDeletionHandler = "DeletionHandler" +) + +func NewDeletionHandler(client client.Client) *DeletionHandler { + return &DeletionHandler{ + client: client, + } +} + +type DeletionHandler struct { + client client.Client +} + +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() + + switch { + case current.GetDeletionTimestamp().IsZero(): + controllerutil.AddFinalizer(changed, v1alpha2.FinalizerNodeUSBDeviceCleanup) + return reconcile.Result{}, nil + + default: + if err := h.cleanupOwnedUSBDevices(ctx, current); err != nil { + return reconcile.Result{}, err + } + controllerutil.RemoveFinalizer(changed, v1alpha2.FinalizerNodeUSBDeviceCleanup) + } + + return reconcile.Result{}, nil +} + +func (h *DeletionHandler) cleanupOwnedUSBDevices(ctx context.Context, owner *v1alpha2.NodeUSBDevice) error { + var usbDeviceList v1alpha2.USBDeviceList + if err := h.client.List(ctx, &usbDeviceList); err != nil { + return fmt.Errorf("failed to list USBDevices: %w", err) + } + + for i := range usbDeviceList.Items { + usbDevice := &usbDeviceList.Items[i] + if !metav1.IsControlledBy(usbDevice, owner) { + continue + } + + if err := h.client.Delete(ctx, usbDevice); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to delete USBDevice %s/%s: %w", usbDevice.Namespace, usbDevice.Name, err) + } + } + + return nil +} + +func (h *DeletionHandler) Name() string { + return nameDeletionHandler +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/deletion_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/deletion_test.go new file mode 100644 index 0000000000..ec39f47379 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/deletion_test.go @@ -0,0 +1,109 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handler + +import ( + "context" + "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" + + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +var _ = Describe("DeletionHandler", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = logger.ToContext(context.TODO(), slog.Default()) + }) + + DescribeTable("Handle", + func(deleting, withOwnedUSB bool, usbNamespace string, expectFinalizerPresent, expectOwnedUSBDeleted bool) { + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + node := &v1alpha2.NodeUSBDevice{ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1", UID: "node-usb-uid"}} + if deleting { + now := metav1.Now() + node.DeletionTimestamp = &now + node.Finalizers = []string{v1alpha2.FinalizerNodeUSBDeviceCleanup} + } + + objects := []client.Object{node} + if withOwnedUSB { + objects = append(objects, &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-device-1", + Namespace: usbNamespace, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: v1alpha2.SchemeGroupVersion.String(), + Kind: v1alpha2.NodeUSBDeviceKind, + Name: node.Name, + UID: node.UID, + Controller: ptr.To(true), + }}, + }, + }) + } + + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() + res := reconciler.NewResource( + types.NamespacedName{Name: node.Name}, + cl, + func() *v1alpha2.NodeUSBDevice { return &v1alpha2.NodeUSBDevice{} }, + func(obj *v1alpha2.NodeUSBDevice) v1alpha2.NodeUSBDeviceStatus { return obj.Status }, + ) + Expect(res.Fetch(ctx)).To(Succeed()) + + h := NewDeletionHandler(cl) + st := state.New(cl, res) + _, err := h.Handle(ctx, st) + Expect(err).NotTo(HaveOccurred()) + + if expectFinalizerPresent { + Expect(res.Changed().GetFinalizers()).To(ContainElement(v1alpha2.FinalizerNodeUSBDeviceCleanup)) + } else { + Expect(res.Changed().GetFinalizers()).NotTo(ContainElement(v1alpha2.FinalizerNodeUSBDeviceCleanup)) + } + + if withOwnedUSB { + usb := &v1alpha2.USBDevice{} + err = cl.Get(ctx, types.NamespacedName{Name: "usb-device-1", Namespace: usbNamespace}, usb) + if expectOwnedUSBDeleted { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).NotTo(HaveOccurred()) + } + } + }, + Entry("not deleting adds finalizer", false, false, "", true, false), + Entry("deleting removes finalizer and owned USB", true, true, "test-namespace", false, true), + Entry("deleting removes finalizer even without owned USB", true, false, "", false, false), + Entry("deleting removes owned USB in different namespace", true, true, "previous-namespace", false, true), + ) +}) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/ready.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/ready.go new file mode 100644 index 0000000000..872673360f --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/ready.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 handler + +import ( + "context" + "fmt" + + resourcev1 "k8s.io/api/resource/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" +) + +const ( + nameReadyHandler = "ReadyHandler" + draDriverName = "virtualization-usb" +) + +func NewReadyHandler(client client.Client) *ReadyHandler { + return &ReadyHandler{client: client} +} + +type ReadyHandler struct { + client client.Client +} + +func (h *ReadyHandler) Name() string { + return nameReadyHandler +} + +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() + + readyReason := nodeusbdevicecondition.NotFound + readyStatus := metav1.ConditionFalse + readyMessage := "Device is absent on the host." + + found, err := h.deviceExistsOnHost(ctx, current) + if err != nil { + return reconcile.Result{}, err + } + if found { + readyReason = nodeusbdevicecondition.Ready + readyStatus = metav1.ConditionTrue + readyMessage = "Device is ready to use." + } + + cb := conditions.NewConditionBuilder(nodeusbdevicecondition.ReadyType). + Generation(current.GetGeneration()). + Status(readyStatus). + Reason(readyReason). + Message(readyMessage) + + conditions.SetCondition(cb, &changed.Status.Conditions) + + return reconcile.Result{}, nil +} + +func (h *ReadyHandler) deviceExistsOnHost(ctx context.Context, nodeUSBDevice *v1alpha2.NodeUSBDevice) (bool, error) { + nodeName := nodeUSBDevice.Status.NodeName + if nodeName == "" { + return false, nil + } + + var slices resourcev1.ResourceSliceList + if err := h.client.List(ctx, &slices, client.MatchingFields{ + indexer.IndexFieldResourceSliceByPoolName: nodeName, + indexer.IndexFieldResourceSliceByDriver: draDriverName, + }); err != nil { + return false, fmt.Errorf("failed to list ResourceSlices: %w", err) + } + + deviceName := nodeUSBDevice.Status.Attributes.Name + if deviceName == "" { + deviceName = nodeUSBDevice.GetName() + } + + for _, slice := range slices.Items { + for _, dev := range slice.Spec.Devices { + if dev.Name == deviceName { + return true, nil + } + } + } + + return false, nil +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/ready_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/ready_test.go new file mode 100644 index 0000000000..f5c792cac6 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/ready_test.go @@ -0,0 +1,106 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handler + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + resourcev1 "k8s.io/api/resource/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apiruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/nodeusbdevicecondition" +) + +var _ = Describe("ReadyHandler", func() { + DescribeTable("Handle", + func(slices []client.Object, expectedReason string, expectedStatus metav1.ConditionStatus) { + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(resourcev1.AddToScheme(scheme)).To(Succeed()) + + node := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1", Generation: 1}, + Status: v1alpha2.NodeUSBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{Name: "usb-device-1"}, + NodeName: "node-a", + }, + } + + objects := append([]client.Object{node}, slices...) + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithIndex(&resourcev1.ResourceSlice{}, indexer.IndexFieldResourceSliceByPoolName, func(object client.Object) []string { + resourceSlice, ok := object.(*resourcev1.ResourceSlice) + if !ok || resourceSlice == nil || resourceSlice.Spec.Pool.Name == "" { + return nil + } + + return []string{resourceSlice.Spec.Pool.Name} + }). + WithIndex(&resourcev1.ResourceSlice{}, indexer.IndexFieldResourceSliceByDriver, func(object client.Object) []string { + resourceSlice, ok := object.(*resourcev1.ResourceSlice) + if !ok || resourceSlice == nil || resourceSlice.Spec.Driver == "" { + return nil + } + + return []string{resourceSlice.Spec.Driver} + }). + Build() + + res := reconciler.NewResource( + types.NamespacedName{Name: node.Name}, + cl, + func() *v1alpha2.NodeUSBDevice { return &v1alpha2.NodeUSBDevice{} }, + func(obj *v1alpha2.NodeUSBDevice) v1alpha2.NodeUSBDeviceStatus { return obj.Status }, + ) + Expect(res.Fetch(context.Background())).To(Succeed()) + + h := NewReadyHandler(cl) + st := state.New(cl, res) + _, err := h.Handle(context.Background(), st) + Expect(err).NotTo(HaveOccurred()) + + readyCondition := meta.FindStatusCondition(res.Changed().Status.Conditions, string(nodeusbdevicecondition.ReadyType)) + Expect(readyCondition).NotTo(BeNil()) + Expect(readyCondition.Reason).To(Equal(expectedReason)) + Expect(readyCondition.Status).To(Equal(expectedStatus)) + }, + Entry("device found", []client.Object{&resourcev1.ResourceSlice{ + ObjectMeta: metav1.ObjectMeta{Name: "slice-1"}, + Spec: resourcev1.ResourceSliceSpec{ + Driver: "virtualization-usb", + Pool: resourcev1.ResourcePool{Name: "node-a"}, + Devices: []resourcev1.Device{{ + Name: "usb-device-1", + }}, + }, + }}, string(nodeusbdevicecondition.Ready), metav1.ConditionTrue), + Entry("device not found", nil, string(nodeusbdevicecondition.NotFound), metav1.ConditionFalse), + ) +}) diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/suite_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/suite_test.go new file mode 100644 index 0000000000..dbb86b0785 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/handler/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 handler + +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/state/state.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go new file mode 100644 index 0000000000..094dd0f672 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/state/state.go @@ -0,0 +1,40 @@ +/* +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 ( + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type NodeUSBDeviceState interface { + NodeUSBDevice() *reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus] +} + +func New(_ client.Client, nodeUSBDevice *reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus]) NodeUSBDeviceState { + return &nodeUSBDeviceState{nodeUSBDevice: nodeUSBDevice} +} + +type nodeUSBDeviceState struct { + nodeUSBDevice *reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus] +} + +func (s *nodeUSBDeviceState) NodeUSBDevice() *reconciler.Resource[*v1alpha2.NodeUSBDevice, v1alpha2.NodeUSBDeviceStatus] { + return s.nodeUSBDevice +} 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..e715ec6bf3 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher.go @@ -0,0 +1,132 @@ +/* +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" + + resourcev1 "k8s.io/api/resource/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/deckhouse/pkg/log" +) + +const ( + draDriverName = "virtualization-usb" + usbDeviceNamePrefix = "usb-" +) + +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(), + &resourcev1.ResourceSlice{}, + handler.TypedEnqueueRequestsFromMapFunc(func(_ context.Context, resourceSlice *resourcev1.ResourceSlice) []reconcile.Request { + return requestsByResourceSlice(resourceSlice) + }), + predicate.TypedFuncs[*resourcev1.ResourceSlice]{ + CreateFunc: func(e event.TypedCreateEvent[*resourcev1.ResourceSlice]) bool { + return e.Object != nil && e.Object.Spec.Driver == draDriverName + }, + DeleteFunc: func(e event.TypedDeleteEvent[*resourcev1.ResourceSlice]) bool { + return e.Object != nil && e.Object.Spec.Driver == draDriverName + }, + UpdateFunc: func(e event.TypedUpdateEvent[*resourcev1.ResourceSlice]) bool { + if e.ObjectOld == nil || e.ObjectNew == nil { + return false + } + + return e.ObjectOld.Spec.Driver == draDriverName || e.ObjectNew.Spec.Driver == draDriverName + }, + }, + ), + ) +} + +func requestsByResourceSlice(resourceSlice *resourcev1.ResourceSlice) []reconcile.Request { + if resourceSlice == nil || resourceSlice.Spec.Driver != draDriverName { + log.Error("resource slice is not a DRA slice") + return nil + } + + requests := make([]reconcile.Request, 0, len(resourceSlice.Spec.Devices)) + seen := make(map[types.NamespacedName]struct{}, len(resourceSlice.Spec.Devices)) + + for _, nodeUSBDeviceName := range nodeUSBDeviceNamesFromSlice(resourceSlice) { + key := types.NamespacedName{Name: nodeUSBDeviceName} + if _, exists := seen[key]; exists { + continue + } + + seen[key] = struct{}{} + requests = append(requests, reconcile.Request{NamespacedName: key}) + } + + return requests +} + +func nodeUSBDeviceNamesFromSlice(resourceSlice *resourcev1.ResourceSlice) []string { + result := make([]string, 0, len(resourceSlice.Spec.Devices)) + seen := make(map[string]struct{}, len(resourceSlice.Spec.Devices)) + + for _, device := range resourceSlice.Spec.Devices { + nodeUSBDeviceName, ok := nodeUSBDeviceName(device) + if !ok { + continue + } + + if _, exists := seen[nodeUSBDeviceName]; exists { + continue + } + + seen[nodeUSBDeviceName] = struct{}{} + result = append(result, nodeUSBDeviceName) + } + + return result +} + +func nodeUSBDeviceName(device resourcev1.Device) (string, bool) { + if !strings.HasPrefix(device.Name, usbDeviceNamePrefix) { + return "", false + } + + name := device.Name + if attr, ok := device.Attributes["name"]; ok && attr.StringValue != nil && *attr.StringValue != "" { + name = *attr.StringValue + } + + sanitizedName := strings.ToLower(strings.ReplaceAll(name, ".", "-")) + if sanitizedName == "" { + return "", false + } + + return sanitizedName, true +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher_test.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher_test.go new file mode 100644 index 0000000000..355c02e494 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/internal/watcher/resourceslice_watcher_test.go @@ -0,0 +1,72 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package watcher + +import ( + "testing" + + resourcev1 "k8s.io/api/resource/v1" + "k8s.io/utils/ptr" +) + +func TestNodeUSBDeviceNamesFromSlice(t *testing.T) { + resourceSlice := &resourcev1.ResourceSlice{ + Spec: resourcev1.ResourceSliceSpec{ + Devices: []resourcev1.Device{ + {Name: "gpu-1"}, + {Name: "usb-1"}, + { + Name: "usb-2", + Attributes: map[resourcev1.QualifiedName]resourcev1.DeviceAttribute{ + "name": {StringValue: ptr.To("USB.Device.2")}, + }, + }, + { + Name: "usb-duplicate", + Attributes: map[resourcev1.QualifiedName]resourcev1.DeviceAttribute{ + "name": {StringValue: ptr.To("USB.Device.2")}, + }, + }, + }, + }, + } + + actual := nodeUSBDeviceNamesFromSlice(resourceSlice) + if len(actual) != 2 { + t.Fatalf("expected 2 device names, got %d", len(actual)) + } + + if actual[0] != "usb-1" { + t.Fatalf("unexpected first name %q", actual[0]) + } + + if actual[1] != "usb-device-2" { + t.Fatalf("unexpected second name %q", actual[1]) + } +} + +func TestNodeUSBDeviceName(t *testing.T) { + name, ok := nodeUSBDeviceName(resourcev1.Device{Name: "gpu-1"}) + if ok { + t.Fatalf("expected non-usb device to be skipped, got %q", name) + } + + name, ok = nodeUSBDeviceName(resourcev1.Device{Name: "usb-raw"}) + if !ok || name != "usb-raw" { + t.Fatalf("expected usb raw name, got %q (%v)", name, ok) + } +} 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..a67f363dc1 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_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 nodeusbdevice + +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/nodeusbdevice/internal/handler" + "github.com/deckhouse/virtualization-controller/pkg/featuregates" + "github.com/deckhouse/virtualization-controller/pkg/logger" +) + +const ( + ControllerName = "nodeusbdevice-controller" +) + +func NewController( + ctx context.Context, + mgr manager.Manager, + log *log.Logger, +) (controller.Controller, error) { + if !featuregates.Default().Enabled(featuregates.USB) { + return nil, nil + } + + client := mgr.GetClient() + + handlers := []Handler{ + handler.NewDeletionHandler(client), + handler.NewReadyHandler(client), + handler.NewAssignedHandler(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, + UsePriorityQueue: ptr.To(true), + }) + if err != nil { + return nil, err + } + + if err = r.SetupController(ctx, mgr, c); err != nil { + return nil, err + } + + log.Info("Initialized NodeUSBDevice controller") + return c, nil +} diff --git a/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go new file mode 100644 index 0000000000..0f393f6e70 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/nodeusbdevice/nodeusbdevice_reconciler.go @@ -0,0 +1,108 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package nodeusbdevice + +import ( + "context" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/controller/nodeusbdevice/internal/watcher" + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type Handler interface { + Handle(ctx context.Context, s state.NodeUSBDeviceState) (reconcile.Result, error) + Name() string +} + +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) + } + + if err := watcher.NewResourceSliceWatcher().Watch(mgr, ctr); err != nil { + return fmt.Errorf("error setting watch on ResourceSlice: %w", err) + } + + return nil +} + +func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + log := logger.FromContext(ctx) + + nodeUSBDevice := reconciler.NewResource(req.NamespacedName, r.client, r.factory, r.statusGetter) + + err := nodeUSBDevice.Fetch(ctx) + if err != nil { + return reconcile.Result{}, err + } + + if nodeUSBDevice.IsEmpty() { + log.Info("Reconcile observe an absent NodeUSBDevice: it may be deleted") + return reconcile.Result{}, nil + } + + s := state.New(r.client, nodeUSBDevice) + + rec := reconciler.NewBaseReconciler[Handler](r.handlers) + rec.SetHandlerExecutor(func(ctx context.Context, h Handler) (reconcile.Result, error) { + return h.Handle(ctx, s) + }) + rec.SetResourceUpdater(func(ctx context.Context) error { + changed := nodeUSBDevice.Changed() + changed.Status.ObservedGeneration = 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/resourceslice/internal/handler/device.go b/images/virtualization-artifact/pkg/controller/resourceslice/internal/handler/device.go new file mode 100644 index 0000000000..ccad93f54f --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/resourceslice/internal/handler/device.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 handler + +import ( + "strings" + + resourcev1 "k8s.io/api/resource/v1" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +const ( + USBDeviceNamePrefix = "usb-" +) + +func IsUSBDevice(device resourcev1.Device) bool { + return strings.HasPrefix(device.Name, USBDeviceNamePrefix) +} + +func ConvertDeviceToAttributes(device resourcev1.Device, nodeName string) v1alpha2.NodeUSBDeviceAttributes { + attrs := v1alpha2.NodeUSBDeviceAttributes{ + NodeName: nodeName, + Name: device.Name, + } + + for key, attr := range device.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 +} diff --git a/images/virtualization-artifact/pkg/controller/resourceslice/internal/handler/device_test.go b/images/virtualization-artifact/pkg/controller/resourceslice/internal/handler/device_test.go new file mode 100644 index 0000000000..cba5720257 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/resourceslice/internal/handler/device_test.go @@ -0,0 +1,45 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handler + +import ( + "testing" + + resourcev1 "k8s.io/api/resource/v1" +) + +func TestIsUSBDevice(t *testing.T) { + tests := []struct { + name string + deviceName string + expectUSB bool + }{ + {name: "usb device", deviceName: "usb-device-1", expectUSB: true}, + {name: "non usb device", deviceName: "gpu-device-1", expectUSB: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + device := resourcev1.Device{Name: tt.deviceName} + if IsUSBDevice(device) != tt.expectUSB { + t.Fatalf("expected %v for device %q", tt.expectUSB, tt.deviceName) + } + }) + } +} + +func ptrString(v string) *string { return &v } diff --git a/images/virtualization-artifact/pkg/controller/resourceslice/internal/handler/discovery.go b/images/virtualization-artifact/pkg/controller/resourceslice/internal/handler/discovery.go new file mode 100644 index 0000000000..b0188b3532 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/resourceslice/internal/handler/discovery.go @@ -0,0 +1,122 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handler + +import ( + "context" + "fmt" + "strings" + + resourcev1 "k8s.io/api/resource/v1" + 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/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/resourceslice/internal/state" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +const ( + nameDiscoveryHandler = "DiscoveryHandler" + draDriverName = "virtualization-usb" +) + +func NewDiscoveryHandler(client client.Client) *DiscoveryHandler { + return &DiscoveryHandler{client: client} +} + +type DiscoveryHandler struct { + client client.Client +} + +func (h *DiscoveryHandler) Name() string { + return nameDiscoveryHandler +} + +func (h *DiscoveryHandler) Handle(ctx context.Context, s state.ResourceSliceState) (reconcile.Result, error) { + resourceSlice := s.ResourceSlice() + if resourceSlice == nil { + return reconcile.Result{}, nil + } + if resourceSlice.Spec.Driver != draDriverName { + return reconcile.Result{}, nil + } + + for _, device := range resourceSlice.Spec.Devices { + if !IsUSBDevice(device) { + continue + } + + attributes := ConvertDeviceToAttributes(device, resourceSlice.Spec.Pool.Name) + if err := h.createNodeUSBDevice(ctx, resourceSlice, attributes); err != nil { + return reconcile.Result{}, err + } + } + + return reconcile.Result{}, nil +} + +func (h *DiscoveryHandler) createNodeUSBDevice(ctx context.Context, resourceSlice *resourcev1.ResourceSlice, attributes v1alpha2.NodeUSBDeviceAttributes) error { + name := h.sanitizeName(attributes.Name) + + existing := &v1alpha2.NodeUSBDevice{} + err := h.client.Get(ctx, client.ObjectKey{Name: name}, existing) + if err == nil { + if existing.Status.Attributes != attributes || existing.Status.NodeName != attributes.NodeName { + existing.Status.Attributes = attributes + existing.Status.NodeName = attributes.NodeName + if err := h.client.Status().Update(ctx, existing); err != nil { + return fmt.Errorf("failed to update NodeUSBDevice status: %w", err) + } + } + return nil + } + if !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to check if NodeUSBDevice exists: %w", err) + } + + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + OwnerReferences: []metav1.OwnerReference{{APIVersion: resourcev1.SchemeGroupVersion.String(), Kind: "ResourceSlice", Name: resourceSlice.Name, UID: resourceSlice.UID}}, + }, + Spec: v1alpha2.NodeUSBDeviceSpec{AssignedNamespace: ""}, + } + + if err := h.client.Create(ctx, nodeUSBDevice); err != nil { + if apierrors.IsAlreadyExists(err) { + return nil + } + return fmt.Errorf("failed to create NodeUSBDevice: %w", err) + } + + nodeUSBDevice.Status = v1alpha2.NodeUSBDeviceStatus{ + Attributes: attributes, + NodeName: attributes.NodeName, + } + + if err := h.client.Status().Update(ctx, nodeUSBDevice); err != nil { + return fmt.Errorf("failed to update NodeUSBDevice status: %w", err) + } + + return nil +} + +func (h *DiscoveryHandler) sanitizeName(deviceName string) string { + return strings.ToLower(strings.ReplaceAll(deviceName, ".", "-")) +} diff --git a/images/virtualization-artifact/pkg/controller/resourceslice/internal/handler/discovery_test.go b/images/virtualization-artifact/pkg/controller/resourceslice/internal/handler/discovery_test.go new file mode 100644 index 0000000000..5a826a80d5 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/resourceslice/internal/handler/discovery_test.go @@ -0,0 +1,110 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handler + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + resourcev1 "k8s.io/api/resource/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/fake" + + resourcest "github.com/deckhouse/virtualization-controller/pkg/controller/resourceslice/internal/state" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type fakeDiscoveryState struct { + resourceSlice *resourcev1.ResourceSlice + slices []resourcev1.ResourceSlice + slicesErr error +} + +func (f *fakeDiscoveryState) ResourceSlice() *resourcev1.ResourceSlice { + return f.resourceSlice +} + +func (f *fakeDiscoveryState) ResourceSlices(_ context.Context) ([]resourcev1.ResourceSlice, error) { + return f.slices, f.slicesErr +} + +var _ = Describe("DiscoveryHandler", func() { + DescribeTable("Handle", + func(existingName, expectVendor string) { + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(resourcev1.AddToScheme(scheme)).To(Succeed()) + + builder := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&v1alpha2.NodeUSBDevice{}) + if existingName != "" { + builder = builder.WithObjects(&v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{Name: existingName}, + Status: v1alpha2.NodeUSBDeviceStatus{Attributes: v1alpha2.NodeUSBDeviceAttributes{Name: existingName}}, + }) + } + + cl := builder.Build() + h := NewDiscoveryHandler(cl) + st := &fakeDiscoveryState{resourceSlice: &resourcev1.ResourceSlice{ + Spec: resourcev1.ResourceSliceSpec{ + Driver: "virtualization-usb", + Pool: resourcev1.ResourcePool{Name: "node-a"}, + Devices: []resourcev1.Device{{ + Name: "usb-device-1", + Attributes: map[resourcev1.QualifiedName]resourcev1.DeviceAttribute{ + "name": {StringValue: ptrString("usb-device-1")}, + "vendorID": {StringValue: ptrString("1234")}, + }, + }}, + }, + }} + + _, err := h.Handle(context.Background(), st) + Expect(err).NotTo(HaveOccurred()) + + created := &v1alpha2.NodeUSBDevice{} + Expect(cl.Get(context.Background(), types.NamespacedName{Name: "usb-device-1"}, created)).To(Succeed()) + if expectVendor != "" { + Expect(created.Status.Attributes.VendorID).To(Equal(expectVendor)) + } + }, + Entry("creates missing device", "", "1234"), + Entry("syncs existing device attributes", "usb-device-1", "1234"), + ) + + It("should skip when current ResourceSlice is absent", func() { + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(resourcev1.AddToScheme(scheme)).To(Succeed()) + + cl := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&v1alpha2.NodeUSBDevice{}).Build() + h := NewDiscoveryHandler(cl) + st := &fakeDiscoveryState{} + + _, err := h.Handle(context.Background(), st) + Expect(err).NotTo(HaveOccurred()) + + created := &v1alpha2.NodeUSBDevice{} + err = cl.Get(context.Background(), types.NamespacedName{Name: "usb-device-1"}, created) + Expect(err).To(HaveOccurred()) + }) +}) + +var _ resourcest.ResourceSliceState = (*fakeDiscoveryState)(nil) diff --git a/images/virtualization-artifact/pkg/controller/resourceslice/internal/handler/suite_test.go b/images/virtualization-artifact/pkg/controller/resourceslice/internal/handler/suite_test.go new file mode 100644 index 0000000000..f9168cae8a --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/resourceslice/internal/handler/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 handler + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestResourceSlice(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "ResourceSlice Handlers Suite") +} diff --git a/images/virtualization-artifact/pkg/controller/resourceslice/internal/state/state.go b/images/virtualization-artifact/pkg/controller/resourceslice/internal/state/state.go new file mode 100644 index 0000000000..3106a778b1 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/resourceslice/internal/state/state.go @@ -0,0 +1,61 @@ +/* +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" + + resourcev1 "k8s.io/api/resource/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" +) + +const ( + draDriverName = "virtualization-usb" +) + +type ResourceSliceState interface { + ResourceSlice() *resourcev1.ResourceSlice + ResourceSlices(ctx context.Context) ([]resourcev1.ResourceSlice, error) +} + +func New(client client.Client, resourceSlice *resourcev1.ResourceSlice) ResourceSliceState { + return &resourceSliceState{ + client: client, + resourceSlice: resourceSlice, + } +} + +type resourceSliceState struct { + client client.Client + resourceSlice *resourcev1.ResourceSlice +} + +func (s *resourceSliceState) ResourceSlice() *resourcev1.ResourceSlice { + return s.resourceSlice +} + +func (s *resourceSliceState) ResourceSlices(ctx context.Context) ([]resourcev1.ResourceSlice, error) { + var slices resourcev1.ResourceSliceList + if err := s.client.List(ctx, &slices, client.MatchingFields{indexer.IndexFieldResourceSliceByDriver: draDriverName}); err != nil { + return nil, fmt.Errorf("failed to list ResourceSlices: %w", err) + } + + return slices.Items, nil +} diff --git a/images/virtualization-artifact/pkg/controller/resourceslice/internal/watcher/nodeusbdevice_watcher.go b/images/virtualization-artifact/pkg/controller/resourceslice/internal/watcher/nodeusbdevice_watcher.go new file mode 100644 index 0000000000..3f18839431 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/resourceslice/internal/watcher/nodeusbdevice_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" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func 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(_ context.Context, nodeUSBDevice *v1alpha2.NodeUSBDevice) []reconcile.Request { + return requestsByNodeUSBDeviceDeletion(nodeUSBDevice) + }), + predicate.TypedFuncs[*v1alpha2.NodeUSBDevice]{ + CreateFunc: func(_ event.TypedCreateEvent[*v1alpha2.NodeUSBDevice]) bool { + return false + }, + DeleteFunc: func(e event.TypedDeleteEvent[*v1alpha2.NodeUSBDevice]) bool { + return e.Object != nil + }, + UpdateFunc: func(_ event.TypedUpdateEvent[*v1alpha2.NodeUSBDevice]) bool { + return false + }, + }, + ), + ) +} + +func requestsByNodeUSBDeviceDeletion(nodeUSBDevice *v1alpha2.NodeUSBDevice) []reconcile.Request { + if nodeUSBDevice == nil { + return nil + } + + for _, ownerReference := range nodeUSBDevice.OwnerReferences { + if ownerReference.Kind != "ResourceSlice" || ownerReference.Name == "" { + continue + } + + return []reconcile.Request{{NamespacedName: client.ObjectKey{Name: ownerReference.Name}}} + } + + return nil +} diff --git a/images/virtualization-artifact/pkg/controller/resourceslice/internal/watcher/nodeusbdevice_watcher_test.go b/images/virtualization-artifact/pkg/controller/resourceslice/internal/watcher/nodeusbdevice_watcher_test.go new file mode 100644 index 0000000000..fd4612aa87 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/resourceslice/internal/watcher/nodeusbdevice_watcher_test.go @@ -0,0 +1,51 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package watcher + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func TestRequestsByNodeUSBDeviceDeletion(t *testing.T) { + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{ + OwnerReferences: []metav1.OwnerReference{{Kind: "ResourceSlice", Name: "slice-match"}}, + }, + } + + requests := requestsByNodeUSBDeviceDeletion(nodeUSBDevice) + if len(requests) != 1 { + t.Fatalf("expected one request, got %d", len(requests)) + } + + if requests[0].Name != "slice-match" { + t.Fatalf("expected request for slice-match, got %q", requests[0].Name) + } +} + +func TestRequestsByNodeUSBDeviceDeletionWithInvalidInput(t *testing.T) { + nodeUSBDevice := &v1alpha2.NodeUSBDevice{} + + requests := requestsByNodeUSBDeviceDeletion(nodeUSBDevice) + if requests != nil { + t.Fatalf("expected nil requests, got %v", requests) + } +} diff --git a/images/virtualization-artifact/pkg/controller/resourceslice/internal/watcher/resourceslice_watcher.go b/images/virtualization-artifact/pkg/controller/resourceslice/internal/watcher/resourceslice_watcher.go new file mode 100644 index 0000000000..b7bdd03084 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/resourceslice/internal/watcher/resourceslice_watcher.go @@ -0,0 +1,67 @@ +/* +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" + + resourcev1 "k8s.io/api/resource/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" +) + +const ( + draDriverName = "virtualization-usb" +) + +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(), + &resourcev1.ResourceSlice{}, + handler.TypedEnqueueRequestsFromMapFunc(func(_ context.Context, slice *resourcev1.ResourceSlice) []reconcile.Request { + return []reconcile.Request{{NamespacedName: client.ObjectKeyFromObject(slice)}} + }), + predicate.TypedFuncs[*resourcev1.ResourceSlice]{ + CreateFunc: func(e event.TypedCreateEvent[*resourcev1.ResourceSlice]) bool { + return e.Object != nil && e.Object.Spec.Driver == draDriverName + }, + DeleteFunc: func(e event.TypedDeleteEvent[*resourcev1.ResourceSlice]) bool { + return e.Object != nil && e.Object.Spec.Driver == draDriverName + }, + UpdateFunc: func(e event.TypedUpdateEvent[*resourcev1.ResourceSlice]) bool { + if e.ObjectOld == nil || e.ObjectNew == nil { + return false + } + + return e.ObjectOld.Spec.Driver == draDriverName || e.ObjectNew.Spec.Driver == draDriverName + }, + }, + ), + ) +} 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..45576b2f0d --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_controller.go @@ -0,0 +1,71 @@ +/* +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" + "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/resourceslice/internal/handler" + "github.com/deckhouse/virtualization-controller/pkg/featuregates" + "github.com/deckhouse/virtualization-controller/pkg/logger" +) + +const ( + ControllerName = "nodeusbdevice-resourceslice-controller" +) + +func NewController( + ctx context.Context, + mgr manager.Manager, + log *log.Logger, +) (controller.Controller, error) { + if !featuregates.Default().Enabled(featuregates.USB) { + return nil, nil + } + + client := mgr.GetClient() + + handlers := []Handler{ + handler.NewDiscoveryHandler(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, + UsePriorityQueue: ptr.To(true), + }) + if err != nil { + return nil, err + } + + if err = r.SetupController(ctx, mgr, c); err != nil { + return nil, err + } + + log.Info("Initialized NodeUSBDevice 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..134d99a660 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/resourceslice/resourceslice_reconciler.go @@ -0,0 +1,93 @@ +/* +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" + "reflect" + + resourcev1 "k8s.io/api/resource/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization-controller/pkg/controller/resourceslice/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/controller/resourceslice/internal/watcher" +) + +type Handler interface { + Handle(ctx context.Context, s state.ResourceSliceState) (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 { + for _, w := range []Watcher{ + watcher.NewResourceSliceWatcher(), + 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) { + resourceSlice := &resourcev1.ResourceSlice{} + err := r.client.Get(ctx, req.NamespacedName, resourceSlice) + if err != nil { + if !apierrors.IsNotFound(err) { + return reconcile.Result{}, err + } + + resourceSlice = nil + } + + s := state.New(r.client, resourceSlice) + + 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 { + return nil + }) + + return rec.Reconcile(ctx) +} diff --git a/images/virtualization-artifact/pkg/controller/service/mock.go b/images/virtualization-artifact/pkg/controller/service/mock.go index f6640ebfaa..5746000ae7 100644 --- a/images/virtualization-artifact/pkg/controller/service/mock.go +++ b/images/virtualization-artifact/pkg/controller/service/mock.go @@ -45,7 +45,6 @@ import ( flowcontrolv1beta2 "k8s.io/client-go/kubernetes/typed/flowcontrol/v1beta2" "k8s.io/client-go/kubernetes/typed/flowcontrol/v1beta3" networkingv1 "k8s.io/client-go/kubernetes/typed/networking/v1" - networkingv1alpha1 "k8s.io/client-go/kubernetes/typed/networking/v1alpha1" networkingv1beta1 "k8s.io/client-go/kubernetes/typed/networking/v1beta1" nodev1 "k8s.io/client-go/kubernetes/typed/node/v1" nodev1alpha1 "k8s.io/client-go/kubernetes/typed/node/v1alpha1" @@ -55,16 +54,17 @@ import ( rbacv1 "k8s.io/client-go/kubernetes/typed/rbac/v1" rbacv1alpha1 "k8s.io/client-go/kubernetes/typed/rbac/v1alpha1" rbacv1beta1 "k8s.io/client-go/kubernetes/typed/rbac/v1beta1" + resourcev1 "k8s.io/client-go/kubernetes/typed/resource/v1" "k8s.io/client-go/kubernetes/typed/resource/v1alpha3" resourcev1beta1 "k8s.io/client-go/kubernetes/typed/resource/v1beta1" "k8s.io/client-go/kubernetes/typed/resource/v1beta2" schedulingv1 "k8s.io/client-go/kubernetes/typed/scheduling/v1" schedulingv1alpha1 "k8s.io/client-go/kubernetes/typed/scheduling/v1alpha1" schedulingv1beta1 "k8s.io/client-go/kubernetes/typed/scheduling/v1beta1" - storagev1 "k8s.io/client-go/kubernetes/typed/storage/v1" + "k8s.io/client-go/kubernetes/typed/storage/v1" storagev1alpha1 "k8s.io/client-go/kubernetes/typed/storage/v1alpha1" storagev1beta1 "k8s.io/client-go/kubernetes/typed/storage/v1beta1" - storagemigrationv1alpha1 "k8s.io/client-go/kubernetes/typed/storagemigration/v1alpha1" + "k8s.io/client-go/kubernetes/typed/storagemigration/v1alpha1" "sigs.k8s.io/controller-runtime/pkg/client" "sync" ) @@ -859,12 +859,12 @@ var _ VirtClient = &VirtClientMock{} // NetworkingV1Func: func() networkingv1.NetworkingV1Interface { // panic("mock out the NetworkingV1 method") // }, -// NetworkingV1alpha1Func: func() networkingv1alpha1.NetworkingV1alpha1Interface { -// panic("mock out the NetworkingV1alpha1 method") -// }, // NetworkingV1beta1Func: func() networkingv1beta1.NetworkingV1beta1Interface { // panic("mock out the NetworkingV1beta1 method") // }, +// NodeUSBDevicesFunc: func(namespace string) corev1alpha2.NodeUSBDeviceInterface { +// panic("mock out the NodeUSBDevices method") +// }, // NodeV1Func: func() nodev1.NodeV1Interface { // panic("mock out the NodeV1 method") // }, @@ -889,6 +889,9 @@ var _ VirtClient = &VirtClientMock{} // RbacV1beta1Func: func() rbacv1beta1.RbacV1beta1Interface { // panic("mock out the RbacV1beta1 method") // }, +// ResourceV1Func: func() resourcev1.ResourceV1Interface { +// panic("mock out the ResourceV1 method") +// }, // ResourceV1alpha3Func: func() v1alpha3.ResourceV1alpha3Interface { // panic("mock out the ResourceV1alpha3 method") // }, @@ -907,7 +910,7 @@ var _ VirtClient = &VirtClientMock{} // SchedulingV1beta1Func: func() schedulingv1beta1.SchedulingV1beta1Interface { // panic("mock out the SchedulingV1beta1 method") // }, -// StorageV1Func: func() storagev1.StorageV1Interface { +// StorageV1Func: func() v1.StorageV1Interface { // panic("mock out the StorageV1 method") // }, // StorageV1alpha1Func: func() storagev1alpha1.StorageV1alpha1Interface { @@ -916,9 +919,12 @@ var _ VirtClient = &VirtClientMock{} // StorageV1beta1Func: func() storagev1beta1.StorageV1beta1Interface { // panic("mock out the StorageV1beta1 method") // }, -// StoragemigrationV1alpha1Func: func() storagemigrationv1alpha1.StoragemigrationV1alpha1Interface { +// StoragemigrationV1alpha1Func: func() v1alpha1.StoragemigrationV1alpha1Interface { // panic("mock out the StoragemigrationV1alpha1 method") // }, +// USBDevicesFunc: func(namespace string) corev1alpha2.USBDeviceInterface { +// panic("mock out the USBDevices method") +// }, // VirtualDisksFunc: func(namespace string) corev1alpha2.VirtualDiskInterface { // panic("mock out the VirtualDisks method") // }, @@ -1067,12 +1073,12 @@ type VirtClientMock struct { // NetworkingV1Func mocks the NetworkingV1 method. NetworkingV1Func func() networkingv1.NetworkingV1Interface - // NetworkingV1alpha1Func mocks the NetworkingV1alpha1 method. - NetworkingV1alpha1Func func() networkingv1alpha1.NetworkingV1alpha1Interface - // NetworkingV1beta1Func mocks the NetworkingV1beta1 method. NetworkingV1beta1Func func() networkingv1beta1.NetworkingV1beta1Interface + // NodeUSBDevicesFunc mocks the NodeUSBDevices method. + NodeUSBDevicesFunc func(namespace string) corev1alpha2.NodeUSBDeviceInterface + // NodeV1Func mocks the NodeV1 method. NodeV1Func func() nodev1.NodeV1Interface @@ -1097,6 +1103,9 @@ type VirtClientMock struct { // RbacV1beta1Func mocks the RbacV1beta1 method. RbacV1beta1Func func() rbacv1beta1.RbacV1beta1Interface + // ResourceV1Func mocks the ResourceV1 method. + ResourceV1Func func() resourcev1.ResourceV1Interface + // ResourceV1alpha3Func mocks the ResourceV1alpha3 method. ResourceV1alpha3Func func() v1alpha3.ResourceV1alpha3Interface @@ -1116,7 +1125,7 @@ type VirtClientMock struct { SchedulingV1beta1Func func() schedulingv1beta1.SchedulingV1beta1Interface // StorageV1Func mocks the StorageV1 method. - StorageV1Func func() storagev1.StorageV1Interface + StorageV1Func func() v1.StorageV1Interface // StorageV1alpha1Func mocks the StorageV1alpha1 method. StorageV1alpha1Func func() storagev1alpha1.StorageV1alpha1Interface @@ -1125,7 +1134,10 @@ type VirtClientMock struct { StorageV1beta1Func func() storagev1beta1.StorageV1beta1Interface // StoragemigrationV1alpha1Func mocks the StoragemigrationV1alpha1 method. - StoragemigrationV1alpha1Func func() storagemigrationv1alpha1.StoragemigrationV1alpha1Interface + StoragemigrationV1alpha1Func func() v1alpha1.StoragemigrationV1alpha1Interface + + // USBDevicesFunc mocks the USBDevices method. + USBDevicesFunc func(namespace string) corev1alpha2.USBDeviceInterface // VirtualDisksFunc mocks the VirtualDisks method. VirtualDisksFunc func(namespace string) corev1alpha2.VirtualDiskInterface @@ -1270,12 +1282,14 @@ type VirtClientMock struct { // NetworkingV1 holds details about calls to the NetworkingV1 method. NetworkingV1 []struct { } - // NetworkingV1alpha1 holds details about calls to the NetworkingV1alpha1 method. - NetworkingV1alpha1 []struct { - } // NetworkingV1beta1 holds details about calls to the NetworkingV1beta1 method. NetworkingV1beta1 []struct { } + // NodeUSBDevices holds details about calls to the NodeUSBDevices method. + NodeUSBDevices []struct { + // Namespace is the namespace argument value. + Namespace string + } // NodeV1 holds details about calls to the NodeV1 method. NodeV1 []struct { } @@ -1300,6 +1314,9 @@ type VirtClientMock struct { // RbacV1beta1 holds details about calls to the RbacV1beta1 method. RbacV1beta1 []struct { } + // ResourceV1 holds details about calls to the ResourceV1 method. + ResourceV1 []struct { + } // ResourceV1alpha3 holds details about calls to the ResourceV1alpha3 method. ResourceV1alpha3 []struct { } @@ -1330,6 +1347,11 @@ type VirtClientMock struct { // StoragemigrationV1alpha1 holds details about calls to the StoragemigrationV1alpha1 method. StoragemigrationV1alpha1 []struct { } + // USBDevices holds details about calls to the USBDevices method. + USBDevices []struct { + // Namespace is the namespace argument value. + Namespace string + } // VirtualDisks holds details about calls to the VirtualDisks method. VirtualDisks []struct { // Namespace is the namespace argument value. @@ -1412,8 +1434,8 @@ type VirtClientMock struct { lockFlowcontrolV1beta3 sync.RWMutex lockInternalV1alpha1 sync.RWMutex lockNetworkingV1 sync.RWMutex - lockNetworkingV1alpha1 sync.RWMutex lockNetworkingV1beta1 sync.RWMutex + lockNodeUSBDevices sync.RWMutex lockNodeV1 sync.RWMutex lockNodeV1alpha1 sync.RWMutex lockNodeV1beta1 sync.RWMutex @@ -1422,6 +1444,7 @@ type VirtClientMock struct { lockRbacV1 sync.RWMutex lockRbacV1alpha1 sync.RWMutex lockRbacV1beta1 sync.RWMutex + lockResourceV1 sync.RWMutex lockResourceV1alpha3 sync.RWMutex lockResourceV1beta1 sync.RWMutex lockResourceV1beta2 sync.RWMutex @@ -1432,6 +1455,7 @@ type VirtClientMock struct { lockStorageV1alpha1 sync.RWMutex lockStorageV1beta1 sync.RWMutex lockStoragemigrationV1alpha1 sync.RWMutex + lockUSBDevices sync.RWMutex lockVirtualDisks sync.RWMutex lockVirtualImages sync.RWMutex lockVirtualMachineBlockDeviceAttachments sync.RWMutex @@ -2443,33 +2467,6 @@ func (mock *VirtClientMock) NetworkingV1Calls() []struct { return calls } -// NetworkingV1alpha1 calls NetworkingV1alpha1Func. -func (mock *VirtClientMock) NetworkingV1alpha1() networkingv1alpha1.NetworkingV1alpha1Interface { - if mock.NetworkingV1alpha1Func == nil { - panic("VirtClientMock.NetworkingV1alpha1Func: method is nil but VirtClient.NetworkingV1alpha1 was just called") - } - callInfo := struct { - }{} - mock.lockNetworkingV1alpha1.Lock() - mock.calls.NetworkingV1alpha1 = append(mock.calls.NetworkingV1alpha1, callInfo) - mock.lockNetworkingV1alpha1.Unlock() - return mock.NetworkingV1alpha1Func() -} - -// NetworkingV1alpha1Calls gets all the calls that were made to NetworkingV1alpha1. -// Check the length with: -// -// len(mockedVirtClient.NetworkingV1alpha1Calls()) -func (mock *VirtClientMock) NetworkingV1alpha1Calls() []struct { -} { - var calls []struct { - } - mock.lockNetworkingV1alpha1.RLock() - calls = mock.calls.NetworkingV1alpha1 - mock.lockNetworkingV1alpha1.RUnlock() - return calls -} - // NetworkingV1beta1 calls NetworkingV1beta1Func. func (mock *VirtClientMock) NetworkingV1beta1() networkingv1beta1.NetworkingV1beta1Interface { if mock.NetworkingV1beta1Func == nil { @@ -2497,6 +2494,38 @@ func (mock *VirtClientMock) NetworkingV1beta1Calls() []struct { return calls } +// NodeUSBDevices calls NodeUSBDevicesFunc. +func (mock *VirtClientMock) NodeUSBDevices(namespace string) corev1alpha2.NodeUSBDeviceInterface { + if mock.NodeUSBDevicesFunc == nil { + panic("VirtClientMock.NodeUSBDevicesFunc: method is nil but VirtClient.NodeUSBDevices was just called") + } + callInfo := struct { + Namespace string + }{ + Namespace: namespace, + } + mock.lockNodeUSBDevices.Lock() + mock.calls.NodeUSBDevices = append(mock.calls.NodeUSBDevices, callInfo) + mock.lockNodeUSBDevices.Unlock() + return mock.NodeUSBDevicesFunc(namespace) +} + +// NodeUSBDevicesCalls gets all the calls that were made to NodeUSBDevices. +// Check the length with: +// +// len(mockedVirtClient.NodeUSBDevicesCalls()) +func (mock *VirtClientMock) NodeUSBDevicesCalls() []struct { + Namespace string +} { + var calls []struct { + Namespace string + } + mock.lockNodeUSBDevices.RLock() + calls = mock.calls.NodeUSBDevices + mock.lockNodeUSBDevices.RUnlock() + return calls +} + // NodeV1 calls NodeV1Func. func (mock *VirtClientMock) NodeV1() nodev1.NodeV1Interface { if mock.NodeV1Func == nil { @@ -2713,6 +2742,33 @@ func (mock *VirtClientMock) RbacV1beta1Calls() []struct { return calls } +// ResourceV1 calls ResourceV1Func. +func (mock *VirtClientMock) ResourceV1() resourcev1.ResourceV1Interface { + if mock.ResourceV1Func == nil { + panic("VirtClientMock.ResourceV1Func: method is nil but VirtClient.ResourceV1 was just called") + } + callInfo := struct { + }{} + mock.lockResourceV1.Lock() + mock.calls.ResourceV1 = append(mock.calls.ResourceV1, callInfo) + mock.lockResourceV1.Unlock() + return mock.ResourceV1Func() +} + +// ResourceV1Calls gets all the calls that were made to ResourceV1. +// Check the length with: +// +// len(mockedVirtClient.ResourceV1Calls()) +func (mock *VirtClientMock) ResourceV1Calls() []struct { +} { + var calls []struct { + } + mock.lockResourceV1.RLock() + calls = mock.calls.ResourceV1 + mock.lockResourceV1.RUnlock() + return calls +} + // ResourceV1alpha3 calls ResourceV1alpha3Func. func (mock *VirtClientMock) ResourceV1alpha3() v1alpha3.ResourceV1alpha3Interface { if mock.ResourceV1alpha3Func == nil { @@ -2876,7 +2932,7 @@ func (mock *VirtClientMock) SchedulingV1beta1Calls() []struct { } // StorageV1 calls StorageV1Func. -func (mock *VirtClientMock) StorageV1() storagev1.StorageV1Interface { +func (mock *VirtClientMock) StorageV1() v1.StorageV1Interface { if mock.StorageV1Func == nil { panic("VirtClientMock.StorageV1Func: method is nil but VirtClient.StorageV1 was just called") } @@ -2957,7 +3013,7 @@ func (mock *VirtClientMock) StorageV1beta1Calls() []struct { } // StoragemigrationV1alpha1 calls StoragemigrationV1alpha1Func. -func (mock *VirtClientMock) StoragemigrationV1alpha1() storagemigrationv1alpha1.StoragemigrationV1alpha1Interface { +func (mock *VirtClientMock) StoragemigrationV1alpha1() v1alpha1.StoragemigrationV1alpha1Interface { if mock.StoragemigrationV1alpha1Func == nil { panic("VirtClientMock.StoragemigrationV1alpha1Func: method is nil but VirtClient.StoragemigrationV1alpha1 was just called") } @@ -2983,6 +3039,38 @@ func (mock *VirtClientMock) StoragemigrationV1alpha1Calls() []struct { return calls } +// USBDevices calls USBDevicesFunc. +func (mock *VirtClientMock) USBDevices(namespace string) corev1alpha2.USBDeviceInterface { + if mock.USBDevicesFunc == nil { + panic("VirtClientMock.USBDevicesFunc: method is nil but VirtClient.USBDevices was just called") + } + callInfo := struct { + Namespace string + }{ + Namespace: namespace, + } + mock.lockUSBDevices.Lock() + mock.calls.USBDevices = append(mock.calls.USBDevices, callInfo) + mock.lockUSBDevices.Unlock() + return mock.USBDevicesFunc(namespace) +} + +// USBDevicesCalls gets all the calls that were made to USBDevices. +// Check the length with: +// +// len(mockedVirtClient.USBDevicesCalls()) +func (mock *VirtClientMock) USBDevicesCalls() []struct { + Namespace string +} { + var calls []struct { + Namespace string + } + mock.lockUSBDevices.RLock() + calls = mock.calls.USBDevices + mock.lockUSBDevices.RUnlock() + return calls +} + // VirtualDisks calls VirtualDisksFunc. func (mock *VirtClientMock) VirtualDisks(namespace string) corev1alpha2.VirtualDiskInterface { if mock.VirtualDisksFunc == nil { diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/handler/conditions.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/handler/conditions.go new file mode 100644 index 0000000000..c122feafb3 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/handler/conditions.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 handler + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/usbdevicecondition" +) + +func setReadyCondition( + usbDevice *v1alpha2.USBDevice, + target *[]metav1.Condition, + status metav1.ConditionStatus, + reason usbdevicecondition.ReadyReason, + message string, + lastTransitionTime *metav1.Time, +) { + cb := conditions.NewConditionBuilder(usbdevicecondition.ReadyType). + Generation(usbDevice.GetGeneration()). + Status(status). + Reason(reason). + Message(message) + + if lastTransitionTime != nil { + cb = cb.LastTransitionTime(lastTransitionTime.Time) + } + + conditions.SetCondition(cb, target) +} + +func setAttachedCondition( + usbDevice *v1alpha2.USBDevice, + target *[]metav1.Condition, + status metav1.ConditionStatus, + reason usbdevicecondition.AttachedReason, + message string, +) { + cb := conditions.NewConditionBuilder(usbdevicecondition.AttachedType). + Generation(usbDevice.GetGeneration()). + Status(status). + Reason(reason). + Message(message) + + conditions.SetCondition(cb, target) +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/handler/deletion.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/handler/deletion.go new file mode 100644 index 0000000000..79a78cf019 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/handler/deletion.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 handler + +import ( + "context" + "fmt" + "time" + + 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/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) *DeletionHandler { + return &DeletionHandler{ + client: client, + virtClient: virtClient, + } +} + +type DeletionHandler struct { + client client.Client + virtClient versioned.Interface +} + +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() + + if current.GetDeletionTimestamp().IsZero() { + controllerutil.AddFinalizer(changed, v1alpha2.FinalizerUSBDeviceCleanup) + return reconcile.Result{}, nil + } + + 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 { + for _, vm := range vms { + err := h.virtClient.VirtualizationV1alpha2().VirtualMachines(vm.Namespace).RemoveResourceClaim(ctx, vm.Name, subv1alpha2.VirtualMachineRemoveResourceClaim{Name: current.Name}) + if err == nil { + continue + } + + if apierrors.IsNotFound(err) { + continue + } + + return reconcile.Result{Requeue: true}, fmt.Errorf("failed to remove ResourceClaim from VM %s/%s: %w", vm.Namespace, vm.Name, err) + } + + return reconcile.Result{RequeueAfter: time.Second}, nil + } + + 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/handler/deletion_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/handler/deletion_test.go new file mode 100644 index 0000000000..f83c74d0d3 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/handler/deletion_test.go @@ -0,0 +1,116 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handler + +import ( + "context" + "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/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 + + type testCase struct { + deleting bool + attached bool + withVM bool + expectRequeue bool + finalizerPresent bool + } + + BeforeEach(func() { + ctx = logger.ToContext(context.TODO(), slog.Default()) + }) + + DescribeTable("Handle", + func(tc testCase) { + scheme := apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + + usb := &v1alpha2.USBDevice{ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1", Namespace: "default"}} + if tc.deleting { + now := metav1.Now() + usb.DeletionTimestamp = &now + usb.Finalizers = []string{v1alpha2.FinalizerUSBDeviceCleanup} + } + condStatus := metav1.ConditionFalse + condReason := string(usbdevicecondition.Available) + if tc.attached { + condStatus = metav1.ConditionTrue + condReason = string(usbdevicecondition.AttachedToVirtualMachine) + } + usb.Status.Conditions = []metav1.Condition{{Type: string(usbdevicecondition.AttachedType), Status: condStatus, Reason: condReason}} + + objects := []client.Object{usb} + if tc.withVM { + objects = append(objects, &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}}}, + }) + } + + vmObj, vmField, vmExtractValue := indexer.IndexVMByUSBDevice() + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).WithIndex(vmObj, vmField, vmExtractValue).Build() + + res := reconciler.NewResource( + types.NamespacedName{Name: usb.Name, Namespace: usb.Namespace}, + cl, + func() *v1alpha2.USBDevice { return &v1alpha2.USBDevice{} }, + func(obj *v1alpha2.USBDevice) v1alpha2.USBDeviceStatus { return obj.Status }, + ) + Expect(res.Fetch(ctx)).To(Succeed()) + + st := state.New(cl, res) + h := NewDeletionHandler(cl, fakeversioned.NewSimpleClientset()) + result, err := h.Handle(ctx, st) + Expect(err).NotTo(HaveOccurred()) + + if tc.expectRequeue { + Expect(result.RequeueAfter).To(BeNumerically(">", 0)) + } else { + Expect(result).To(Equal(reconcile.Result{})) + } + + if tc.finalizerPresent { + Expect(res.Changed().GetFinalizers()).To(ContainElement(v1alpha2.FinalizerUSBDeviceCleanup)) + } else { + Expect(res.Changed().GetFinalizers()).NotTo(ContainElement(v1alpha2.FinalizerUSBDeviceCleanup)) + } + }, + Entry("not deleting adds finalizer", testCase{finalizerPresent: true}), + Entry("deleting not attached removes finalizer", testCase{deleting: true, finalizerPresent: false}), + Entry("deleting attached requeues", testCase{deleting: true, attached: true, withVM: true, expectRequeue: true, finalizerPresent: true}), + ) +}) diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/handler/lifecycle.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/handler/lifecycle.go new file mode 100644 index 0000000000..53c8f6efbd --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/handler/lifecycle.go @@ -0,0 +1,256 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handler + +import ( + "context" + "fmt" + "reflect" + "strconv" + + resourcev1 "k8s.io/api/resource/v1" + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/usbdevice/internal/state" + "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" +) + +const ( + nameLifecycleHandler = "LifecycleHandler" + resourceClaimTemplateNameSuffix = "-template" +) + +func ResourceClaimTemplateName(usbDeviceName string) string { + return usbDeviceName + resourceClaimTemplateNameSuffix +} + +func NewLifecycleHandler(client client.Client) *LifecycleHandler { + return &LifecycleHandler{ + client: client, + } +} + +type LifecycleHandler struct { + client client.Client +} + +func (h *LifecycleHandler) Name() string { + return nameLifecycleHandler +} + +func (h *LifecycleHandler) Handle(ctx context.Context, s state.USBDeviceState) (reconcile.Result, error) { + if s.USBDevice().IsEmpty() { + return reconcile.Result{}, nil + } + + if err := h.syncReady(ctx, s); err != nil { + return reconcile.Result{}, err + } + + if err := h.ensureResourceClaimTemplate(ctx, s); err != nil { + return reconcile.Result{}, err + } + + if err := h.syncAttached(ctx, s); err != nil { + return reconcile.Result{}, err + } + + return reconcile.Result{}, nil +} + +func (h *LifecycleHandler) syncReady(ctx context.Context, s state.USBDeviceState) error { + current := s.USBDevice().Current() + changed := s.USBDevice().Changed() + + nodeUSBDevice, err := s.NodeUSBDevice(ctx) + if err != nil { + return err + } + + if nodeUSBDevice == nil { + setReadyCondition(current, &changed.Status.Conditions, metav1.ConditionFalse, usbdevicecondition.NotFound, "Corresponding NodeUSBDevice not found.", nil) + return nil + } + + if !equality.Semantic.DeepEqual(changed.Status.Attributes, nodeUSBDevice.Status.Attributes) || changed.Status.NodeName != nodeUSBDevice.Status.NodeName { + changed.Status.Attributes = nodeUSBDevice.Status.Attributes + changed.Status.NodeName = nodeUSBDevice.Status.NodeName + } + + readyCondition := meta.FindStatusCondition(nodeUSBDevice.Status.Conditions, string(nodeusbdevicecondition.ReadyType)) + if readyCondition == nil { + setReadyCondition(current, &changed.Status.Conditions, metav1.ConditionFalse, usbdevicecondition.NotReady, "Ready condition not found in NodeUSBDevice.", nil) + return nil + } + + 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 + } + + setReadyCondition(current, &changed.Status.Conditions, status, reason, readyCondition.Message, &readyCondition.LastTransitionTime) + + return nil +} + +func (h *LifecycleHandler) ensureResourceClaimTemplate(ctx context.Context, s state.USBDeviceState) error { + log := logger.FromContext(ctx).With(logger.SlogHandler(nameLifecycleHandler)) + usbDevice := s.USBDevice().Current() + + if usbDevice.Status.Attributes.Name == "" { + log.Debug("USBDevice has no attributes name yet, skipping ResourceClaimTemplate") + return nil + } + + templateName := ResourceClaimTemplateName(usbDevice.Name) + template := &resourcev1.ResourceClaimTemplate{} + key := types.NamespacedName{Name: templateName, Namespace: usbDevice.Namespace} + desiredSpec := buildResourceClaimTemplateSpec(usbDevice) + + err := h.client.Get(ctx, key, template) + if err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to get ResourceClaimTemplate: %w", err) + } + + if !apierrors.IsNotFound(err) { + if !reflect.DeepEqual(template.Spec, desiredSpec) { + if err := h.client.Delete(ctx, template); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to delete outdated ResourceClaimTemplate: %w", err) + } + + template = &resourcev1.ResourceClaimTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: templateName, + Namespace: usbDevice.Namespace, + OwnerReferences: []metav1.OwnerReference{service.MakeControllerOwnerReference(usbDevice)}, + }, + Spec: desiredSpec, + } + + if err := h.client.Create(ctx, template); err != nil { + return fmt.Errorf("failed to recreate ResourceClaimTemplate: %w", err) + } + + log.Info("recreated ResourceClaimTemplate for USBDevice", "template", templateName) + } + return nil + } + + template = &resourcev1.ResourceClaimTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: templateName, + Namespace: usbDevice.Namespace, + OwnerReferences: []metav1.OwnerReference{service.MakeControllerOwnerReference(usbDevice)}, + }, + Spec: desiredSpec, + } + + if err := h.client.Create(ctx, template); err != nil { + return fmt.Errorf("failed to create ResourceClaimTemplate: %w", err) + } + + log.Info("created ResourceClaimTemplate for USBDevice", "template", templateName) + return nil +} + +func (h *LifecycleHandler) syncAttached(ctx context.Context, s state.USBDeviceState) error { + current := s.USBDevice().Current() + changed := s.USBDevice().Changed() + + vms, err := s.VirtualMachinesUsingDevice(ctx) + if err != nil { + return 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 { + reason = usbdevicecondition.Available + status = metav1.ConditionFalse + message = "Device is available for attachment to a virtual machine." + setAttachedCondition(current, &changed.Status.Conditions, status, reason, message) + return nil + } + + reason = usbdevicecondition.AttachedToVirtualMachine + status = metav1.ConditionTrue + message = fmt.Sprintf("Device is attached to %d VirtualMachines.", len(vms)) + if len(vms) == 1 { + message = fmt.Sprintf("Device is attached to VirtualMachine %s/%s.", vms[0].Namespace, vms[0].Name) + } + + setAttachedCondition(current, &changed.Status.Conditions, status, reason, message) + return nil +} + +func buildResourceClaimTemplateSpec(usbDevice *v1alpha2.USBDevice) resourcev1.ResourceClaimTemplateSpec { + attributes := usbDevice.Status.Attributes + selectorDeviceName := attributes.Name + if selectorDeviceName == "" { + selectorDeviceName = usbDevice.Name + } + + return resourcev1.ResourceClaimTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + annotations.AnnUSBDeviceGroup: annotations.DefaultUSBDeviceGroup, + annotations.AnnUSBDeviceUser: annotations.DefaultUSBDeviceUser, + }, + }, + Spec: resourcev1.ResourceClaimSpec{ + Devices: resourcev1.DeviceClaim{ + Requests: []resourcev1.DeviceRequest{{ + Name: "req-" + usbDevice.Name, + Exactly: &resourcev1.ExactDeviceRequest{ + Count: 1, + AllocationMode: resourcev1.DeviceAllocationModeExactCount, + DeviceClassName: "usb-devices.virtualization.deckhouse.io", + Selectors: []resourcev1.DeviceSelector{{ + CEL: &resourcev1.CELDeviceSelector{Expression: fmt.Sprintf(`device.attributes["virtualization-usb"].name == %s`, strconv.Quote(selectorDeviceName))}, + }}, + }, + }}, + }, + }, + } +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/handler/lifecycle_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/handler/lifecycle_test.go new file mode 100644 index 0000000000..7b2e2aa07d --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/handler/lifecycle_test.go @@ -0,0 +1,226 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handler + +import ( + "context" + "log/slog" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + resourcev1 "k8s.io/api/resource/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apiruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/deckhouse/virtualization-controller/pkg/controller/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/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("LifecycleHandler", func() { + var ctx context.Context + var scheme *apiruntime.Scheme + + BeforeEach(func() { + ctx = logger.ToContext(context.TODO(), slog.Default()) + scheme = apiruntime.NewScheme() + Expect(v1alpha2.AddToScheme(scheme)).To(Succeed()) + Expect(resourcev1.AddToScheme(scheme)).To(Succeed()) + }) + + DescribeTable("Handle", + func(hasNode, nodeReady, withVM bool, expectReady metav1.ConditionStatus, expectReadyReason, expectAttachedReason string) { + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1", Namespace: "default", UID: "usb-uid-1"}, + Status: v1alpha2.USBDeviceStatus{Attributes: v1alpha2.NodeUSBDeviceAttributes{Name: "usb-device-1", VendorID: "0000", ProductID: "0000"}}, + } + + objects := []client.Object{usbDevice} + if hasNode { + nodeStatus := metav1.ConditionFalse + nodeReason := string(nodeusbdevicecondition.NotReady) + if nodeReady { + nodeStatus = metav1.ConditionTrue + nodeReason = string(nodeusbdevicecondition.Ready) + } + objects = append(objects, &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1"}, + Status: v1alpha2.NodeUSBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{Name: "usb-device-1", VendorID: "1234", ProductID: "5678"}, + NodeName: "node-1", + Conditions: []metav1.Condition{{Type: string(nodeusbdevicecondition.ReadyType), Status: nodeStatus, Reason: nodeReason, Message: "Node status"}}, + }, + }) + } + if withVM { + objects = append(objects, &v1alpha2.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{Name: "vm-1", Namespace: "default"}, + Spec: v1alpha2.VirtualMachineSpec{USBDevices: []v1alpha2.USBDeviceSpecRef{{Name: "usb-device-1"}}}, + Status: v1alpha2.VirtualMachineStatus{USBDevices: []v1alpha2.USBDeviceStatusRef{{Name: "usb-device-1", Attached: true}}}, + }) + } + + vmObj, vmField, vmExtractValue := indexer.IndexVMByUSBDevice() + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).WithIndex(vmObj, vmField, vmExtractValue).Build() + + res := reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + cl, + func() *v1alpha2.USBDevice { return &v1alpha2.USBDevice{} }, + func(obj *v1alpha2.USBDevice) v1alpha2.USBDeviceStatus { return obj.Status }, + ) + Expect(res.Fetch(ctx)).To(Succeed()) + + st := state.New(cl, res) + h := NewLifecycleHandler(cl) + _, err := h.Handle(ctx, st) + Expect(err).NotTo(HaveOccurred()) + + ready := meta.FindStatusCondition(res.Changed().Status.Conditions, string(usbdevicecondition.ReadyType)) + Expect(ready).NotTo(BeNil()) + Expect(ready.Status).To(Equal(expectReady)) + Expect(ready.Reason).To(Equal(expectReadyReason)) + + attached := meta.FindStatusCondition(res.Changed().Status.Conditions, string(usbdevicecondition.AttachedType)) + Expect(attached).NotTo(BeNil()) + Expect(attached.Reason).To(Equal(expectAttachedReason)) + + template := &resourcev1.ResourceClaimTemplate{} + err = cl.Get(ctx, types.NamespacedName{Name: ResourceClaimTemplateName("usb-device-1"), Namespace: "default"}, template) + Expect(err).NotTo(HaveOccurred()) + }, + Entry("node ready and not attached", true, true, false, metav1.ConditionTrue, string(usbdevicecondition.Ready), string(usbdevicecondition.Available)), + Entry("node ready and attached", true, true, true, metav1.ConditionTrue, string(usbdevicecondition.Ready), string(usbdevicecondition.AttachedToVirtualMachine)), + Entry("node not ready", true, false, false, metav1.ConditionFalse, string(usbdevicecondition.NotReady), string(usbdevicecondition.Available)), + Entry("node missing", false, false, false, metav1.ConditionFalse, string(usbdevicecondition.NotFound), string(usbdevicecondition.Available)), + ) + + DescribeTable("ResourceClaimTemplate request and selector names", + func(attrName, expectedSelectorName string) { + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{Name: "usb-device-cr", Namespace: "default", UID: "usb-uid-1"}, + Status: v1alpha2.USBDeviceStatus{Attributes: v1alpha2.NodeUSBDeviceAttributes{Name: attrName, VendorID: "0000", ProductID: "0000"}}, + } + + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{Name: "usb-device-cr"}, + Status: v1alpha2.NodeUSBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{Name: attrName, VendorID: "1234", ProductID: "5678"}, + NodeName: "node-1", + Conditions: []metav1.Condition{{Type: string(nodeusbdevicecondition.ReadyType), Status: metav1.ConditionTrue, Reason: string(nodeusbdevicecondition.Ready), Message: "Node status"}}, + }, + } + + vmObj, vmField, vmExtractValue := indexer.IndexVMByUSBDevice() + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice, nodeUSBDevice).WithIndex(vmObj, vmField, vmExtractValue).Build() + + res := reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + cl, + func() *v1alpha2.USBDevice { return &v1alpha2.USBDevice{} }, + func(obj *v1alpha2.USBDevice) v1alpha2.USBDeviceStatus { return obj.Status }, + ) + Expect(res.Fetch(ctx)).To(Succeed()) + + st := state.New(cl, res) + h := NewLifecycleHandler(cl) + _, err := h.Handle(ctx, st) + Expect(err).NotTo(HaveOccurred()) + + template := &resourcev1.ResourceClaimTemplate{} + err = cl.Get(ctx, types.NamespacedName{Name: ResourceClaimTemplateName("usb-device-cr"), Namespace: "default"}, template) + Expect(err).NotTo(HaveOccurred()) + Expect(template.Spec.Spec.Devices.Requests).To(HaveLen(1)) + Expect(template.Spec.Spec.Devices.Requests[0].Name).To(Equal("req-usb-device-cr")) + Expect(template.Spec.Spec.Devices.Requests[0].Exactly.Selectors).To(HaveLen(1)) + Expect(template.Spec.Spec.Devices.Requests[0].Exactly.Selectors[0].CEL).NotTo(BeNil()) + Expect(template.Spec.Spec.Devices.Requests[0].Exactly.Selectors[0].CEL.Expression).To(ContainSubstring(`device.attributes["virtualization-usb"].name == "` + expectedSelectorName + `"`)) + }, + Entry("uses attribute name in selector", "usb-raw-device", "usb-raw-device"), + ) + + DescribeTable("buildResourceClaimTemplateSpec selector fallback", + func(attrName, expectedSelectorName string) { + spec := buildResourceClaimTemplateSpec(&v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{Name: "usb-device-cr", Namespace: "default"}, + Status: v1alpha2.USBDeviceStatus{Attributes: v1alpha2.NodeUSBDeviceAttributes{Name: attrName}}, + }) + + Expect(spec.Spec.Devices.Requests).To(HaveLen(1)) + Expect(spec.Spec.Devices.Requests[0].Name).To(Equal("req-usb-device-cr")) + Expect(spec.Spec.Devices.Requests[0].Exactly.Selectors).To(HaveLen(1)) + Expect(spec.Spec.Devices.Requests[0].Exactly.Selectors[0].CEL).NotTo(BeNil()) + Expect(spec.Spec.Devices.Requests[0].Exactly.Selectors[0].CEL.Expression).To(ContainSubstring(`device.attributes["virtualization-usb"].name == "` + expectedSelectorName + `"`)) + }, + Entry("uses provided attribute name", "usb-raw-device", "usb-raw-device"), + Entry("falls back to resource name when attribute name is empty", "", "usb-device-cr"), + ) + + It("should update existing ResourceClaimTemplate when selector drifts", func() { + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{Name: "usb-device-cr", Namespace: "default", UID: "usb-uid-1"}, + Status: v1alpha2.USBDeviceStatus{Attributes: v1alpha2.NodeUSBDeviceAttributes{Name: "usb-new-name", VendorID: "0000", ProductID: "0000"}}, + } + + nodeUSBDevice := &v1alpha2.NodeUSBDevice{ + ObjectMeta: metav1.ObjectMeta{Name: "usb-device-cr"}, + Status: v1alpha2.NodeUSBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{Name: "usb-new-name", VendorID: "1234", ProductID: "5678"}, + NodeName: "node-1", + Conditions: []metav1.Condition{{Type: string(nodeusbdevicecondition.ReadyType), Status: metav1.ConditionTrue, Reason: string(nodeusbdevicecondition.Ready), Message: "Node status"}}, + }, + } + + template := &resourcev1.ResourceClaimTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: ResourceClaimTemplateName("usb-device-cr"), Namespace: "default"}, + Spec: buildResourceClaimTemplateSpec(&v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{Name: "usb-device-cr", Namespace: "default"}, + Status: v1alpha2.USBDeviceStatus{Attributes: v1alpha2.NodeUSBDeviceAttributes{Name: "usb-old-name"}}, + }), + } + + vmObj, vmField, vmExtractValue := indexer.IndexVMByUSBDevice() + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(usbDevice, nodeUSBDevice, template).WithIndex(vmObj, vmField, vmExtractValue).Build() + + res := reconciler.NewResource( + types.NamespacedName{Name: usbDevice.Name, Namespace: usbDevice.Namespace}, + cl, + func() *v1alpha2.USBDevice { return &v1alpha2.USBDevice{} }, + func(obj *v1alpha2.USBDevice) v1alpha2.USBDeviceStatus { return obj.Status }, + ) + Expect(res.Fetch(ctx)).To(Succeed()) + + st := state.New(cl, res) + h := NewLifecycleHandler(cl) + _, err := h.Handle(ctx, st) + Expect(err).NotTo(HaveOccurred()) + + updated := &resourcev1.ResourceClaimTemplate{} + err = cl.Get(ctx, types.NamespacedName{Name: ResourceClaimTemplateName("usb-device-cr"), Namespace: "default"}, updated) + Expect(err).NotTo(HaveOccurred()) + expr := updated.Spec.Spec.Devices.Requests[0].Exactly.Selectors[0].CEL.Expression + Expect(expr).To(ContainSubstring(`device.attributes["virtualization-usb"].name == "usb-new-name"`)) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/handler/suite_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/handler/suite_test.go new file mode 100644 index 0000000000..090bd59bbd --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/handler/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 handler + +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/state/state.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go new file mode 100644 index 0000000000..bf1da15aa4 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/state/state.go @@ -0,0 +1,99 @@ +/* +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" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "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 := s.usbDevice.Current() + if usbDevice == nil { + return nil, nil + } + + nodeUSBDevice := &v1alpha2.NodeUSBDevice{} + err := s.client.Get(ctx, client.ObjectKey{Name: usbDevice.Name}, nodeUSBDevice) + if apierrors.IsNotFound(err) { + return nil, nil + } + if err != nil { + return nil, err + } + + return nodeUSBDevice, 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/watcher/nodeusbdevice_watcher.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/nodeusbdevice_watcher.go new file mode 100644 index 0000000000..4263de5f66 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/nodeusbdevice_watcher.go @@ -0,0 +1,83 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package watcher + +import ( + "context" + + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func 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(_ context.Context, nodeUSBDevice *v1alpha2.NodeUSBDevice) []reconcile.Request { + // Only enqueue USBDevice if NodeUSBDevice has assignedNamespace + if nodeUSBDevice.Spec.AssignedNamespace == "" { + return nil + } + + return []reconcile.Request{{ + NamespacedName: types.NamespacedName{ + Namespace: nodeUSBDevice.Spec.AssignedNamespace, + Name: nodeUSBDevice.Name, + }, + }} + }), + predicate.TypedFuncs[*v1alpha2.NodeUSBDevice]{ + CreateFunc: func(e event.TypedCreateEvent[*v1alpha2.NodeUSBDevice]) bool { + return e.Object != nil + }, + DeleteFunc: func(e event.TypedDeleteEvent[*v1alpha2.NodeUSBDevice]) bool { + return e.Object != nil + }, + UpdateFunc: func(e event.TypedUpdateEvent[*v1alpha2.NodeUSBDevice]) bool { + return shouldProcessNodeUSBDeviceUpdate(e.ObjectOld, e.ObjectNew) + }, + }, + ), + ) +} + +func shouldProcessNodeUSBDeviceUpdate(oldObj, newObj *v1alpha2.NodeUSBDevice) bool { + if oldObj == nil || newObj == nil { + return false + } + + return oldObj.Spec.AssignedNamespace != newObj.Spec.AssignedNamespace || + oldObj.Status.NodeName != newObj.Status.NodeName || + !equality.Semantic.DeepEqual(oldObj.Status.Attributes, newObj.Status.Attributes) || + !equality.Semantic.DeepEqual(oldObj.Status.Conditions, newObj.Status.Conditions) || + !equality.Semantic.DeepEqual(oldObj.GetDeletionTimestamp(), newObj.GetDeletionTimestamp()) +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/nodeusbdevice_watcher_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/nodeusbdevice_watcher_test.go new file mode 100644 index 0000000000..87c534f945 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/nodeusbdevice_watcher_test.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 watcher + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func TestShouldProcessNodeUSBDeviceUpdate(t *testing.T) { + oldObj := &v1alpha2.NodeUSBDevice{ + Spec: v1alpha2.NodeUSBDeviceSpec{AssignedNamespace: "ns-a"}, + Status: v1alpha2.NodeUSBDeviceStatus{ + NodeName: "node-a", + Conditions: []metav1.Condition{{ + Type: "Ready", + Status: metav1.ConditionTrue, + }}, + }, + } + + sameObj := oldObj.DeepCopy() + if shouldProcessNodeUSBDeviceUpdate(oldObj, sameObj) { + t.Fatal("expected unchanged object update to be ignored") + } + + changedNamespace := oldObj.DeepCopy() + changedNamespace.Spec.AssignedNamespace = "ns-b" + if !shouldProcessNodeUSBDeviceUpdate(oldObj, changedNamespace) { + t.Fatal("expected assigned namespace update to be processed") + } + + changedConditions := oldObj.DeepCopy() + changedConditions.Status.Conditions[0].Reason = "Changed" + if !shouldProcessNodeUSBDeviceUpdate(oldObj, changedConditions) { + t.Fatal("expected conditions update to be processed") + } + + if shouldProcessNodeUSBDeviceUpdate(nil, changedConditions) { + t.Fatal("expected nil old object to be ignored") + } + if shouldProcessNodeUSBDeviceUpdate(oldObj, nil) { + t.Fatal("expected nil new object to be ignored") + } +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/resourceclaimtemplate_watcher.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/resourceclaimtemplate_watcher.go new file mode 100644 index 0000000000..c1a3dc06b1 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/resourceclaimtemplate_watcher.go @@ -0,0 +1,98 @@ +/* +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" + + resourcev1 "k8s.io/api/resource/v1" + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +const resourceClaimTemplateNameSuffix = "-template" + +func NewResourceClaimTemplateWatcher() *ResourceClaimTemplateWatcher { + return &ResourceClaimTemplateWatcher{} +} + +type ResourceClaimTemplateWatcher struct{} + +func (w *ResourceClaimTemplateWatcher) Watch(mgr manager.Manager, ctr controller.Controller) error { + return ctr.Watch( + source.Kind(mgr.GetCache(), + &resourcev1.ResourceClaimTemplate{}, + handler.TypedEnqueueRequestsFromMapFunc(func(_ context.Context, template *resourcev1.ResourceClaimTemplate) []reconcile.Request { + name, ok := mapResourceClaimTemplateToUSBDeviceName(template) + if !ok { + return nil + } + + return []reconcile.Request{{ + NamespacedName: types.NamespacedName{Namespace: template.Namespace, Name: name}, + }} + }), + predicate.TypedFuncs[*resourcev1.ResourceClaimTemplate]{ + CreateFunc: func(e event.TypedCreateEvent[*resourcev1.ResourceClaimTemplate]) bool { + return e.Object != nil + }, + DeleteFunc: func(e event.TypedDeleteEvent[*resourcev1.ResourceClaimTemplate]) bool { + return e.Object != nil + }, + UpdateFunc: func(e event.TypedUpdateEvent[*resourcev1.ResourceClaimTemplate]) bool { + return shouldProcessResourceClaimTemplateUpdate(e.ObjectOld, e.ObjectNew) + }, + }, + ), + ) +} + +func mapResourceClaimTemplateToUSBDeviceName(template *resourcev1.ResourceClaimTemplate) (string, bool) { + if template == nil { + return "", false + } + + for _, ref := range template.OwnerReferences { + if ref.Kind == v1alpha2.USBDeviceKind && ref.APIVersion == v1alpha2.SchemeGroupVersion.String() { + return ref.Name, true + } + } + + if name, hasSuffix := strings.CutSuffix(template.Name, resourceClaimTemplateNameSuffix); hasSuffix && name != "" { + return name, true + } + + return "", false +} + +func shouldProcessResourceClaimTemplateUpdate(oldObj, newObj *resourcev1.ResourceClaimTemplate) bool { + if oldObj == nil || newObj == nil { + return false + } + + return !equality.Semantic.DeepEqual(oldObj.OwnerReferences, newObj.OwnerReferences) || !equality.Semantic.DeepEqual(oldObj.Spec, newObj.Spec) +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/resourceclaimtemplate_watcher_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/resourceclaimtemplate_watcher_test.go new file mode 100644 index 0000000000..ac44553993 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/resourceclaimtemplate_watcher_test.go @@ -0,0 +1,103 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package watcher + +import ( + "testing" + + resourcev1 "k8s.io/api/resource/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func TestShouldProcessResourceClaimTemplateUpdate(t *testing.T) { + oldObj := &resourcev1.ResourceClaimTemplate{ + ObjectMeta: metav1.ObjectMeta{ + OwnerReferences: []metav1.OwnerReference{{Kind: "USBDevice", Name: "usb-a"}}, + }, + } + + sameObj := oldObj.DeepCopy() + if shouldProcessResourceClaimTemplateUpdate(oldObj, sameObj) { + t.Fatal("expected unchanged owner references update to be ignored") + } + + changedOwners := oldObj.DeepCopy() + changedOwners.OwnerReferences = []metav1.OwnerReference{{Kind: "USBDevice", Name: "usb-b"}} + if !shouldProcessResourceClaimTemplateUpdate(oldObj, changedOwners) { + t.Fatal("expected owner references update to be processed") + } + + changedSpec := oldObj.DeepCopy() + changedSpec.Spec = resourcev1.ResourceClaimTemplateSpec{ + Spec: resourcev1.ResourceClaimSpec{ + Devices: resourcev1.DeviceClaim{ + Requests: []resourcev1.DeviceRequest{{ + Name: "req-usb-a", + }}, + }, + }, + } + if !shouldProcessResourceClaimTemplateUpdate(oldObj, changedSpec) { + t.Fatal("expected spec update to be processed") + } + + if shouldProcessResourceClaimTemplateUpdate(nil, changedOwners) { + t.Fatal("expected nil old object to be ignored") + } + if shouldProcessResourceClaimTemplateUpdate(oldObj, nil) { + t.Fatal("expected nil new object to be ignored") + } +} + +func TestMapResourceClaimTemplateToUSBDeviceName(t *testing.T) { + templateWithOwner := &resourcev1.ResourceClaimTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "usb-a-template", + Namespace: "ns-a", + OwnerReferences: []metav1.OwnerReference{{ + Kind: v1alpha2.USBDeviceKind, + APIVersion: v1alpha2.SchemeGroupVersion.String(), + Name: "usb-a", + }}, + }, + } + + name, ok := mapResourceClaimTemplateToUSBDeviceName(templateWithOwner) + if !ok || name != "usb-a" { + t.Fatalf("expected name from owner reference, got %q (%v)", name, ok) + } + + templateBySuffix := &resourcev1.ResourceClaimTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "usb-b-template"}, + } + + name, ok = mapResourceClaimTemplateToUSBDeviceName(templateBySuffix) + if !ok || name != "usb-b" { + t.Fatalf("expected name from template suffix, got %q (%v)", name, ok) + } + + templateWithoutOwnerAndSuffix := &resourcev1.ResourceClaimTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "something-else"}, + } + + name, ok = mapResourceClaimTemplateToUSBDeviceName(templateWithoutOwnerAndSuffix) + if ok { + t.Fatalf("expected no mapped name, got %q", name) + } +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/virtualmachine_watcher.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/virtualmachine_watcher.go new file mode 100644 index 0000000000..43d463d33f --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/virtualmachine_watcher.go @@ -0,0 +1,80 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package watcher + +import ( + "context" + + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func NewVirtualMachineWatcher() *VirtualMachineWatcher { + return &VirtualMachineWatcher{} +} + +type VirtualMachineWatcher struct{} + +func (w *VirtualMachineWatcher) Watch(mgr manager.Manager, ctr controller.Controller) error { + return ctr.Watch( + source.Kind(mgr.GetCache(), + &v1alpha2.VirtualMachine{}, + handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, vm *v1alpha2.VirtualMachine) []reconcile.Request { + var result []reconcile.Request + for _, ref := range vm.Spec.USBDevices { + result = append(result, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: vm.Namespace, + Name: ref.Name, + }, + }) + } + return result + }), + predicate.TypedFuncs[*v1alpha2.VirtualMachine]{ + CreateFunc: func(e event.TypedCreateEvent[*v1alpha2.VirtualMachine]) bool { + return true + }, + DeleteFunc: func(e event.TypedDeleteEvent[*v1alpha2.VirtualMachine]) bool { + return true + }, + UpdateFunc: func(e event.TypedUpdateEvent[*v1alpha2.VirtualMachine]) bool { + return shouldProcessVirtualMachineUpdate(e.ObjectOld, e.ObjectNew) + }, + }, + ), + ) +} + +func shouldProcessVirtualMachineUpdate(oldObj, newObj *v1alpha2.VirtualMachine) bool { + if oldObj == nil || newObj == nil { + return false + } + + return !equality.Semantic.DeepEqual(oldObj.Spec.USBDevices, newObj.Spec.USBDevices) || + !equality.Semantic.DeepEqual(oldObj.Status.USBDevices, newObj.Status.USBDevices) || + !equality.Semantic.DeepEqual(oldObj.GetDeletionTimestamp(), newObj.GetDeletionTimestamp()) +} diff --git a/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/virtualmachine_watcher_test.go b/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/virtualmachine_watcher_test.go new file mode 100644 index 0000000000..022814dbf5 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/internal/watcher/virtualmachine_watcher_test.go @@ -0,0 +1,67 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package watcher + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func TestShouldProcessVirtualMachineUpdate(t *testing.T) { + oldObj := &v1alpha2.VirtualMachine{ + Spec: v1alpha2.VirtualMachineSpec{ + USBDevices: []v1alpha2.USBDeviceSpecRef{{Name: "usb-a"}}, + }, + Status: v1alpha2.VirtualMachineStatus{ + USBDevices: []v1alpha2.USBDeviceStatusRef{{Name: "usb-a", Attached: false}}, + }, + } + + sameObj := oldObj.DeepCopy() + if shouldProcessVirtualMachineUpdate(oldObj, sameObj) { + t.Fatal("expected unchanged VM update to be ignored") + } + + changedSpec := oldObj.DeepCopy() + changedSpec.Spec.USBDevices = []v1alpha2.USBDeviceSpecRef{{Name: "usb-b"}} + if !shouldProcessVirtualMachineUpdate(oldObj, changedSpec) { + t.Fatal("expected VM spec USB devices update to be processed") + } + + changedStatus := oldObj.DeepCopy() + changedStatus.Status.USBDevices[0].Attached = true + if !shouldProcessVirtualMachineUpdate(oldObj, changedStatus) { + t.Fatal("expected VM status USB devices update to be processed") + } + + now := metav1.Now() + changedDeletion := oldObj.DeepCopy() + changedDeletion.DeletionTimestamp = &now + if !shouldProcessVirtualMachineUpdate(oldObj, changedDeletion) { + t.Fatal("expected VM deletion timestamp update to be processed") + } + + if shouldProcessVirtualMachineUpdate(nil, changedStatus) { + t.Fatal("expected nil old object to be ignored") + } + if shouldProcessVirtualMachineUpdate(oldObj, nil) { + t.Fatal("expected nil new object to be ignored") + } +} 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..cd271615fd --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_controller.go @@ -0,0 +1,78 @@ +/* +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/handler" + "github.com/deckhouse/virtualization-controller/pkg/featuregates" + "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) { + if !featuregates.Default().Enabled(featuregates.USB) { + return nil, nil + } + + client := mgr.GetClient() + + virtClient, err := versioned.NewForConfig(mgr.GetConfig()) + if err != nil { + return nil, err + } + + handlers := []Handler{ + handler.NewDeletionHandler(client, virtClient), + handler.NewLifecycleHandler(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, + 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..eb4f5d4ff3 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/usbdevice/usbdevice_reconciler.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 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(), + watcher.NewResourceClaimTemplateWatcher(), + watcher.NewVirtualMachineWatcher(), + } { + 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/vm/internal/migrating.go b/images/virtualization-artifact/pkg/controller/vm/internal/migrating.go index 3c215bfb2a..2c52e6cbb7 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/migrating.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/migrating.go @@ -78,7 +78,7 @@ func (h *MigratingHandler) Handle(ctx context.Context, s state.VirtualMachineSta vm.Status.MigrationState = migrationState } - err = h.syncMigratable(ctx, s, vm, kvvm) + err = h.syncMigratable(ctx, s, vm, kvvm, kvvmi) if err != nil { return reconcile.Result{}, fmt.Errorf("failed to sync migratable condition: %w", err) } @@ -277,7 +277,7 @@ func (h *MigratingHandler) getVMOPCandidate(ctx context.Context, s state.Virtual return nil, nil } -func (h *MigratingHandler) syncMigratable(ctx context.Context, s state.VirtualMachineState, vm *v1alpha2.VirtualMachine, kvvm *virtv1.VirtualMachine) error { +func (h *MigratingHandler) syncMigratable(ctx context.Context, s state.VirtualMachineState, vm *v1alpha2.VirtualMachine, kvvm *virtv1.VirtualMachine, kvvmi *virtv1.VirtualMachineInstance) error { cb := conditions.NewConditionBuilder(vmcondition.TypeMigratable).Generation(vm.GetGeneration()) if kvvm != nil { @@ -294,6 +294,22 @@ func (h *MigratingHandler) syncMigratable(ctx context.Context, s state.VirtualMa Reason(vmcondition.ReasonDisksNotMigratable). Message("Live migration requires that all PVCs must be shared (using ReadWriteMany access mode)") } + conditions.SetCondition(cb, &vm.Status.Conditions) + return nil + case liveMigratable.Reason == virtv1.VirtualMachineInstanceReasonHostDeviceNotMigratable: + hostDeviceNames := getHostDeviceNamesFromKVVMI(kvvmi) + isAllUSB := allHostDevicesAreUSB(hostDeviceNames) + + if isAllUSB { + cb.Status(metav1.ConditionTrue). + Reason(vmcondition.ReasonUSBShouldBeMigrating). + Message("") + } else { + cb.Status(metav1.ConditionFalse). + Reason(vmcondition.ReasonHostDevicesNotMigratable). + Message("Live migration requires that all Host Devices must be unplugged") + } + conditions.SetCondition(cb, &vm.Status.Conditions) return nil case liveMigratable.Status == corev1.ConditionFalse: @@ -338,3 +354,43 @@ func (h *MigratingHandler) syncMigratable(ctx context.Context, s state.VirtualMa func liveMigrationInProgress(migrationState *v1alpha2.VirtualMachineMigrationState) bool { return migrationState != nil && migrationState.StartTimestamp != nil && migrationState.EndTimestamp == nil } + +// getHostDeviceNamesFromKVVMI returns unique host device names from both Spec and Status. +// Either spec or status (or both) can be nil when the other has devices, e.g. after deleting hp pod. +func getHostDeviceNamesFromKVVMI(kvvmi *virtv1.VirtualMachineInstance) []string { + if kvvmi == nil { + return nil + } + names := make(map[string]struct{}) + if kvvmi.Spec.Domain.Devices.HostDevices != nil { + for _, d := range kvvmi.Spec.Domain.Devices.HostDevices { + if d.Name != "" { + names[d.Name] = struct{}{} + } + } + } + if kvvmi.Status.DeviceStatus != nil && kvvmi.Status.DeviceStatus.HostDeviceStatuses != nil { + for _, st := range kvvmi.Status.DeviceStatus.HostDeviceStatuses { + if st.Name != "" { + names[st.Name] = struct{}{} + } + } + } + result := make([]string, 0, len(names)) + for n := range names { + result = append(result, n) + } + return result +} + +func allHostDevicesAreUSB(deviceNames []string) bool { + if len(deviceNames) == 0 { + return true // no host devices visible — do not block migration (e.g. when spec/status incomplete) + } + for _, name := range deviceNames { + if !strings.HasPrefix(name, "usb-") { + return false + } + } + return true +} 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_attach_handler.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_attach_handler.go new file mode 100644 index 0000000000..fc9ca182ed --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_attach_handler.go @@ -0,0 +1,206 @@ +/* +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" + virtv1 "kubevirt.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/vm/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vmcondition" +) + +const nameUSBDeviceAttachHandler = "USBDeviceAttachHandler" + +func NewUSBDeviceAttachHandler(cl client.Client, virtClient VirtClient) *USBDeviceAttachHandler { + return &USBDeviceAttachHandler{ + usbDeviceHandlerBase: usbDeviceHandlerBase{ + client: cl, + virtClient: virtClient, + }, + } +} + +type USBDeviceAttachHandler struct { + usbDeviceHandlerBase +} + +func (h *USBDeviceAttachHandler) Name() string { + return nameUSBDeviceAttachHandler +} + +// Handle builds USB device status, attaches devices that are ready, and updates USBDevicesReady condition. +func (h *USBDeviceAttachHandler) Handle(ctx context.Context, s state.VirtualMachineState) (reconcile.Result, error) { + log := logger.FromContext(ctx).With(logger.SlogHandler(nameUSBDeviceAttachHandler)) + + if s.VirtualMachine().IsEmpty() { + return reconcile.Result{}, nil + } + + vm := s.VirtualMachine().Current() + changed := s.VirtualMachine().Changed() + + _, isMigrating := conditions.GetCondition(vmcondition.TypeMigrating, changed.Status.Conditions) + if isMigrating { + return reconcile.Result{}, nil + } + + hasPendingMigration, err := h.hasPendingMigrationOp(ctx, s) + if err != nil { + return reconcile.Result{}, err + } + + if hasPendingMigration { + return reconcile.Result{}, nil + } + + usbDevicesByName, err := s.USBDevicesByName(ctx) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to get USB devices: %w", err) + } + + statusByName := make(map[string]*v1alpha2.USBDeviceStatusRef) + for i := range changed.Status.USBDevices { + device := &changed.Status.USBDevices[i] + statusByName[device.Name] = device + } + + var kvvmiLoaded bool + var kvvmi *virtv1.VirtualMachineInstance + var hostDeviceReadyByName map[string]bool + + var nextStatusRefs []v1alpha2.USBDeviceStatusRef + for _, usbDeviceRef := range vm.Spec.USBDevices { + deviceName := usbDeviceRef.Name + existingStatus := statusByName[deviceName] + + // 1) Resolve source USBDevice object. + usbDevice, exists := usbDevicesByName[deviceName] + if !exists { + nextStatusRefs = append(nextStatusRefs, h.buildDetachedStatus(nil, deviceName, false)) + continue + } + + isReady := h.isUSBDeviceReady(usbDevice) + + // 2) Pre-attach gates: deleting/template/ready checks. + if !usbDevice.GetDeletionTimestamp().IsZero() { + nextStatusRefs = append(nextStatusRefs, h.buildDetachedStatus(existingStatus, deviceName, false)) + continue + } + + templateName := h.getResourceClaimTemplateName(deviceName) + if _, err := h.getResourceClaimTemplate(ctx, vm.Namespace, templateName); err != nil { + if !apierrors.IsNotFound(err) { + return reconcile.Result{}, err + } + + log.Error("failed to get ResourceClaimTemplate", "error", err, "usbDevice", deviceName) + nextStatusRefs = append(nextStatusRefs, h.buildDetachedStatus(nil, deviceName, isReady)) + continue + } + + if !isReady { + nextStatusRefs = append(nextStatusRefs, h.buildDetachedStatus(existingStatus, deviceName, isReady)) + continue + } + + // 3) Runtime evidence from KVVMI and attach action. + if !kvvmiLoaded { + fetchedKVVMI, err := s.KVVMI(ctx) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to get KVVMI: %w", err) + } + kvvmi = fetchedKVVMI + kvvmiLoaded = true + } + + if hostDeviceReadyByName == nil { + hostDeviceReadyByName = h.hostDeviceReadyByName(kvvmi) + } + + if hostDeviceReadyByName[deviceName] { + address := h.getUSBAddressFromKVVMI(deviceName, kvvmi) + isHotplugged := vm.Status.Phase == v1alpha2.MachineRunning + + if existingStatus != nil && existingStatus.Attached { + status := *existingStatus + status.Ready = isReady + status.Address = address + status.Hotplugged = isHotplugged + nextStatusRefs = append(nextStatusRefs, status) + } else { + nextStatusRefs = append(nextStatusRefs, h.buildAttachedStatus(deviceName, isReady, address, isHotplugged)) + } + continue + } + + requestName := h.getResourceClaimRequestName(deviceName) + err := h.attachUSBDevice(ctx, vm, deviceName, templateName, requestName) + if err != nil && !apierrors.IsAlreadyExists(err) { + log.Error("failed to attach USB device", "error", err, "usbDevice", deviceName) + } + + nextStatusRefs = append(nextStatusRefs, h.buildDetachedStatus(existingStatus, deviceName, isReady)) + } + + changed.Status.USBDevices = nextStatusRefs + + return reconcile.Result{}, nil +} + +func (h *USBDeviceAttachHandler) buildAttachedStatus( + deviceName string, + ready bool, + address *v1alpha2.USBAddress, + hotplugged bool, +) v1alpha2.USBDeviceStatusRef { + return v1alpha2.USBDeviceStatusRef{ + Name: deviceName, + Attached: true, + Ready: ready, + Address: address, + Hotplugged: hotplugged, + } +} + +func (h *USBDeviceAttachHandler) buildDetachedStatus( + existing *v1alpha2.USBDeviceStatusRef, + deviceName string, + ready bool, +) v1alpha2.USBDeviceStatusRef { + status := v1alpha2.USBDeviceStatusRef{Name: deviceName} + if existing != nil { + status = *existing + } + + status.Name = deviceName + status.Attached = false + status.Ready = ready + status.Address = nil + status.Hotplugged = false + + return status +} diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_attach_handler_test.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_attach_handler_test.go new file mode 100644 index 0000000000..d3d1d95b3a --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_attach_handler_test.go @@ -0,0 +1,440 @@ +/* +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" + "errors" + "log/slog" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + resourcev1 "k8s.io/api/resource/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + virtv1 "kubevirt.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "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" +) + +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] +} + +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("USBDeviceAttachHandler", func() { + var ctx context.Context + var fakeClient client.WithWatch + var mockVirtCl *mockVirtClient + var handler *USBDeviceAttachHandler + var vmState state.VirtualMachineState + var vmResource *reconciler.Resource[*v1alpha2.VirtualMachine, v1alpha2.VirtualMachineStatus] + + BeforeEach(func() { + ctx = logger.ToContext(context.TODO(), slog.Default()) + mockVirtCl = newMockVirtClient() + }) + + DescribeTable("Handle attach matrix", + func(phase v1alpha2.MachinePhase, ready, withTemplate bool, attributeName string, expectAttached bool, expectAddCalls int, expectRequestName string) { + 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: phase}, + } + + conds := []metav1.Condition{} + vendor := "" + if ready { + conds = append(conds, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue}) + vendor = "1234" + } + + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1", Namespace: "default"}, + Status: v1alpha2.USBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{Name: attributeName, VendorID: vendor, ProductID: "5678"}, + NodeName: "node-1", + Conditions: conds, + }, + } + + objs := []client.Object{usbDevice} + if withTemplate { + objs = append(objs, &resourcev1.ResourceClaimTemplate{ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1-template", Namespace: "default"}}) + } + + fakeClient, vmResource, vmState = setupEnvironment(vm, objs...) + handler = NewUSBDeviceAttachHandler(fakeClient, mockVirtCl) + + result, err := handler.Handle(ctx, vmState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + Expect(vmResource.Changed().Status.USBDevices).To(HaveLen(1)) + Expect(vmResource.Changed().Status.USBDevices[0].Attached).To(Equal(expectAttached)) + + _ = mockVirtCl.VirtualMachines("default") + mockVM := mockVirtCl.vmClients["default"] + Expect(mockVM.addResourceClaimCalls).To(HaveLen(expectAddCalls)) + if expectAddCalls > 0 { + Expect(mockVM.addResourceClaimCalls[0].Name).To(Equal("usb-device-1")) + Expect(mockVM.addResourceClaimCalls[0].RequestName).To(Equal(expectRequestName)) + } + }, + Entry("ready + template", v1alpha2.MachineRunning, true, true, "usb-device-1", false, 1, "req-usb-device-1"), + Entry("ready + no template", v1alpha2.MachineRunning, true, false, "usb-device-1", false, 0, ""), + Entry("not ready + template", v1alpha2.MachineRunning, false, true, "usb-device-1", false, 0, ""), + Entry("not ready + vm stopped", v1alpha2.MachineStopped, false, true, "", false, 0, ""), + Entry("request name ignores attribute name", v1alpha2.MachineRunning, true, true, "usb-raw-name", false, 1, "req-usb-device-1"), + ) + + 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: "missing-device"}}}, + Status: v1alpha2.VirtualMachineStatus{Phase: v1alpha2.MachineStopped}, + } + + fakeClient, vmResource, vmState = setupEnvironment(vm) + handler = NewUSBDeviceAttachHandler(fakeClient, mockVirtCl) + + _, err := handler.Handle(ctx, vmState) + Expect(err).NotTo(HaveOccurred()) + Expect(vmResource.Changed().Status.USBDevices).To(HaveLen(1)) + Expect(vmResource.Changed().Status.USBDevices[0].Name).To(Equal("missing-device")) + Expect(vmResource.Changed().Status.USBDevices[0].Attached).To(BeFalse()) + }) + + It("should set attached when KVVMI host device phase is HostDeviceReady and attach pod name is set", 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{Name: "usb-device-1", VendorID: "1234", ProductID: "5678"}, + NodeName: "node-1", + Conditions: []metav1.Condition{{Type: "Ready", Status: metav1.ConditionTrue}}, + }, + } + template := &resourcev1.ResourceClaimTemplate{ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1-template", Namespace: "default"}} + hostDeviceStatus := virtv1.DeviceStatusInfo{ + Name: "usb-device-1", + Hotplug: &virtv1.HotplugDeviceStatus{AttachPodName: "hp-usb-device-1"}, + } + hostDeviceStatus.Phase = virtv1.DeviceReady + + kvvmi := &virtv1.VirtualMachineInstance{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vm", Namespace: "default"}, + Status: virtv1.VirtualMachineInstanceStatus{DeviceStatus: &virtv1.DeviceStatus{HostDeviceStatuses: []virtv1.DeviceStatusInfo{hostDeviceStatus}}}, + } + + fakeClient, vmResource, vmState = setupEnvironment(vm, usbDevice, template, kvvmi) + handler = NewUSBDeviceAttachHandler(fakeClient, mockVirtCl) + + _, err := handler.Handle(ctx, vmState) + Expect(err).NotTo(HaveOccurred()) + Expect(vmResource.Changed().Status.USBDevices).To(HaveLen(1)) + Expect(vmResource.Changed().Status.USBDevices[0].Attached).To(BeTrue()) + }) + + It("should set attached when KVVMI host device phase is HostDeviceReady", 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{Name: "usb-device-1", VendorID: "1234", ProductID: "5678"}, + NodeName: "node-1", + Conditions: []metav1.Condition{{Type: "Ready", Status: metav1.ConditionTrue}}, + }, + } + template := &resourcev1.ResourceClaimTemplate{ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1-template", Namespace: "default"}} + + hostDeviceStatus := virtv1.DeviceStatusInfo{ + Address: "0:2", + Name: "usb-device-1", + } + + kvvmi := &virtv1.VirtualMachineInstance{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vm", Namespace: "default"}, + Status: virtv1.VirtualMachineInstanceStatus{DeviceStatus: &virtv1.DeviceStatus{HostDeviceStatuses: []virtv1.DeviceStatusInfo{hostDeviceStatus}}}, + } + + fakeClient, vmResource, vmState = setupEnvironment(vm, usbDevice, template, kvvmi) + handler = NewUSBDeviceAttachHandler(fakeClient, mockVirtCl) + + _, err := handler.Handle(ctx, vmState) + Expect(err).NotTo(HaveOccurred()) + Expect(vmResource.Changed().Status.USBDevices).To(HaveLen(1)) + Expect(vmResource.Changed().Status.USBDevices[0].Attached).To(BeTrue()) + Expect(vmResource.Changed().Status.USBDevices[0].Address).To(Equal(&v1alpha2.USBAddress{Bus: 0, Port: 2})) + }) + + DescribeTable("should keep USB status address nil when KVVMI host device address format is invalid", + func(invalidAddress string) { + 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{Name: "usb-device-1", VendorID: "1234", ProductID: "5678"}, + NodeName: "node-1", + Conditions: []metav1.Condition{{Type: "Ready", Status: metav1.ConditionTrue}}, + }, + } + template := &resourcev1.ResourceClaimTemplate{ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1-template", Namespace: "default"}} + + hostDeviceStatus := virtv1.DeviceStatusInfo{ + Address: invalidAddress, + Name: "usb-device-1", + } + + kvvmi := &virtv1.VirtualMachineInstance{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vm", Namespace: "default"}, + Status: virtv1.VirtualMachineInstanceStatus{DeviceStatus: &virtv1.DeviceStatus{HostDeviceStatuses: []virtv1.DeviceStatusInfo{hostDeviceStatus}}}, + } + + fakeClient, vmResource, vmState = setupEnvironment(vm, usbDevice, template, kvvmi) + handler = NewUSBDeviceAttachHandler(fakeClient, mockVirtCl) + + var result reconcile.Result + var err error + Expect(func() { + result, err = handler.Handle(ctx, vmState) + }).NotTo(Panic()) + + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + Expect(vmResource.Changed().Status.USBDevices).To(HaveLen(1)) + Expect(vmResource.Changed().Status.USBDevices[0].Attached).To(BeTrue()) + Expect(vmResource.Changed().Status.USBDevices[0].Address).To(BeNil()) + }, + Entry("malformed separator", "0-"), + Entry("empty address", ""), + Entry("missing bus", ":2"), + Entry("missing port", "1:"), + ) + + It("should keep detached when KVVMI host device phase is Ready even if attach pod name is set", 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{Name: "usb-device-1", VendorID: "1234", ProductID: "5678"}, + NodeName: "node-1", + Conditions: []metav1.Condition{{Type: "Ready", Status: metav1.ConditionTrue}}, + }, + } + template := &resourcev1.ResourceClaimTemplate{ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1-template", Namespace: "default"}} + hostDeviceStatus := virtv1.DeviceStatusInfo{ + Name: "usb-device-1", + Hotplug: &virtv1.HotplugDeviceStatus{AttachPodName: "hp-usb-device-1"}, + } + + kvvmi := &virtv1.VirtualMachineInstance{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vm", Namespace: "default"}, + Status: virtv1.VirtualMachineInstanceStatus{DeviceStatus: &virtv1.DeviceStatus{HostDeviceStatuses: []virtv1.DeviceStatusInfo{hostDeviceStatus}}}, + } + + fakeClient, vmResource, vmState = setupEnvironment(vm, usbDevice, template, kvvmi) + handler = NewUSBDeviceAttachHandler(fakeClient, mockVirtCl) + + _, err := handler.Handle(ctx, vmState) + Expect(err).NotTo(HaveOccurred()) + Expect(vmResource.Changed().Status.USBDevices).To(HaveLen(1)) + Expect(vmResource.Changed().Status.USBDevices[0].Attached).To(BeFalse()) + }) + + It("should keep detached when KVVMI host device phase is AttachedToPod even if attach pod name is set", 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{Name: "usb-device-1", VendorID: "1234", ProductID: "5678"}, + NodeName: "node-1", + Conditions: []metav1.Condition{{Type: "Ready", Status: metav1.ConditionTrue}}, + }, + } + template := &resourcev1.ResourceClaimTemplate{ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1-template", Namespace: "default"}} + hostDeviceStatus := virtv1.DeviceStatusInfo{ + Name: "usb-device-1", + Hotplug: &virtv1.HotplugDeviceStatus{AttachPodName: "hp-usb-device-1"}, + } + + kvvmi := &virtv1.VirtualMachineInstance{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vm", Namespace: "default"}, + Status: virtv1.VirtualMachineInstanceStatus{DeviceStatus: &virtv1.DeviceStatus{HostDeviceStatuses: []virtv1.DeviceStatusInfo{hostDeviceStatus}}}, + } + + fakeClient, vmResource, vmState = setupEnvironment(vm, usbDevice, template, kvvmi) + handler = NewUSBDeviceAttachHandler(fakeClient, mockVirtCl) + + _, err := handler.Handle(ctx, vmState) + Expect(err).NotTo(HaveOccurred()) + Expect(vmResource.Changed().Status.USBDevices).To(HaveLen(1)) + Expect(vmResource.Changed().Status.USBDevices[0].Attached).To(BeFalse()) + }) + + DescribeTable("AddResourceClaim error handling", + func(addErr error, expectAttached bool) { + 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{Name: "usb-device-1", VendorID: "1234", ProductID: "5678"}, + NodeName: "node-1", + Conditions: []metav1.Condition{{Type: "Ready", Status: metav1.ConditionTrue}}, + }, + } + template := &resourcev1.ResourceClaimTemplate{ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1-template", Namespace: "default"}} + + fakeClient, vmResource, vmState = setupEnvironment(vm, usbDevice, template) + handler = NewUSBDeviceAttachHandler(fakeClient, mockVirtCl) + + mockVM := mockVirtCl.VirtualMachines("default").(*mockVirtualMachines) + mockVM.addResourceClaimErr = addErr + + _, err := handler.Handle(ctx, vmState) + Expect(err).NotTo(HaveOccurred()) + Expect(vmResource.Changed().Status.USBDevices).To(HaveLen(1)) + Expect(vmResource.Changed().Status.USBDevices[0].Attached).To(Equal(expectAttached)) + Expect(mockVM.addResourceClaimCalls).To(HaveLen(1)) + }, + Entry("non AlreadyExists error keeps detached", apierrors.NewInternalError(errors.New("boom")), false), + Entry("AlreadyExists keeps detached until KVVMI reflects claim", apierrors.NewAlreadyExists(schema.GroupResource{Resource: "resourceclaims"}, "usb-device-1"), false), + ) + + DescribeTable("clears stale attachment fields in non-attached branches", + func(markDeleting bool, addErr error) { + now := metav1.NewTime(time.Now()) + 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, + Ready: true, + Address: &v1alpha2.USBAddress{Bus: 1, Port: 2}, + Hotplugged: true, + }}, + }, + } + + usbMeta := metav1.ObjectMeta{Name: "usb-device-1", Namespace: "default"} + if markDeleting { + usbMeta.DeletionTimestamp = &now + usbMeta.Finalizers = []string{"test-finalizer"} + } + + usbDevice := &v1alpha2.USBDevice{ + ObjectMeta: usbMeta, + Status: v1alpha2.USBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{Name: "usb-device-1", VendorID: "1234", ProductID: "5678"}, + NodeName: "node-1", + Conditions: []metav1.Condition{{Type: "Ready", Status: metav1.ConditionTrue}}, + }, + } + + objs := []client.Object{usbDevice} + if !markDeleting { + objs = append(objs, &resourcev1.ResourceClaimTemplate{ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1-template", Namespace: "default"}}) + } + + fakeClient, vmResource, vmState = setupEnvironment(vm, objs...) + handler = NewUSBDeviceAttachHandler(fakeClient, mockVirtCl) + + if addErr != nil { + mockVM := mockVirtCl.VirtualMachines("default").(*mockVirtualMachines) + mockVM.addResourceClaimErr = addErr + } + + _, err := handler.Handle(ctx, vmState) + Expect(err).NotTo(HaveOccurred()) + Expect(vmResource.Changed().Status.USBDevices).To(HaveLen(1)) + status := vmResource.Changed().Status.USBDevices[0] + Expect(status.Attached).To(BeFalse()) + Expect(status.Address).To(BeNil()) + Expect(status.Hotplugged).To(BeFalse()) + }, + Entry("device deleting", true, nil), + Entry("attach failed", false, apierrors.NewInternalError(errors.New("boom"))), + ) +}) diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_detach_handler.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_detach_handler.go new file mode 100644 index 0000000000..24fcf2198d --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_detach_handler.go @@ -0,0 +1,122 @@ +/* +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/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/vm/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +const nameUSBDeviceDetachHandler = "USBDeviceDetachHandler" + +func NewUSBDeviceDetachHandler(cl client.Client, virtClient VirtClient) *USBDeviceDetachHandler { + return &USBDeviceDetachHandler{ + usbDeviceHandlerBase: usbDeviceHandlerBase{ + client: cl, + virtClient: virtClient, + }, + } +} + +type USBDeviceDetachHandler struct { + usbDeviceHandlerBase +} + +func (h *USBDeviceDetachHandler) Name() string { + return nameUSBDeviceDetachHandler +} + +func (h *USBDeviceDetachHandler) Handle(ctx context.Context, s state.VirtualMachineState) (reconcile.Result, error) { + log := logger.FromContext(ctx).With(logger.SlogHandler(nameUSBDeviceDetachHandler)) + + if s.VirtualMachine().IsEmpty() { + return reconcile.Result{}, nil + } + + vm := s.VirtualMachine().Current() + changed := s.VirtualMachine().Changed() + + usbDevicesByName, err := s.USBDevicesByName(ctx) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to get USB devices: %w", err) + } + + currentStatusMap := make(map[string]*v1alpha2.USBDeviceStatusRef) + for i := range changed.Status.USBDevices { + device := &changed.Status.USBDevices[i] + currentStatusMap[device.Name] = device + } + + specDeviceNames := make(map[string]struct{}) + for _, usbDeviceRef := range vm.Spec.USBDevices { + specDeviceNames[usbDeviceRef.Name] = struct{}{} + } + + for _, existingStatus := range currentStatusMap { + if _, ok := specDeviceNames[existingStatus.Name]; !ok { + 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) + return reconcile.Result{}, fmt.Errorf("failed to detach USB device %s: %w", existingStatus.Name, err) + } + } + } + + for _, usbDeviceRef := range vm.Spec.USBDevices { + existingStatus := currentStatusMap[usbDeviceRef.Name] + if existingStatus == nil || !existingStatus.Attached { + continue + } + + usbDevice, exists := usbDevicesByName[usbDeviceRef.Name] + if !exists { + err := h.detachUSBDevice(ctx, vm, usbDeviceRef.Name) + if err != nil && !apierrors.IsNotFound(err) { + log.Error("failed to detach USB device (device not found)", "error", err, "usbDevice", usbDeviceRef.Name) + return reconcile.Result{}, fmt.Errorf("failed to detach USB device %s (device not found): %w", usbDeviceRef.Name, err) + } + continue + } + + if !usbDevice.GetDeletionTimestamp().IsZero() { + err := h.detachUSBDevice(ctx, vm, usbDeviceRef.Name) + if err != nil && !apierrors.IsNotFound(err) { + log.Error("failed to detach USB device (device deleting)", "error", err, "usbDevice", usbDeviceRef.Name) + return reconcile.Result{}, fmt.Errorf("failed to detach USB device %s (device deleting): %w", usbDeviceRef.Name, err) + } + continue + } + + if !h.isUSBDeviceReady(usbDevice) { + err := h.detachUSBDevice(ctx, vm, usbDeviceRef.Name) + if err != nil && !apierrors.IsNotFound(err) { + log.Error("failed to detach USB device (absent on device)", "error", err, "usbDevice", usbDeviceRef.Name) + return reconcile.Result{}, fmt.Errorf("failed to detach USB device %s (device not ready): %w", usbDeviceRef.Name, err) + } + } + } + + return reconcile.Result{}, nil +} diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_detach_handler_test.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_detach_handler_test.go new file mode 100644 index 0000000000..795db9c278 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_detach_handler_test.go @@ -0,0 +1,132 @@ +/* +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" + "errors" + "log/slog" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/vm/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +var _ = Describe("USBDeviceDetachHandler", func() { + var ctx context.Context + var mockVirtCl *mockVirtClient + var handler *USBDeviceDetachHandler + var vmState state.VirtualMachineState + + BeforeEach(func() { + ctx = logger.ToContext(context.TODO(), slog.Default()) + mockVirtCl = newMockVirtClient() + }) + + DescribeTable("Handle detach matrix", + func(inSpec, hasAttachedStatus, usbReady bool, expectDetachCalls int, expectFirstDetachName string) { + spec := []v1alpha2.USBDeviceSpecRef{} + if inSpec { + spec = append(spec, v1alpha2.USBDeviceSpecRef{Name: "usb-device-1"}) + } + + status := []v1alpha2.USBDeviceStatusRef{} + if hasAttachedStatus { + status = append(status, v1alpha2.USBDeviceStatusRef{Name: "usb-device-1", Attached: true}) + } + + vm := &v1alpha2.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vm", Namespace: "default", UID: types.UID("vm-uid")}, + Spec: v1alpha2.VirtualMachineSpec{USBDevices: spec}, + Status: v1alpha2.VirtualMachineStatus{Phase: v1alpha2.MachineRunning, USBDevices: status}, + } + + objs := []client.Object{} + if inSpec { + conds := []metav1.Condition{} + vendor := "" + if usbReady { + vendor = "1234" + conds = append(conds, metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue}) + } + objs = append(objs, &v1alpha2.USBDevice{ + ObjectMeta: metav1.ObjectMeta{Name: "usb-device-1", Namespace: "default"}, + Status: v1alpha2.USBDeviceStatus{ + Attributes: v1alpha2.NodeUSBDeviceAttributes{VendorID: vendor, ProductID: "5678"}, + NodeName: "node-1", + Conditions: conds, + }, + }) + } + + fakeClient, _, st := setupEnvironment(vm, objs...) + vmState = st + handler = NewUSBDeviceDetachHandler(fakeClient, mockVirtCl) + + result, err := handler.Handle(ctx, vmState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + _ = mockVirtCl.VirtualMachines("default") + calls := mockVirtCl.vmClients["default"].removeResourceClaimCalls + Expect(calls).To(HaveLen(expectDetachCalls)) + if expectDetachCalls > 0 { + Expect(calls[0].Name).To(Equal(expectFirstDetachName)) + } + }, + Entry("removed from spec", false, true, true, 1, "usb-device-1"), + Entry("in spec and ready", true, true, true, 0, ""), + Entry("in spec but not ready", true, true, false, 1, "usb-device-1"), + Entry("no attached status means no detach", true, false, true, 0, ""), + ) + + DescribeTable("detach failure handling", + func(removeErr error, expectErr bool) { + vm := &v1alpha2.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vm", Namespace: "default", UID: types.UID("vm-uid")}, + Spec: v1alpha2.VirtualMachineSpec{USBDevices: []v1alpha2.USBDeviceSpecRef{}}, + Status: v1alpha2.VirtualMachineStatus{ + Phase: v1alpha2.MachineRunning, + USBDevices: []v1alpha2.USBDeviceStatusRef{{Name: "usb-device-1", Attached: true}}, + }, + } + + fakeClient, _, st := setupEnvironment(vm) + vmState = st + handler = NewUSBDeviceDetachHandler(fakeClient, mockVirtCl) + + mockVM := mockVirtCl.VirtualMachines("default").(*mockVirtualMachines) + mockVM.removeResourceClaimErr = removeErr + + _, err := handler.Handle(ctx, vmState) + if expectErr { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).NotTo(HaveOccurred()) + } + }, + Entry("returns error", errors.New("boom"), true), + Entry("ignores not found", nil, false), + ) +}) 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..c4f0000232 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_handler.go @@ -0,0 +1,185 @@ +/* +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" + "strconv" + "strings" + + resourcev1 "k8s.io/api/resource/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + virtv1 "kubevirt.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/vm/internal/state" + 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" + 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 +} + +type usbDeviceHandlerBase struct { + client client.Client + virtClient VirtClient +} + +func (h *usbDeviceHandlerBase) getResourceClaimTemplateName(usbDeviceName string) string { + return usbDeviceName + "-template" +} + +func (h *usbDeviceHandlerBase) getResourceClaimRequestName(usbDeviceName string) string { + return "req-" + usbDeviceName +} + +func (h *usbDeviceHandlerBase) getResourceClaimTemplate( + ctx context.Context, + namespace string, + templateName string, +) (*resourcev1.ResourceClaimTemplate, error) { + template := &resourcev1.ResourceClaimTemplate{} + key := types.NamespacedName{ + Name: templateName, + Namespace: namespace, + } + if err := h.client.Get(ctx, key, template); err != nil { + return nil, fmt.Errorf("failed to get ResourceClaimTemplate: %w", err) + } + return template, nil +} + +func (h *usbDeviceHandlerBase) isUSBDeviceReady(usbDevice *v1alpha2.USBDevice) bool { + if usbDevice.Status.Attributes.VendorID == "" || usbDevice.Status.Attributes.ProductID == "" { + return false + } + if usbDevice.Status.NodeName == "" { + return false + } + readyCondition, found := conditions.GetCondition(usbdevicecondition.ReadyType, usbDevice.Status.Conditions) + return found && readyCondition.Status == metav1.ConditionTrue +} + +func (h *usbDeviceHandlerBase) hostDeviceReadyByName(kvvmi *virtv1.VirtualMachineInstance) map[string]bool { + hostDeviceReadyByName := make(map[string]bool) + if kvvmi == nil || kvvmi.Status.DeviceStatus == nil { + return hostDeviceReadyByName + } + + for _, hostDeviceStatus := range kvvmi.Status.DeviceStatus.HostDeviceStatuses { + if hostDeviceStatus.Name == "" { + continue + } + + hostDeviceReadyByName[hostDeviceStatus.Name] = hostDeviceReadyByName[hostDeviceStatus.Name] || hostDeviceStatus.Phase == virtv1.DeviceReady + } + + return hostDeviceReadyByName +} + +func (h *usbDeviceHandlerBase) attachUSBDevice( + ctx context.Context, + vm *v1alpha2.VirtualMachine, + usbDeviceName string, + templateName string, + requestName string, +) error { + opts := subv1alpha2.VirtualMachineAddResourceClaim{ + Name: usbDeviceName, + ResourceClaimTemplateName: templateName, + RequestName: requestName, + } + return h.virtClient.VirtualMachines(vm.Namespace).AddResourceClaim(ctx, vm.Name, opts) +} + +func (h *usbDeviceHandlerBase) detachUSBDevice( + ctx context.Context, + vm *v1alpha2.VirtualMachine, + usbDeviceName string, +) error { + opts := subv1alpha2.VirtualMachineRemoveResourceClaim{ + Name: usbDeviceName, + } + return h.virtClient.VirtualMachines(vm.Namespace).RemoveResourceClaim(ctx, vm.Name, opts) +} + +func (h *usbDeviceHandlerBase) getUSBAddressFromKVVMI(deviceName string, kvvmi *virtv1.VirtualMachineInstance) *v1alpha2.USBAddress { + if kvvmi == nil || kvvmi.Status.DeviceStatus == nil { + return nil + } + for _, st := range kvvmi.Status.DeviceStatus.HostDeviceStatuses { + if st.Name != deviceName { + continue + } + + if st.Address == "" { + continue + } + + return parseUSBAddress(st.Address) + } + return nil +} + +func parseUSBAddress(address string) *v1alpha2.USBAddress { + parts := strings.Split(address, ":") + if len(parts) != 2 { + return nil + } + + busPart := strings.TrimSpace(parts[0]) + portPart := strings.TrimSpace(parts[1]) + if busPart == "" || portPart == "" { + return nil + } + + bus, err := strconv.Atoi(busPart) + if err != nil { + return nil + } + + port, err := strconv.Atoi(portPart) + if err != nil { + return nil + } + + return &v1alpha2.USBAddress{ + Bus: bus, + Port: port, + } +} + +func (h *usbDeviceHandlerBase) hasPendingMigrationOp(ctx context.Context, s state.VirtualMachineState) (bool, error) { + vmops, err := s.VMOPs(ctx) + if err != nil { + return false, err + } + + for _, vmop := range vmops { + if (vmop.Spec.Type == v1alpha2.VMOPTypeEvict || vmop.Spec.Type == v1alpha2.VMOPTypeMigrate) && vmop.Status.Phase == v1alpha2.VMOPPhasePending { + return true, nil + } + } + return false, nil +} diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_migration_handler.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_migration_handler.go new file mode 100644 index 0000000000..4becb25e47 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_migration_handler.go @@ -0,0 +1,96 @@ +/* +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/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" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vmcondition" +) + +const nameUSBDeviceMigrationHandler = "USBDeviceMigrationHandler" + +// USBDeviceMigrationHandler unplugs all attached USB devices when migration is pending. +// After migration completes, the normal attach handler will plug them back (DRA over network). +func NewUSBDeviceMigrationHandler(cl client.Client, virtClient VirtClient) *USBDeviceMigrationHandler { + return &USBDeviceMigrationHandler{ + usbDeviceHandlerBase: usbDeviceHandlerBase{ + client: cl, + virtClient: virtClient, + }, + } +} + +type USBDeviceMigrationHandler struct { + usbDeviceHandlerBase +} + +func (h *USBDeviceMigrationHandler) Name() string { + return nameUSBDeviceMigrationHandler +} + +func (h *USBDeviceMigrationHandler) Handle(ctx context.Context, s state.VirtualMachineState) (reconcile.Result, error) { + log := logger.FromContext(ctx).With(logger.SlogHandler(nameUSBDeviceMigrationHandler)) + + if s.VirtualMachine().IsEmpty() { + return reconcile.Result{}, nil + } + + vm := s.VirtualMachine().Current() + changed := s.VirtualMachine().Changed() + + migratingCond, exists := conditions.GetCondition(vmcondition.TypeMigratable, changed.Status.Conditions) + if !exists { + return reconcile.Result{}, nil + } + + if migratingCond.Reason != vmcondition.ReasonUSBShouldBeMigrating.String() { + return reconcile.Result{}, nil + } + + hasPendingMigration, err := h.hasPendingMigrationOp(ctx, s) + if err != nil { + return reconcile.Result{}, err + } + + if !hasPendingMigration { + return reconcile.Result{}, nil + } + + for i := 0; i < len(changed.Status.USBDevices); i++ { + ref := &changed.Status.USBDevices[i] + err := h.detachUSBDevice(ctx, vm, ref.Name) + if err != nil && !apierrors.IsNotFound(err) { + log.Error("failed to unplug USB device for migration", "error", err, "usbDevice", ref.Name) + return reconcile.Result{}, fmt.Errorf("failed to unplug USB device for migration %s: %w", ref.Name, err) + } + + ref.Attached = false + ref.Address = nil + ref.Hotplugged = false + } + + return reconcile.Result{}, nil +} diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_migration_handler_test.go b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_migration_handler_test.go new file mode 100644 index 0000000000..e3374273d8 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/usb_device_migration_handler_test.go @@ -0,0 +1,151 @@ +/* +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" + "errors" + "log/slog" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/controller/vm/internal/state" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vmcondition" +) + +var _ = Describe("USBDeviceMigrationHandler", func() { + var ctx context.Context + var mockVirtCl *mockVirtClient + var handler *USBDeviceMigrationHandler + var vmState state.VirtualMachineState + + BeforeEach(func() { + ctx = logger.ToContext(context.TODO(), slog.Default()) + mockVirtCl = newMockVirtClient() + }) + + DescribeTable("Handle migration matrix", + func(reason string, vmopPhase v1alpha2.VMOPPhase, expectDetach bool) { + vm := &v1alpha2.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vm", Namespace: "default", UID: types.UID("vm-uid")}, + Status: v1alpha2.VirtualMachineStatus{ + USBDevices: []v1alpha2.USBDeviceStatusRef{{Name: "usb-device-1", Attached: true}}, + Conditions: []metav1.Condition{{ + Type: string(vmcondition.TypeMigratable), + Status: metav1.ConditionFalse, + Reason: reason, + }}, + }, + } + + objs := []client.Object{} + if vmopPhase != "" { + objs = append(objs, &v1alpha2.VirtualMachineOperation{ + ObjectMeta: metav1.ObjectMeta{Name: "vmop-1", Namespace: "default"}, + Spec: v1alpha2.VirtualMachineOperationSpec{VirtualMachine: "test-vm", Type: v1alpha2.VMOPTypeMigrate}, + Status: v1alpha2.VirtualMachineOperationStatus{Phase: vmopPhase}, + }) + } + + fakeClient, _, st := setupEnvironment(vm, objs...) + vmState = st + handler = NewUSBDeviceMigrationHandler(fakeClient, mockVirtCl) + + _, err := handler.Handle(ctx, vmState) + Expect(err).NotTo(HaveOccurred()) + + _ = mockVirtCl.VirtualMachines("default") + calls := len(mockVirtCl.vmClients["default"].removeResourceClaimCalls) + if expectDetach { + Expect(calls).To(Equal(1)) + } else { + Expect(calls).To(Equal(0)) + } + }, + Entry("usb migration reason + pending vmop", vmcondition.ReasonUSBShouldBeMigrating.String(), v1alpha2.VMOPPhasePending, true), + Entry("usb migration reason + inprogress vmop", vmcondition.ReasonUSBShouldBeMigrating.String(), v1alpha2.VMOPPhaseInProgress, false), + Entry("other migratable reason", vmcondition.ReasonMigratable.String(), v1alpha2.VMOPPhasePending, false), + ) + + It("should detach all USB devices when migration is pending", func() { + vm := &v1alpha2.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vm", Namespace: "default", UID: types.UID("vm-uid")}, + Status: v1alpha2.VirtualMachineStatus{ + USBDevices: []v1alpha2.USBDeviceStatusRef{{Name: "usb-device-1", Attached: true, Hotplugged: true}, {Name: "usb-device-2", Attached: true}}, + Conditions: []metav1.Condition{{ + Type: string(vmcondition.TypeMigratable), + Status: metav1.ConditionFalse, + Reason: vmcondition.ReasonUSBShouldBeMigrating.String(), + }}, + }, + } + vmop := &v1alpha2.VirtualMachineOperation{ + ObjectMeta: metav1.ObjectMeta{Name: "vmop-1", Namespace: "default"}, + Spec: v1alpha2.VirtualMachineOperationSpec{VirtualMachine: "test-vm", Type: v1alpha2.VMOPTypeMigrate}, + Status: v1alpha2.VirtualMachineOperationStatus{Phase: v1alpha2.VMOPPhasePending}, + } + + fakeClient, vmResource, st := setupEnvironment(vm, vmop) + vmState = st + handler = NewUSBDeviceMigrationHandler(fakeClient, mockVirtCl) + + result, err := handler.Handle(ctx, vmState) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + + mockVM := mockVirtCl.vmClients["default"] + Expect(mockVM.removeResourceClaimCalls).To(HaveLen(2)) + Expect(vmResource.Changed().Status.USBDevices[0].Attached).To(BeFalse()) + Expect(vmResource.Changed().Status.USBDevices[0].Hotplugged).To(BeFalse()) + }) + + It("should return error when detach fails during migration", func() { + vm := &v1alpha2.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{Name: "test-vm", Namespace: "default", UID: types.UID("vm-uid")}, + Status: v1alpha2.VirtualMachineStatus{ + USBDevices: []v1alpha2.USBDeviceStatusRef{{Name: "usb-device-1", Attached: true}}, + Conditions: []metav1.Condition{{ + Type: string(vmcondition.TypeMigratable), + Status: metav1.ConditionFalse, + Reason: vmcondition.ReasonUSBShouldBeMigrating.String(), + }}, + }, + } + vmop := &v1alpha2.VirtualMachineOperation{ + ObjectMeta: metav1.ObjectMeta{Name: "vmop-1", Namespace: "default"}, + Spec: v1alpha2.VirtualMachineOperationSpec{VirtualMachine: "test-vm", Type: v1alpha2.VMOPTypeMigrate}, + Status: v1alpha2.VirtualMachineOperationStatus{Phase: v1alpha2.VMOPPhasePending}, + } + + fakeClient, _, st := setupEnvironment(vm, vmop) + vmState = st + handler = NewUSBDeviceMigrationHandler(fakeClient, mockVirtCl) + + mockVM := mockVirtCl.VirtualMachines("default").(*mockVirtualMachines) + mockVM.removeResourceClaimErr = errors.New("boom") + + _, err := handler.Handle(ctx, vmState) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/validators/usb_devices_validator.go b/images/virtualization-artifact/pkg/controller/vm/internal/validators/usb_devices_validator.go new file mode 100644 index 0000000000..f7bfe55898 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/validators/usb_devices_validator.go @@ -0,0 +1,99 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validators + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/equality" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type USBDevicesValidator struct { + client client.Client +} + +func NewUSBDevicesValidator(client client.Client) *USBDevicesValidator { + return &USBDevicesValidator{client: client} +} + +func (v *USBDevicesValidator) ValidateCreate(ctx context.Context, vm *v1alpha2.VirtualMachine) (admission.Warnings, error) { + return v.validateUSBDevicesUnique(ctx, vm, "", nil) +} + +func (v *USBDevicesValidator) ValidateUpdate(ctx context.Context, oldVM, newVM *v1alpha2.VirtualMachine) (admission.Warnings, error) { + if equality.Semantic.DeepEqual(oldVM.Spec.USBDevices, newVM.Spec.USBDevices) { + return nil, nil + } + + return v.validateUSBDevicesUnique(ctx, newVM, newVM.Name, getUSBDeviceNames(oldVM.Spec.USBDevices)) +} + +// validateUSBDevicesUnique checks that each USB device is not used by another VM. +// currentVMName is empty for Create (no VM to exclude), or VM name for Update (exclude current VM from conflict check). +func (v *USBDevicesValidator) validateUSBDevicesUnique(ctx context.Context, vm *v1alpha2.VirtualMachine, currentVMName string, oldUSBDevices map[string]struct{}) (admission.Warnings, error) { + if len(vm.Spec.USBDevices) == 0 { + return nil, nil + } + + seen := make(map[string]struct{}) + for _, ref := range vm.Spec.USBDevices { + if ref.Name == "" { + continue + } + if _, exists := seen[ref.Name]; exists { + return nil, fmt.Errorf("duplicate USB device %s in spec.usbDevices", ref.Name) + } + seen[ref.Name] = struct{}{} + + if _, exists := oldUSBDevices[ref.Name]; exists { + continue + } + + var vmList v1alpha2.VirtualMachineList + if err := v.client.List(ctx, &vmList, client.InNamespace(vm.Namespace), client.MatchingFields{indexer.IndexFieldVMByUSBDevice: ref.Name}); err != nil { + return nil, fmt.Errorf("failed to list VMs using USB device %s: %w", ref.Name, err) + } + + for i := range vmList.Items { + otherVM := &vmList.Items[i] + if otherVM.Name == currentVMName { + continue + } + return nil, fmt.Errorf("USB device %s is already used by VirtualMachine %s/%s", ref.Name, otherVM.Namespace, otherVM.Name) + } + } + + return nil, nil +} + +func getUSBDeviceNames(refs []v1alpha2.USBDeviceSpecRef) map[string]struct{} { + names := make(map[string]struct{}, len(refs)) + for _, ref := range refs { + if ref.Name == "" { + continue + } + names[ref.Name] = struct{}{} + } + + return names +} diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/validators/usb_devices_validator_test.go b/images/virtualization-artifact/pkg/controller/vm/internal/validators/usb_devices_validator_test.go new file mode 100644 index 0000000000..1a38cd344e --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vm/internal/validators/usb_devices_validator_test.go @@ -0,0 +1,98 @@ +/* +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 validators + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func TestUSBDevicesValidatorValidateUpdate(t *testing.T) { + tests := []struct { + name string + oldUSB []v1alpha2.USBDeviceSpecRef + newUSB []v1alpha2.USBDeviceSpecRef + existing []client.Object + wantError bool + }{ + { + name: "should skip conflict check for unchanged usb devices", + oldUSB: []v1alpha2.USBDeviceSpecRef{{Name: "usb-legacy"}}, + newUSB: []v1alpha2.USBDeviceSpecRef{{Name: "usb-legacy"}, {Name: "usb-new"}}, + existing: []client.Object{newVirtualMachine("vm-other", []v1alpha2.USBDeviceSpecRef{{Name: "usb-legacy"}})}, + wantError: false, + }, + { + name: "should fail when new usb device is already used by another vm", + oldUSB: []v1alpha2.USBDeviceSpecRef{{Name: "usb-legacy"}}, + newUSB: []v1alpha2.USBDeviceSpecRef{{Name: "usb-legacy"}, {Name: "usb-new"}}, + existing: []client.Object{newVirtualMachine("vm-other", []v1alpha2.USBDeviceSpecRef{{Name: "usb-new"}})}, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oldVM := newVirtualMachine("vm-current", tt.oldUSB) + newVM := newVirtualMachine("vm-current", tt.newUSB) + + objects := []client.Object{oldVM} + objects = append(objects, tt.existing...) + + validator := NewUSBDevicesValidator(newFakeClientWithUSBVMIndexer(t, objects...)) + _, err := validator.ValidateUpdate(t.Context(), oldVM, newVM) + + if tt.wantError && err == nil { + t.Fatalf("expected error, got nil") + } + + if !tt.wantError && err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + } +} + +func newVirtualMachine(name string, usb []v1alpha2.USBDeviceSpecRef) *v1alpha2.VirtualMachine { + return &v1alpha2.VirtualMachine{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Spec: v1alpha2.VirtualMachineSpec{USBDevices: usb}, + } +} + +func newFakeClientWithUSBVMIndexer(t *testing.T, objects ...client.Object) client.Client { + t.Helper() + + scheme := runtime.NewScheme() + if err := v1alpha2.AddToScheme(scheme); err != nil { + t.Fatalf("failed to add virtualization API scheme: %v", err) + } + + vmObj, vmField, vmExtractValue := indexer.IndexVMByUSBDevice() + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithIndex(vmObj, vmField, vmExtractValue). + Build() +} 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..dd22ee3236 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, @@ -65,6 +67,8 @@ func SetupController( internal.NewIPAMHandler(netmanager.NewIPAM(), client, recorder), internal.NewMACHandler(netmanager.NewMACManager(), client, recorder), internal.NewBlockDeviceHandler(client, blockDeviceService), + internal.NewUSBDeviceDetachHandler(client, virtClient), + internal.NewUSBDeviceAttachHandler(client, virtClient), internal.NewProvisioningHandler(client), internal.NewAgentHandler(), internal.NewFilesystemHandler(), @@ -80,6 +84,7 @@ func SetupController( internal.NewFirmwareHandler(firmwareImage), internal.NewEvictHandler(), internal.NewStatisticHandler(client), + internal.NewUSBDeviceMigrationHandler(client, virtClient), } r := NewReconciler(client, handlers...) 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-artifact/pkg/controller/vm/vm_webhook.go b/images/virtualization-artifact/pkg/controller/vm/vm_webhook.go index 34b6ac0db4..cb56986437 100644 --- a/images/virtualization-artifact/pkg/controller/vm/vm_webhook.go +++ b/images/virtualization-artifact/pkg/controller/vm/vm_webhook.go @@ -55,6 +55,7 @@ func NewValidator(client client.Client, service *service.BlockDeviceService, fea validators.NewCPUCountValidator(), validators.NewNetworksValidator(featureGate), validators.NewFirstDiskValidator(client), + validators.NewUSBDevicesValidator(client), }, log: log.With("webhook", "validation"), } diff --git a/images/virtualization-artifact/pkg/controller/vmchange/compare.go b/images/virtualization-artifact/pkg/controller/vmchange/compare.go index 02fad60b67..54350e7d96 100644 --- a/images/virtualization-artifact/pkg/controller/vmchange/compare.go +++ b/images/virtualization-artifact/pkg/controller/vmchange/compare.go @@ -41,6 +41,7 @@ var specComparators = []SpecFieldsComparator{ compareBlockDevices, compareProvisioning, compareNetworks, + compareUSBDevices, } type VMClassSpecFieldsComparator func(prev, next *v1alpha2.VirtualMachineClassSpec) []FieldChange diff --git a/images/virtualization-artifact/pkg/controller/vmchange/usb_change.go b/images/virtualization-artifact/pkg/controller/vmchange/usb_change.go new file mode 100644 index 0000000000..3642b06469 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmchange/usb_change.go @@ -0,0 +1,36 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package vmchange + +import ( + "reflect" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func compareUSBDevices(current, desired *v1alpha2.VirtualMachineSpec) []FieldChange { + currentValue := NewValue(current.USBDevices, current.USBDevices == nil, false) + desiredValue := NewValue(desired.USBDevices, desired.USBDevices == nil, false) + + return compareValues( + "usbDevices", + currentValue, + desiredValue, + reflect.DeepEqual(current.USBDevices, desired.USBDevices), + ActionApplyImmediate, + ) +} diff --git a/images/virtualization-artifact/pkg/featuregates/featuregate.go b/images/virtualization-artifact/pkg/featuregates/featuregate.go index 3b68e8c931..2e5cbbcf8c 100644 --- a/images/virtualization-artifact/pkg/featuregates/featuregate.go +++ b/images/virtualization-artifact/pkg/featuregates/featuregate.go @@ -20,6 +20,7 @@ import ( "github.com/spf13/pflag" "k8s.io/component-base/featuregate" + "github.com/deckhouse/virtualization-controller/pkg/kubeapi" "github.com/deckhouse/virtualization-controller/pkg/version" ) @@ -28,6 +29,7 @@ const ( AutoMigrationIfNodePlacementChanged featuregate.Feature = "AutoMigrationIfNodePlacementChanged" VolumeMigration featuregate.Feature = "VolumeMigration" TargetMigration featuregate.Feature = "TargetMigration" + USB featuregate.Feature = "USB" ) var featureSpecs = map[featuregate.Feature]featuregate.FeatureSpec{ @@ -50,6 +52,11 @@ var featureSpecs = map[featuregate.Feature]featuregate.FeatureSpec{ LockToDefault: true, PreRelease: featuregate.Alpha, }, + USB: { + Default: version.GetEdition() == version.EditionEE && kubeapi.ResourceV1Available(), + LockToDefault: true, + PreRelease: featuregate.Alpha, + }, } var ( diff --git a/images/virtualization-artifact/pkg/kubeapi/kubeapi.go b/images/virtualization-artifact/pkg/kubeapi/kubeapi.go new file mode 100644 index 0000000000..e82423b24a --- /dev/null +++ b/images/virtualization-artifact/pkg/kubeapi/kubeapi.go @@ -0,0 +1,56 @@ +/* +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 kubeapi + +import ( + "log/slog" + + resourcev1 "k8s.io/api/resource/v1" + "k8s.io/client-go/discovery" + "k8s.io/client-go/kubernetes" + "sigs.k8s.io/controller-runtime/pkg/client/config" +) + +var client kubernetes.Interface + +func init() { + restConfig := config.GetConfigOrDie() + client = kubernetes.NewForConfigOrDie(restConfig) +} + +func ResourceV1Available() bool { + enabled, err := isResourceV1Enabled(client) + if err != nil { + slog.Error("failed to check if resource v1 is enabled", "error", err) + } + return enabled +} + +func isResourceV1Enabled(clientset kubernetes.Interface) (bool, error) { + _, apis, err := clientset.Discovery().ServerGroupsAndResources() + if err != nil && !discovery.IsGroupDiscoveryFailedError(err) { + return false, err + } + + for _, api := range apis { + if api.GroupVersion == resourcev1.SchemeGroupVersion.String() { + return true, nil + } + } + + return false, nil +} diff --git a/templates/admission-policy.yaml b/templates/admission-policy.yaml index 597b40fb55..029d7ac860 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,57 @@ spec: matchResources: namespaceSelector: {} objectSelector: {} +--- +apiVersion: {{ $apiVersion }} +kind: ValidatingAdmissionPolicy +metadata: + {{- include "helm_lib_module_labels" (list .) | nindent 2 }} + name: d8-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" + - "UPDATE" + 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: d8-virtualization-usbdevice-access-policy-binding +spec: + policyName: d8-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 e62ef4f8a6..de7e903c93 100644 --- a/templates/virtualization-api/rbac-for-us.yaml +++ b/templates/virtualization-api/rbac-for-us.yaml @@ -84,6 +84,8 @@ rules: resources: - virtualmachines/addvolume - virtualmachines/removevolume + - virtualmachines/addresourceclaim + - virtualmachines/removeresourceclaim - virtualmachines/evacuatecancel - virtualmachines/addresourceclaim - virtualmachines/removeresourceclaim @@ -97,11 +99,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..501a0ae6da 100644 --- a/templates/virtualization-controller/rbac-for-us.yaml +++ b/templates/virtualization-controller/rbac-for-us.yaml @@ -30,6 +30,14 @@ rules: - list - watch - patch +- apiGroups: + - "" + resources: + - namespaces + verbs: + - get + - list + - watch - apiGroups: - networking.k8s.io resources: @@ -162,6 +170,8 @@ rules: - virtualmachines/migrate - virtualmachines/addvolume - virtualmachines/removevolume + - virtualmachines/addresourceclaim + - virtualmachines/removeresourceclaim - virtualmachines/cancelevacuation verbs: - update @@ -214,6 +224,8 @@ rules: - virtualdisksnapshots - virtualmachinesnapshots - virtualmachinerestores + - nodeusbdevices + - usbdevices verbs: - create - delete @@ -240,6 +252,8 @@ rules: - virtualdisksnapshots/finalizers - virtualmachinesnapshots/finalizers - virtualmachinerestores/finalizers + - nodeusbdevices/finalizers + - usbdevices/finalizers - virtualmachineipaddresses/status - virtualmachineipaddressleases/status - virtualmachinemacaddresses/status @@ -255,6 +269,8 @@ rules: - virtualdisksnapshots/status - virtualmachinesnapshots/status - virtualmachinerestores/status + - nodeusbdevices/status + - usbdevices/status verbs: - patch - update @@ -281,6 +297,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/test/e2e/go.mod b/test/e2e/go.mod index e28b16c045..9d533daf36 100644 --- a/test/e2e/go.mod +++ b/test/e2e/go.mod @@ -17,7 +17,7 @@ require ( k8s.io/client-go v0.34.2 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 kubevirt.io/api v1.6.2 - kubevirt.io/containerized-data-importer-api v1.60.3 + kubevirt.io/containerized-data-importer-api v1.63.1 sigs.k8s.io/controller-runtime v0.21.0 sigs.k8s.io/yaml v1.6.0 ) @@ -76,7 +76,7 @@ require ( google.golang.org/protobuf v1.36.6 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - k8s.io/apiextensions-apiserver v0.33.3 // indirect + k8s.io/apiextensions-apiserver v0.34.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect kubevirt.io/controller-lifecycle-operator-sdk/api v0.0.0-20220329064328-f3cc58c6ed90 // indirect diff --git a/test/e2e/go.sum b/test/e2e/go.sum index 63389b94bb..17706d8a06 100644 --- a/test/e2e/go.sum +++ b/test/e2e/go.sum @@ -238,8 +238,9 @@ github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= @@ -269,8 +270,9 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= @@ -622,8 +624,8 @@ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= kubevirt.io/api v1.6.2 h1:aoqZ4KsbOyDjLnuDw7H9wEgE/YTd/q5BBmYeQjJNizc= kubevirt.io/api v1.6.2/go.mod h1:p66fEy/g79x7VpgUwrkUgOoG2lYs5LQq37WM6JXMwj4= -kubevirt.io/containerized-data-importer-api v1.60.3 h1:kQEXi7scpzUa0RPf3/3MKk1Kmem0ZlqqiuK3kDF5L2I= -kubevirt.io/containerized-data-importer-api v1.60.3/go.mod h1:8mwrkZIdy8j/LmCyKt2wFXbiMavLUIqDaegaIF67CZs= +kubevirt.io/containerized-data-importer-api v1.63.1 h1:g2I9za0QEscRsQjOOK/MM0feywp1x9Gl8IyT6Egtg0g= +kubevirt.io/containerized-data-importer-api v1.63.1/go.mod h1:VGp35wxpLXU18b7cnEpmcThI3AjcZUSfg/Zfql44U4o= kubevirt.io/controller-lifecycle-operator-sdk/api v0.0.0-20220329064328-f3cc58c6ed90 h1:QMrd0nKP0BGbnxTqakhDZAUhGKxPiPiN5gSDqKUmGGc= kubevirt.io/controller-lifecycle-operator-sdk/api v0.0.0-20220329064328-f3cc58c6ed90/go.mod h1:018lASpFYBsYN6XwmA2TIrPCx6e0gviTd/ZNtSitKgc= sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8=