diff --git a/api/core/v1alpha2/virtual_machine_operation.go b/api/core/v1alpha2/virtual_machine_operation.go index 1f0344e180..1b11cdc0a4 100644 --- a/api/core/v1alpha2/virtual_machine_operation.go +++ b/api/core/v1alpha2/virtual_machine_operation.go @@ -46,9 +46,9 @@ type VirtualMachineOperation struct { // +kubebuilder:validation:XValidation:rule="self == oldSelf",message=".spec is immutable" // +kubebuilder:validation:XValidation:rule="self.type == 'Start' ? !has(self.force) || !self.force : true",message="The `Start` operation cannot be performed forcibly." -// +kubebuilder:validation:XValidation:rule="self.type == 'Migrate' ? !has(self.force) || !self.force : true",message="The `Migrate` operation cannot be performed forcibly." // +kubebuilder:validation:XValidation:rule="self.type == 'Restore' ? has(self.restore) : true",message="Restore requires restore field." // +kubebuilder:validation:XValidation:rule="self.type == 'Clone' ? has(self.clone) : true",message="Clone requires clone field." +// +kubebuilder:validation:XValidation:rule="!(has(self.migrate)) || self.type == 'Migrate'",message="spec.migrate can only be set when spec.type is 'Migrate'" type VirtualMachineOperationSpec struct { Type VMOPType `json:"type"` // Name of the virtual machine the operation is performed for. @@ -63,6 +63,8 @@ type VirtualMachineOperationSpec struct { Restore *VirtualMachineOperationRestoreSpec `json:"restore,omitempty"` // Clone defines the clone operation. Clone *VirtualMachineOperationCloneSpec `json:"clone,omitempty"` + // Defines the virtual machine migration operation. + Migrate *VirtualMachineOperationMigrateSpec `json:"migrate,omitempty"` } // VirtualMachineOperationRestoreSpec defines the restore operation. @@ -84,6 +86,15 @@ type VirtualMachineOperationCloneSpec struct { Customization *VirtualMachineOperationCloneCustomization `json:"customization,omitempty"` } +// VirtualMachineOperationMigrateSpec defines the restore operation. +type VirtualMachineOperationMigrateSpec struct { + // Node selector for scheduling the VM onto a node. Must match the target node's labels. + // [Same](https://kubernetes.io/docs/tasks/configure-pod-container/assign-pods-nodes/) as the Pod `spec.nodeSelector` field in Kubernetes. + // + // > The `nodeSelector` field is not available in the Community Edition. + NodeSelector map[string]string `json:"nodeSelector,omitempty"` +} + // +kubebuilder:validation:XValidation:rule="!has(self.namePrefix) || (size(self.namePrefix) >= 1 && size(self.namePrefix) <= 59)",message="namePrefix length must be between 1 and 59 characters if set" // +kubebuilder:validation:XValidation:rule="!has(self.nameSuffix) || (size(self.nameSuffix) >= 1 && size(self.nameSuffix) <= 59)",message="nameSuffix length must be between 1 and 59 characters if set" // VirtualMachineOperationCloneCustomization defines customization options for cloning. @@ -162,8 +173,8 @@ const ( // * `Start`: Start the virtual machine. // * `Stop`: Stop the virtual machine. // * `Restart`: Restart the virtual machine. -// * `Migrate` (deprecated): Migrate the virtual machine to another node where it can be started. -// * `Evict`: Migrate the virtual machine to another node where it can be started. +// * `Migrate`: Migrate the virtual machine to another node where it can run. +// * `Evict`: Evict the virtual machine to another node where it can run. // * `Restore`: Restore the virtual machine from a snapshot. // * `Clone`: Clone the virtual machine to a new virtual machine. // +kubebuilder:validation:Enum={Restart,Start,Stop,Migrate,Evict,Restore,Clone} diff --git a/api/core/v1alpha2/zz_generated.deepcopy.go b/api/core/v1alpha2/zz_generated.deepcopy.go index e1ad5e58e5..c64f428875 100644 --- a/api/core/v1alpha2/zz_generated.deepcopy.go +++ b/api/core/v1alpha2/zz_generated.deepcopy.go @@ -2659,6 +2659,29 @@ func (in *VirtualMachineOperationList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VirtualMachineOperationMigrateSpec) DeepCopyInto(out *VirtualMachineOperationMigrateSpec) { + *out = *in + if in.NodeSelector != nil { + in, out := &in.NodeSelector, &out.NodeSelector + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMachineOperationMigrateSpec. +func (in *VirtualMachineOperationMigrateSpec) DeepCopy() *VirtualMachineOperationMigrateSpec { + if in == nil { + return nil + } + out := new(VirtualMachineOperationMigrateSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VirtualMachineOperationRestoreSpec) DeepCopyInto(out *VirtualMachineOperationRestoreSpec) { *out = *in @@ -2693,6 +2716,11 @@ func (in *VirtualMachineOperationSpec) DeepCopyInto(out *VirtualMachineOperation *out = new(VirtualMachineOperationCloneSpec) (*in).DeepCopyInto(*out) } + if in.Migrate != nil { + in, out := &in.Migrate, &out.Migrate + *out = new(VirtualMachineOperationMigrateSpec) + (*in).DeepCopyInto(*out) + } return } diff --git a/crds/doc-ru-virtualmachineoperations.yaml b/crds/doc-ru-virtualmachineoperations.yaml index b471a20560..0c9713d792 100644 --- a/crds/doc-ru-virtualmachineoperations.yaml +++ b/crds/doc-ru-virtualmachineoperations.yaml @@ -15,8 +15,8 @@ spec: * `Start` — запустить виртуальную машину; * `Stop` — остановить виртуальную машину; * `Restart` — перезапустить виртуальную машину; - * `Migrate` (устаревшее значение) — мигрировать виртуальную машину на другой узел, доступный для запуска данной ВМ; - * `Evict` — мигрировать виртуальную машину на другой узел, доступный для запуска данной ВМ; + * `Migrate` — мигрировать виртуальную машину на другой узел, на котором её можно запустить; + * `Evict` — вытеснить виртуальную машину на другой узел, на котором её можно запустить; * `Restore` — восстановить виртуальную машину из снимка; * `Clone` — клонировать виртуальную машину. virtualMachineName: @@ -42,6 +42,16 @@ spec: virtualMachineSnapshotName: description: | Имя снимка виртуальной машины, который используется как источник для операции восстановления. + migrate: + description: | + Определяет операцию миграции виртуальной машины. + properties: + nodeSelector: + description: |- + Селектор меток узла для планирования виртуальной машины на узел. Должен соответствовать меткам целевого узла. + [По аналогии](https://kubernetes.io/docs/tasks/configure-pod-container/assign-pods-nodes/) с параметром подов `spec.nodeSelector` в Kubernetes. + + > Поле `nodeSelector` недоступно в Community Edition. clone: description: | Определяет операцию клонирования. diff --git a/crds/virtualmachineoperations.yaml b/crds/virtualmachineoperations.yaml index e617c8a5ac..86b75a01c4 100644 --- a/crds/virtualmachineoperations.yaml +++ b/crds/virtualmachineoperations.yaml @@ -161,6 +161,19 @@ spec: * Effect on `Restart` and `Stop`: operation performs immediately. * Effect on `Evict` and `Migrate`: enable the AutoConverge feature to force migration via CPU throttling if the `PreferSafe` or `PreferForced` policies are used for live migration. type: boolean + migrate: + description: Defines the virtual machine migration operation. + properties: + nodeSelector: + additionalProperties: + type: string + description: |- + Node selector for scheduling the VM onto a node. Must match the target node's labels. + [Same](https://kubernetes.io/docs/tasks/configure-pod-container/assign-pods-nodes/) as the Pod `spec.nodeSelector` field in Kubernetes. + + > The `nodeSelector` field is not available in the Community Edition. + type: object + type: object restore: description: Restore defines the restore operation. properties: @@ -191,8 +204,8 @@ spec: * `Start`: Start the virtual machine. * `Stop`: Stop the virtual machine. * `Restart`: Restart the virtual machine. - * `Migrate` (deprecated): Migrate the virtual machine to another node where it can be started. - * `Evict`: Migrate the virtual machine to another node where it can be started. + * `Migrate`: Migrate the virtual machine to another node where it can run. + * `Evict`: Evict the virtual machine to another node where it can run. * `Restore`: Restore the virtual machine from a snapshot. * `Clone`: Clone the virtual machine to a new virtual machine. enum: @@ -219,14 +232,12 @@ spec: rule: self == oldSelf - message: The `Start` operation cannot be performed forcibly. rule: "self.type == 'Start' ? !has(self.force) || !self.force : true" - - message: The `Migrate` operation cannot be performed forcibly. - rule: - "self.type == 'Migrate' ? !has(self.force) || !self.force : - true" - message: Restore requires restore field. rule: "self.type == 'Restore' ? has(self.restore) : true" - message: Clone requires clone field. rule: "self.type == 'Clone' ? has(self.clone) : true" + - message: spec.migrate can only be set when spec.type is 'Migrate' + rule: "!(has(self.migrate)) || self.type == 'Migrate'" status: properties: conditions: diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index a0755a8262..84610703a3 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -1623,12 +1623,13 @@ d8 v restart linux-vm A list of possible operations is given in the table below: -| d8 | vmop type | Action | -| -------------- | --------- | ------------------------------ | -| `d8 v stop` | `Stop` | Stop VM | -| `d8 v start` | `Start` | Start the VM | -| `d8 v restart` | `Restart` | Restart the VM | -| `d8 v evict` | `Evict` | Migrate the VM to another host | +| d8 | vmop type | Action | +| ---------------- | ----------- | ------------------------------ | +| `d8 v stop` | `Stop` | Stop VM | +| `d8 v start` | `Start` | Start the VM | +| `d8 v restart` | `Restart` | Restart the VM | +| `d8 v evict` | `Evict` | Evict the VM to another host | +| `d8 v migrate` | `Migrate` | Migrate the VM to another host | How to perform the operation in the web interface: @@ -2364,31 +2365,55 @@ We can see that it is currently running on the `virtlab-pt-1` node. To migrate a virtual machine from one node to another while taking into account VM placement requirements, use the following command: ```bash -d8 v evict -n [--force] +d8 v migrate -n [--force] [--target-node-name string] ``` Running this command creates a VirtualMachineOperations resource. When used during virtual machine migration, the `--force` flag activates a special mechanism called AutoConverge (for more details, see the [Migration with insufficient network bandwidth](#migration-with-insufficient-network-bandwidth) section). This mechanism automatically reduces the CPU load of the virtual machine (slows down its CPU) when it is necessary to speed up the completion of migration and help it complete successfully, even when the virtual machine memory transfer is too slow. Use this flag if a standard migration cannot complete due to high virtual machine activity. -You can also start the migration by creating a VirtualMachineOperations (`vmop`) resource with the `Evict` type manually: +To migrate a virtual machine to a specific target node, specify the node name using the `--target-node-name option`. For example, to migrate the virtual machine to the `production-1` node, run: + +```bash +d8 v migrate -n project-1 linux-vm --target-node-name production-1 +``` + +As a result, a virtual machine operation will be created with the specific node selector `kubernetes.io/hostname: production-1`, where `production-1` is the node name. + +You can also start the migration by manually creating a [VirtualMachineOperation](/modules/virtualization/cr.html#virtualmachineoperation) (`vmop`) resource of type `Migrate`: ```yaml d8 k create -f - <}} +To avoid a virtual machine becoming unschedulable, the node selector must not conflict with other placement rules such as virtual machine affinity, node selectors, and virtual machine class node selector rules. +{{< /alert >}} + +{{< alert level="info" >}} +Targeted migration to a specific node is not available in the Community Edition. + +If you don’t need to specify target node parameters, you can omit the `migrate` field or evict the virtual machine to another suitable node using the `d8 v evict` command, or by creating a [VirtualMachineOperation](/modules/virtualization/cr.html#virtualmachineoperation) resource of type `Evict`. +{{< /alert >}} + To track the migration of a virtual machine immediately after the `vmop` resource is created, run the command: ```bash @@ -2434,9 +2459,9 @@ The AutoConverge mechanism works in two stages: To configure the migration policy, use the [`.spec.liveMigrationPolicy`](/modules/virtualization/cr.html#virtualmachine-v1alpha2-spec-livemigrationpolicy) parameter in the virtual machine configuration. The following options are available: - `AlwaysSafe`: Migration is always performed without slowing down the CPU (AutoConverge is not used). Suitable for cases where maximum virtual machine performance is important, but it requires high network bandwidth. -- `PreferSafe` (used as the default policy): Migration is performed without slowing down the CPU (AutoConverge is not used). However, you can start migration with CPU slowdown using the VirtualMachineOperation resource with parameters `type=Evict` and `force=true`. +- `PreferSafe` (used as the default policy): Migration is performed without slowing down the CPU (AutoConverge is not used). However, you can start migration with CPU slowdown using the [VirtualMachineOperation](/modules/virtualization/cr.html#virtualmachineoperation) resource with parameters `type=Migrate` and `force=true`. - `AlwaysForced`: Migration always uses AutoConverge, meaning the CPU is slowed down when necessary. This guarantees migration completion even with poor network, but may reduce virtual machine performance. -- `PreferForced`: Migration uses AutoConverge, meaning the CPU is slowed down when necessary. However, you can start migration without slowing down the CPU using the VirtualMachineOperation resource with parameters `type=Evict` and `force=false`. +- `PreferForced`: Migration uses AutoConverge, meaning the CPU is slowed down when necessary. However, you can start migration without slowing down the CPU using the [VirtualMachineOperation](/modules/virtualization/cr.html#virtualmachineoperation) resource with parameters `type=Migrate` and `force=false`. #### Migration with insufficient network bandwidth @@ -2455,7 +2480,7 @@ Example of a situation where migration cannot be completed due to insufficient n ![](./images/livemigration-example.png) -Example of performing migration of the same virtual machine using the `--force` flag of the `d8 v evict` command (which enables the AutoConverge mechanism): here you can clearly see that the CPU frequency decreases step by step to reduce the memory change rate. +Example of migrating the same virtual machine using the `--force` flag of the `d8 v migrate` command (which enables AutoConverge). The example shows the CPU frequency decreasing step by step to reduce the memory change rate. ![](./images/livemigration-example-autoconverge.png) diff --git a/docs/USER_GUIDE.ru.md b/docs/USER_GUIDE.ru.md index a2e133ffc5..eee4b32221 100644 --- a/docs/USER_GUIDE.ru.md +++ b/docs/USER_GUIDE.ru.md @@ -1637,12 +1637,13 @@ d8 v restart linux-vm Перечень возможных операций приведен в таблице ниже: -| d8 | vmop type | Действие | -| -------------- | --------- | ----------------------------- | -| `d8 v stop` | `Stop` | Остановить ВМ | -| `d8 v start` | `Start` | Запустить ВМ | -| `d8 v restart` | `Restart` | Перезапустить ВМ | -| `d8 v evict` | `Evict` | Мигрировать ВМ на другой узел | +| d8 | vmop type | Действие | +| ---------------- | ----------- | ----------------------------- | +| `d8 v stop` | `Stop` | Остановить ВМ | +| `d8 v start` | `Start` | Запустить ВМ | +| `d8 v restart` | `Restart` | Перезапустить ВМ | +| `d8 v evict` | `Evict` | Выселить ВМ на другой узел | +| `d8 v migrate` | `Migrate` | Мигрировать ВМ на другой узел | Как выполнить операцию в веб-интерфейсе: @@ -2383,31 +2384,55 @@ linux-vm Running virtlab-pt-1 10.66.10.14 79 Для миграции виртуальной машины с одного узла на другой с учётом требований к её размещению используйте команду: ```bash -d8 v evict -n [--force] +d8 v migrate -n [--force] [--target-node-name string] ``` Выполнение этой команды приводит к созданию ресурса VirtualMachineOperations. Флаг `--force` при выполнении миграции виртуальной машины активирует специальный механизм AutoConverge (подробнее см. раздел [Миграции при недостаточной пропускной способности сети](#миграции-при-недостаточной-пропускной-способности-сети)). Этот механизм автоматически снижает нагрузку на процессор виртуальной машины (замедляет её CPU), если требуется ускорить завершение миграции и обеспечить её успешное выполнение, даже если передача памяти ВМ идёт слишком медленно. Используйте этот флаг, если стандартная миграция не может завершиться из-за высокой активности ВМ. -Запустить миграцию можно также создав ресурс VirtualMachineOperations (`vmop`) с типом `Evict` вручную: +Чтобы мигрировать виртуальную машину на конкретный целевой узел, укажите имя этого узла в опции `--target-node-name`. Например, для миграции на узел `production-1`, выполните команду: + +```bash +d8 v migrate -n project-1 linux-vm --target-node-name production-1 +``` + +В результате будет создана операция виртуальной машины с конкретным селектором узла `kubernetes.io/hostname: production-1`, где `production-1` — это имя узла. + +Запустить миграцию можно также, вручную создав ресурс [VirtualMachineOperation](/modules/virtualization/cr.html#virtualmachineoperation) (`vmop`) с типом `Migrate`: ```yaml d8 k create -f - <}} +Чтобы виртуальная машина не стала непланируемой, селектор узла не должен конфликтовать с другими правилами размещения, например с правилами affinity виртуальной машины, селекторами узлов и правилами селектора узлов класса виртуальной машины. +{{< /alert >}} + +{{< alert level="info" >}} +Целевая миграция на конкретный узел недоступна в версии Community Edition. + +Если вам не нужно указывать параметры целевого узла, вы можете опустить поле `migrate` или вытеснить виртуальную машину на другой подходящий узел, используя команду `d8 v evict` или создав ресурс [VirtualMachineOperation](/modules/virtualization/cr.html#virtualmachineoperation) типа `Evict`. +{{< /alert >}} + Для отслеживания миграции виртуальной машины сразу после создания ресурса `vmop`, выполните команду: ```bash @@ -2453,9 +2478,9 @@ linux-vm Running virtlab-pt-2 10.66.10.14 7 Для настройки политики миграции используйте [параметр `.spec.liveMigrationPolicy`](/modules/virtualization/cr.html#virtualmachine-v1alpha2-spec-livemigrationpolicy) в конфигурации виртуальной машины. Допустимые значения параметра: - `AlwaysSafe` — миграция всегда выполняется без замедления процессора (AutoConverge не используется). Подходит для случаев, когда важна максимальная производительность виртуальной машины, но требует высокой пропускной способности сети. -- `PreferSafe` (используется в качестве политики по умолчанию) — миграция выполняется без замедления процессора (AutoConverge не используется). Однако можно запустить миграцию с замедлением процессора, используя ресурс VirtualMachineOperation с параметрами `type=Evict` и `force=true`. +- `PreferSafe` (используется в качестве политики по умолчанию) — миграция выполняется без замедления процессора (AutoConverge не используется). Однако можно запустить миграцию с замедлением процессора, используя ресурс [VirtualMachineOperation](/modules/virtualization/cr.html#virtualmachineoperation) с параметрами `type=Migrate` и `force=true`. - `AlwaysForced` — миграция всегда использует AutoConverge, то есть процессор замедляется при необходимости. Это гарантирует завершение миграции даже при плохой сети, но может снизить производительность виртуальной машины. -- `PreferForced` — миграция использует AutoConverge, то есть процессор замедляется при необходимости. Однако можно запустить миграцию без замедления процессора, используя ресурс VirtualMachineOperation с параметрами `type=Evict` и `force=false`. +- `PreferForced` — миграция использует AutoConverge, то есть процессор замедляется при необходимости. Однако можно запустить миграцию без замедления процессора, используя ресурс [VirtualMachineOperation](/modules/virtualization/cr.html#virtualmachineoperation) с параметрами `type=Migrate` и `force=false`. #### Миграции при недостаточной пропускной способности сети @@ -2474,7 +2499,7 @@ linux-vm Running virtlab-pt-2 10.66.10.14 7 ![](./images/livemigration-example.ru.png) -Пример выполнения миграции той же виртуальной машины с использованием флага `--force` команды `d8 v evict` (который включает механизм AutoConverge): здесь хорошо видно, что частота процессора снижается поэтапно, чтобы уменьшить скорость изменения памяти. +Пример выполнения миграции той же виртуальной машины с использованием флага `--force` команды `d8 v migrate` (который включает механизм AutoConverge): здесь хорошо видно, что частота процессора снижается поэтапно, чтобы уменьшить скорость изменения содержимого памяти. ![](./images/livemigration-example-autoconverge.ru.png) diff --git a/images/virtualization-artifact/Taskfile.yaml b/images/virtualization-artifact/Taskfile.yaml index 122ad42a16..03641deba1 100644 --- a/images/virtualization-artifact/Taskfile.yaml +++ b/images/virtualization-artifact/Taskfile.yaml @@ -52,7 +52,8 @@ tasks: desc: "Run go unit tests" cmds: - | - go tool ginkgo -v -r pkg/ + go tool ginkgo -v -r --label-filter '!EE' pkg/ + go tool ginkgo -v -r -tags=EE --label-filter 'EE' pkg/ lint: desc: "Run linters locally" diff --git a/images/virtualization-artifact/pkg/builder/vm/option.go b/images/virtualization-artifact/pkg/builder/vm/option.go index c48fe809b4..25257723d3 100644 --- a/images/virtualization-artifact/pkg/builder/vm/option.go +++ b/images/virtualization-artifact/pkg/builder/vm/option.go @@ -17,6 +17,7 @@ limitations under the License. package vm import ( + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" "github.com/deckhouse/virtualization-controller/pkg/builder/meta" @@ -139,3 +140,12 @@ func WithNetwork(network v1alpha2.NetworksSpec) Option { vm.Spec.Networks = append(vm.Spec.Networks, network) } } + +func WithTolerations(t []corev1.Toleration) Option { + return func(vm *v1alpha2.VirtualMachine) { + if vm.Spec.Tolerations == nil { + vm.Spec.Tolerations = make([]corev1.Toleration, 0, len(t)) + } + vm.Spec.Tolerations = append(vm.Spec.Tolerations, t...) + } +} diff --git a/images/virtualization-artifact/pkg/builder/vmop/option.go b/images/virtualization-artifact/pkg/builder/vmop/option.go index 1a8010fb37..cb941b35b1 100644 --- a/images/virtualization-artifact/pkg/builder/vmop/option.go +++ b/images/virtualization-artifact/pkg/builder/vmop/option.go @@ -69,3 +69,12 @@ func WithVirtualMachineSnapshotName(name string) Option { vmop.Spec.Restore.VirtualMachineSnapshotName = name } } + +func WithVMOPMigrateNodeSelector(nodeSelector map[string]string) Option { + return func(vmop *v1alpha2.VirtualMachineOperation) { + if vmop.Spec.Migrate == nil { + vmop.Spec.Migrate = &v1alpha2.VirtualMachineOperationMigrateSpec{} + } + vmop.Spec.Migrate.NodeSelector = nodeSelector + } +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle_test.go b/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle_test.go index a543e52544..d5f4ae67d4 100644 --- a/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle_test.go +++ b/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle_test.go @@ -18,11 +18,13 @@ package handler import ( "context" + "fmt" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" + virtv1 "kubevirt.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" vmbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vm" @@ -88,6 +90,18 @@ var _ = Describe("LifecycleHandler", func() { return vm } + newVMOPMigrate := func(opts ...vmopbuilder.Option) *v1alpha2.VirtualMachineOperation { + options := []vmopbuilder.Option{ + vmopbuilder.WithName(name), + vmopbuilder.WithNamespace(namespace), + vmopbuilder.WithType(v1alpha2.VMOPTypeMigrate), + vmopbuilder.WithVirtualMachine(name), + } + options = append(options, opts...) + vmop := vmopbuilder.New(options...) + return vmop + } + DescribeTable("Evict operation for migration policy", func(vmop *v1alpha2.VirtualMachineOperation, vmPolicy v1alpha2.LiveMigrationPolicy, expectedPhase v1alpha2.VMOPPhase) { vm := newVM(vmPolicy) @@ -169,4 +183,40 @@ var _ = Describe("LifecycleHandler", func() { v1alpha2.VMOPPhasePending, ), ) + + DescribeTable("TargetMigration", func(vmPolicy v1alpha2.LiveMigrationPolicy, nodeSelector map[string]string) { + vm := newVM(vmPolicy) + vm.Status.Conditions = []metav1.Condition{ + { + Type: string(vmcondition.TypeMigrating), + Reason: string(vmcondition.ReasonReadyToMigrate), + }, + } + vmop := newVMOPMigrate(vmopbuilder.WithVMOPMigrateNodeSelector(nodeSelector)) + + fakeClient, err := testutil.NewFakeClientWithObjects(vmop, vm) + Expect(err).NotTo(HaveOccurred()) + + migrationService := service.NewMigrationService(fakeClient) + base := genericservice.NewBaseVMOPService(fakeClient, recorderMock) + + h := NewLifecycleHandler(fakeClient, migrationService, base, recorderMock) + _, err = h.Handle(ctx, vmop) + Expect(err).NotTo(HaveOccurred()) + + vmim := &virtv1.VirtualMachineInstanceMigration{} + err = fakeClient.Get(context.Background(), client.ObjectKey{Namespace: namespace, Name: fmt.Sprintf("vmop-%s", vmop.Name)}, vmim) + Expect(err).NotTo(HaveOccurred()) + + for k, v := range nodeSelector { + Expect(vmim.Spec.AddedNodeSelector).To(HaveKeyWithValue(k, v)) + } + }, + Entry( + "VMIM must have an AddedNodeSelector which is equal to the NodeSelector from VMOP.", + v1alpha2.PreferSafeMigrationPolicy, + map[string]string{"key": "value"}, + Label("EE"), + ), + ) }) diff --git a/images/virtualization-artifact/pkg/controller/vmop/migration/internal/service/migration.go b/images/virtualization-artifact/pkg/controller/vmop/migration/internal/service/migration.go index 138a7da43d..e7c47670ba 100644 --- a/images/virtualization-artifact/pkg/controller/vmop/migration/internal/service/migration.go +++ b/images/virtualization-artifact/pkg/controller/vmop/migration/internal/service/migration.go @@ -18,6 +18,7 @@ package service import ( "context" + "errors" "fmt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -27,6 +28,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/featuregates" "github.com/deckhouse/virtualization/api/core/v1alpha2" ) @@ -51,7 +53,7 @@ func (s MigrationService) IsApplicableForRunPolicy(runPolicy v1alpha2.RunPolicy) } func (s MigrationService) CreateMigration(ctx context.Context, vmop *v1alpha2.VirtualMachineOperation) error { - return client.IgnoreAlreadyExists(s.client.Create(ctx, &virtv1.VirtualMachineInstanceMigration{ + vmim := &virtv1.VirtualMachineInstanceMigration{ TypeMeta: metav1.TypeMeta{ APIVersion: virtv1.SchemeGroupVersion.String(), Kind: "VirtualMachineInstanceMigration", @@ -73,7 +75,19 @@ func (s MigrationService) CreateMigration(ctx context.Context, vmop *v1alpha2.Vi Spec: virtv1.VirtualMachineInstanceMigrationSpec{ VMIName: vmop.Spec.VirtualMachine, }, - })) + } + + if !featuregates.Default().Enabled(featuregates.TargetMigration) { + if vmop.Spec.Migrate != nil && vmop.Spec.Migrate.NodeSelector != nil { + return errors.New("the `nodeSelector` field is not available in the Community Edition version") + } + } + + if vmop.Spec.Migrate != nil { + vmim.Spec.AddedNodeSelector = vmop.Spec.Migrate.NodeSelector + } + + return client.IgnoreAlreadyExists(s.client.Create(ctx, vmim)) } func (s MigrationService) DeleteMigration(ctx context.Context, vmop *v1alpha2.VirtualMachineOperation) error { diff --git a/images/virtualization-artifact/pkg/controller/vmop/vmop_webhook.go b/images/virtualization-artifact/pkg/controller/vmop/vmop_webhook.go index 76116cfbb8..2a07d0224f 100644 --- a/images/virtualization-artifact/pkg/controller/vmop/vmop_webhook.go +++ b/images/virtualization-artifact/pkg/controller/vmop/vmop_webhook.go @@ -18,12 +18,16 @@ package vmop import ( "context" + "errors" + "fmt" + "k8s.io/apimachinery/pkg/util/validation" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" "github.com/deckhouse/deckhouse/pkg/log" "github.com/deckhouse/virtualization-controller/pkg/controller/validator" + "github.com/deckhouse/virtualization-controller/pkg/featuregates" "github.com/deckhouse/virtualization/api/core/v1alpha2" ) @@ -31,16 +35,38 @@ func NewValidator(c client.Client, log *log.Logger) admission.CustomValidator { return validator.NewValidator[*v1alpha2.VirtualMachineOperation](log. With("controller", "vmop-controller"). With("webhook", "validation"), - ).WithCreateValidators(&deprecateMigrateValidator{}) + ).WithCreateValidators(&nodeSelectorValidator{}) } -type deprecateMigrateValidator struct{} +type nodeSelectorValidator struct{} -func (v *deprecateMigrateValidator) ValidateCreate(_ context.Context, vmop *v1alpha2.VirtualMachineOperation) (admission.Warnings, error) { - // TODO: Delete me after v0.15 - if vmop.Spec.Type == v1alpha2.VMOPTypeMigrate { - return admission.Warnings{"The Migrate type is deprecated, consider using Evict operation"}, nil +func (n *nodeSelectorValidator) ValidateCreate(_ context.Context, vmop *v1alpha2.VirtualMachineOperation) (admission.Warnings, error) { + if !featuregates.Default().Enabled(featuregates.TargetMigration) { + if vmop.Spec.Migrate != nil && vmop.Spec.Migrate.NodeSelector != nil { + return admission.Warnings{}, errors.New("the `nodeSelector` field is not available in the Community Edition version") + } + } + + if vmop.Spec.Migrate != nil && vmop.Spec.Migrate.NodeSelector != nil { + err := n.validateNodeSelector(vmop.Spec.Migrate.NodeSelector) + if err != nil { + return admission.Warnings{}, nil + } } return admission.Warnings{}, nil } + +func (n *nodeSelectorValidator) validateNodeSelector(nodeSelector map[string]string) error { + for k, v := range nodeSelector { + if errs := validation.IsQualifiedName(k); len(errs) != 0 { + return fmt.Errorf("invalid label key in the `nodeSelector` field: %v", errs) + } + + if errs := validation.IsValidLabelValue(v); len(errs) != 0 { + return fmt.Errorf("invalid label value in the `nodeSelector` field: %v", errs) + } + } + + return nil +} diff --git a/images/virtualization-artifact/pkg/featuregates/featuregate.go b/images/virtualization-artifact/pkg/featuregates/featuregate.go index 00da8a3ee2..adb0bf6066 100644 --- a/images/virtualization-artifact/pkg/featuregates/featuregate.go +++ b/images/virtualization-artifact/pkg/featuregates/featuregate.go @@ -27,6 +27,7 @@ const ( SDN featuregate.Feature = "SDN" AutoMigrationIfNodePlacementChanged featuregate.Feature = "AutoMigrationIfNodePlacementChanged" VolumeMigration featuregate.Feature = "VolumeMigration" + TargetMigration featuregate.Feature = "TargetMigration" ) var featureSpecs = map[featuregate.Feature]featuregate.FeatureSpec{ @@ -44,6 +45,11 @@ var featureSpecs = map[featuregate.Feature]featuregate.FeatureSpec{ LockToDefault: true, PreRelease: featuregate.Alpha, }, + TargetMigration: { + Default: version.GetEdition() == version.EditionEE, + LockToDefault: true, + PreRelease: featuregate.Alpha, + }, } var ( diff --git a/src/cli/go.mod b/src/cli/go.mod index 9e2fbd99ba..3f5ac7dd55 100644 --- a/src/cli/go.mod +++ b/src/cli/go.mod @@ -49,7 +49,6 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect - github.com/openshift/api v0.0.0-20230503133300-8bbcb7ca7183 // indirect github.com/openshift/custom-resource-status v1.1.2 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_golang v1.22.0 // indirect diff --git a/src/cli/go.sum b/src/cli/go.sum index 47f332fd2c..8a459f691b 100644 --- a/src/cli/go.sum +++ b/src/cli/go.sum @@ -221,8 +221,6 @@ github.com/onsi/gomega v1.33.0/go.mod h1:+925n5YtiFsLzzafLUHzVMBpvvRAzrydIBiSIxj github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= -github.com/openshift/api v0.0.0-20230503133300-8bbcb7ca7183 h1:t/CahSnpqY46sQR01SoS+Jt0jtjgmhgE6lFmRnO4q70= -github.com/openshift/api v0.0.0-20230503133300-8bbcb7ca7183/go.mod h1:4VWG+W22wrB4HfBL88P40DxLEpSOaiBVxUnfalfJo9k= github.com/openshift/custom-resource-status v1.1.2 h1:C3DL44LEbvlbItfd8mT5jWrqPfHnSOQoQf/sypqA6A4= github.com/openshift/custom-resource-status v1.1.2/go.mod h1:DB/Mf2oTeiAmVVX1gN+NEqweonAPY0TKUwADizj8+ZA= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= @@ -592,12 +590,8 @@ k8s.io/utils v0.0.0-20211116205334-6203023598ed/go.mod h1:jPW/WVKK9YHAvNhRxK0md/ k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/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/api v1.3.1 h1:MoTNo/zvDlZ44c2ocXLPln8XTaQOeUodiYbEKrTCqv4= -kubevirt.io/api v1.3.1/go.mod h1:tCn7VAZktEvymk490iPSMPCmKM9UjbbfH2OsFR/IOLU= 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.57.0-alpha1 h1:IWo12+ei3jltSN5jQN1xjgakfvRSF3G3Rr4GXVOOy2I= -kubevirt.io/containerized-data-importer-api v1.57.0-alpha1/go.mod h1:Y/8ETgHS1GjO89bl682DPtQOYEU/1ctPFBz6Sjxm4DM= kubevirt.io/containerized-data-importer-api v1.60.3-0.20241105012228-50fbed985de9 h1:KTb8wO1Lxj220DX7d2Rdo9xovvlyWWNo3AVm2ua+1nY= kubevirt.io/containerized-data-importer-api v1.60.3-0.20241105012228-50fbed985de9/go.mod h1:SDJjLGhbPyayDqAqawcGmVNapBp0KodOQvhKPLVGCQU= kubevirt.io/controller-lifecycle-operator-sdk/api v0.0.0-20220329064328-f3cc58c6ed90 h1:QMrd0nKP0BGbnxTqakhDZAUhGKxPiPiN5gSDqKUmGGc= diff --git a/src/cli/internal/cmd/lifecycle/evict.go b/src/cli/internal/cmd/lifecycle/evict.go index 54bb5d0ed5..a65993a57a 100644 --- a/src/cli/internal/cmd/lifecycle/evict.go +++ b/src/cli/internal/cmd/lifecycle/evict.go @@ -31,7 +31,7 @@ func NewEvictCommand() *cobra.Command { Args: templates.ExactArgs("evict", 1), RunE: lifecycle.Run, } - AddCommandlineArgs(cmd.Flags(), &lifecycle.opts) + AddCommandLineArgs(cmd.Flags(), &lifecycle.opts) cmd.SetUsageTemplate(templates.UsageTemplate()) return cmd } diff --git a/src/cli/internal/cmd/lifecycle/lifecycle.go b/src/cli/internal/cmd/lifecycle/lifecycle.go index f699c66292..e0bac28d10 100644 --- a/src/cli/internal/cmd/lifecycle/lifecycle.go +++ b/src/cli/internal/cmd/lifecycle/lifecycle.go @@ -26,6 +26,7 @@ import ( "github.com/spf13/pflag" "golang.org/x/text/cases" "golang.org/x/text/language" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "github.com/deckhouse/virtualization/api/client/kubeclient" @@ -41,6 +42,7 @@ const ( Start Command = "start" Restart Command = "restart" Evict Command = "evict" + Migrate Command = "migrate" ) type Manager interface { @@ -48,18 +50,25 @@ type Manager interface { Start(ctx context.Context, name, namespace string) (msg string, err error) Restart(ctx context.Context, name, namespace string) (msg string, err error) Evict(ctx context.Context, name, namespace string) (msg string, err error) + Migrate(ctx context.Context, name, namespace, targetNodeName string) (msg string, err error) } func NewLifecycle(cmd Command) *Lifecycle { return &Lifecycle{ cmd: cmd, opts: DefaultOptions(), + migrationOpts: MigrationOpts{ + TargetNodeName: "", + }, } } +// TODO: Refactor this structure because `Lifecycle` is a common object +// and should not process custom flags for each subcommand like `Migrate`. type Lifecycle struct { - cmd Command - opts Options + cmd Command + opts Options + migrationOpts MigrationOpts } func DefaultOptions() Options { @@ -78,6 +87,10 @@ type Options struct { Timeout time.Duration } +type MigrationOpts struct { + TargetNodeName string +} + func (l *Lifecycle) Run(cmd *cobra.Command, args []string) error { client, defaultNamespace, _, err := clientconfig.ClientAndNamespaceFromContext(cmd.Context()) if err != nil { @@ -106,6 +119,9 @@ func (l *Lifecycle) Run(cmd *cobra.Command, args []string) error { case Evict: cmd.Printf("Evicting virtual machine %q\n", key.String()) msg, err = mgr.Evict(ctx, name, namespace) + case Migrate: + cmd.Printf("Migrating virtual machine %q\n", key.String()) + msg, err = mgr.Migrate(ctx, name, namespace, l.migrationOpts.TargetNodeName) default: return fmt.Errorf("invalid command %q", l.cmd) } @@ -154,6 +170,30 @@ func (l *Lifecycle) getManager(client kubeclient.Client) Manager { ) } +func (l *Lifecycle) ValidateNodeName(cmd *cobra.Command, nodeName string) error { + if nodeName == "" { + return nil + } + + client, _, _, err := clientconfig.ClientAndNamespaceFromContext(cmd.Context()) + if err != nil { + return err + } + + nodes, err := client.CoreV1().Nodes().List(context.Background(), metav1.ListOptions{}) + if err != nil { + return err + } + + for _, node := range nodes.Items { + if node.Name == nodeName { + return nil + } + } + + return fmt.Errorf("there is no node with the name %s in the cluster", nodeName) +} + const ( forceFlag, forceFlagShort = "force", "f" waitFlag, waitFlagShort = "wait", "w" @@ -161,13 +201,19 @@ const ( timeoutFlag, timeoutFlagShort = "timeout", "t" ) -func AddCommandlineArgs(flagset *pflag.FlagSet, opts *Options) { +func AddCommandLineArgs(flagset *pflag.FlagSet, opts *Options) { flagset.BoolVarP(&opts.Force, forceFlag, forceFlagShort, opts.Force, - fmt.Sprintf("--%s, -%s: Set this flag to force the operation.", forceFlag, forceFlagShort)) + "Set this flag to force the operation.") flagset.BoolVarP(&opts.WaitComplete, waitFlag, waitFlagShort, opts.WaitComplete, - fmt.Sprintf("--%s, -%s: Set this flag to wait for the operation to complete.", waitFlag, waitFlagShort)) + "Set this flag to wait for the operation to complete.") flagset.BoolVarP(&opts.CreateOnly, createOnlyFlag, createOnlyFlagShort, opts.CreateOnly, - fmt.Sprintf("--%s, -%s: Set this flag for create operation only.", createOnlyFlag, createOnlyFlagShort)) + "Set this flag for create operation only.") flagset.DurationVarP(&opts.Timeout, timeoutFlag, timeoutFlagShort, opts.Timeout, - fmt.Sprintf("--%s, -%s: Set this flag to change the timeout.", timeoutFlag, timeoutFlagShort)) + "Set this flag to change the timeout.") +} + +func AddCommandLineMigrationArgs(flagset *pflag.FlagSet, migrationOpts *MigrationOpts) { + flagset.StringVar(&migrationOpts.TargetNodeName, "target-node-name", "", + "Set the target node name for virtual machine migration.", + ) } diff --git a/src/cli/internal/cmd/lifecycle/migrate.go b/src/cli/internal/cmd/lifecycle/migrate.go new file mode 100644 index 0000000000..6f1e22ef25 --- /dev/null +++ b/src/cli/internal/cmd/lifecycle/migrate.go @@ -0,0 +1,46 @@ +/* +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 lifecycle + +import ( + "github.com/spf13/cobra" + + "github.com/deckhouse/virtualization/src/cli/internal/templates" +) + +func NewMigrateCommand() *cobra.Command { + lifecycle := NewLifecycle(Migrate) + + cmd := &cobra.Command{ + Use: "migrate (VirtualMachine)", + Short: "Migrate a virtual machine.", + Example: lifecycle.Usage(), + Args: templates.ExactArgs("migrate", 1), + PreRunE: func(cmd *cobra.Command, args []string) error { + err := lifecycle.ValidateNodeName(cmd, lifecycle.migrationOpts.TargetNodeName) + if err != nil { + return err + } + return nil + }, + RunE: lifecycle.Run, + } + AddCommandLineArgs(cmd.Flags(), &lifecycle.opts) + AddCommandLineMigrationArgs(cmd.Flags(), &lifecycle.migrationOpts) + cmd.SetUsageTemplate(templates.UsageTemplate()) + return cmd +} diff --git a/src/cli/internal/cmd/lifecycle/restart.go b/src/cli/internal/cmd/lifecycle/restart.go index 4645d17c9b..44552510d2 100644 --- a/src/cli/internal/cmd/lifecycle/restart.go +++ b/src/cli/internal/cmd/lifecycle/restart.go @@ -31,7 +31,7 @@ func NewRestartCommand() *cobra.Command { Args: templates.ExactArgs("restart", 1), RunE: lifecycle.Run, } - AddCommandlineArgs(cmd.Flags(), &lifecycle.opts) + AddCommandLineArgs(cmd.Flags(), &lifecycle.opts) cmd.SetUsageTemplate(templates.UsageTemplate()) return cmd } diff --git a/src/cli/internal/cmd/lifecycle/start.go b/src/cli/internal/cmd/lifecycle/start.go index 8a4005619c..cad458a9be 100644 --- a/src/cli/internal/cmd/lifecycle/start.go +++ b/src/cli/internal/cmd/lifecycle/start.go @@ -31,7 +31,7 @@ func NewStartCommand() *cobra.Command { Args: templates.ExactArgs("start", 1), RunE: lifecycle.Run, } - AddCommandlineArgs(cmd.Flags(), &lifecycle.opts) + AddCommandLineArgs(cmd.Flags(), &lifecycle.opts) cmd.SetUsageTemplate(templates.UsageTemplate()) return cmd } diff --git a/src/cli/internal/cmd/lifecycle/stop.go b/src/cli/internal/cmd/lifecycle/stop.go index d4a20d9d83..e09d52cb68 100644 --- a/src/cli/internal/cmd/lifecycle/stop.go +++ b/src/cli/internal/cmd/lifecycle/stop.go @@ -31,7 +31,7 @@ func NewStopCommand() *cobra.Command { Args: templates.ExactArgs("stop", 1), RunE: lifecycle.Run, } - AddCommandlineArgs(cmd.Flags(), &lifecycle.opts) + AddCommandLineArgs(cmd.Flags(), &lifecycle.opts) cmd.SetUsageTemplate(templates.UsageTemplate()) return cmd } diff --git a/src/cli/internal/cmd/lifecycle/vmop/vmop.go b/src/cli/internal/cmd/lifecycle/vmop/vmop.go index 4ab33d4db5..668a4c1749 100644 --- a/src/cli/internal/cmd/lifecycle/vmop/vmop.go +++ b/src/cli/internal/cmd/lifecycle/vmop/vmop.go @@ -21,6 +21,7 @@ import ( "fmt" "strings" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/types" @@ -93,6 +94,16 @@ func (v VirtualMachineOperation) Evict(ctx context.Context, vmName, vmNamespace return v.do(ctx, vmop, v.options.createOnly, v.options.waitComplete) } +func (v VirtualMachineOperation) Migrate(ctx context.Context, vmName, vmNamespace, targetNodeName string) (msg string, err error) { + vmop := v.newVMOP(vmName, vmNamespace, v1alpha2.VMOPTypeMigrate, v.options.force) + if targetNodeName != "" { + vmop.Spec.Migrate = &v1alpha2.VirtualMachineOperationMigrateSpec{ + NodeSelector: map[string]string{corev1.LabelHostname: targetNodeName}, + } + } + return v.do(ctx, vmop, v.options.createOnly, v.options.waitComplete) +} + func (v VirtualMachineOperation) do(ctx context.Context, vmop *v1alpha2.VirtualMachineOperation, createOnly, waitCompleted bool) (msg string, err error) { if createOnly { vmop, err = v.create(ctx, vmop) @@ -127,6 +138,8 @@ func (v VirtualMachineOperation) generateMsg(vmop *v1alpha2.VirtualMachineOperat sb.WriteString("restarted. ") case v1alpha2.VMOPTypeEvict: sb.WriteString("evicted.") + case v1alpha2.VMOPTypeMigrate: + sb.WriteString("migrated.") } } else { switch vmop.Spec.Type { @@ -138,6 +151,8 @@ func (v VirtualMachineOperation) generateMsg(vmop *v1alpha2.VirtualMachineOperat sb.WriteString("restarting. ") case v1alpha2.VMOPTypeEvict: sb.WriteString("evicting.") + case v1alpha2.VMOPTypeMigrate: + sb.WriteString("migrating.") } } diff --git a/src/cli/pkg/command/virtualization.go b/src/cli/pkg/command/virtualization.go index 7aece0ccbb..7115c3f8f0 100644 --- a/src/cli/pkg/command/virtualization.go +++ b/src/cli/pkg/command/virtualization.go @@ -96,6 +96,7 @@ func NewCommand(programName string) *cobra.Command { lifecycle.NewStopCommand(), lifecycle.NewRestartCommand(), lifecycle.NewEvictCommand(), + lifecycle.NewMigrateCommand(), optionsCmd, ) diff --git a/test/e2e/internal/object/vd.go b/test/e2e/internal/object/vd.go index bf581a0677..e7a0b8fe78 100644 --- a/test/e2e/internal/object/vd.go +++ b/test/e2e/internal/object/vd.go @@ -84,3 +84,15 @@ func NewGeneratedHTTPVDUbuntu(prefix, namespace string, opts ...vd.Option) *v1al baseOpts = append(baseOpts, opts...) return vd.New(baseOpts...) } + +func NewHTTPVDAlpineBIOS(name, namespace string, opts ...vd.Option) *v1alpha2.VirtualDisk { + baseOpts := []vd.Option{ + vd.WithName(name), + vd.WithNamespace(namespace), + vd.WithDataSourceHTTP(&v1alpha2.DataSourceHTTP{ + URL: ImageURLAlpineBIOS, + }), + } + baseOpts = append(baseOpts, opts...) + return vd.New(baseOpts...) +} diff --git a/test/e2e/internal/object/vm.go b/test/e2e/internal/object/vm.go index 442d268c06..386c7b543f 100644 --- a/test/e2e/internal/object/vm.go +++ b/test/e2e/internal/object/vm.go @@ -28,10 +28,9 @@ func NewMinimalVM(prefix, namespace string, opts ...vm.Option) *v1alpha2.Virtual baseOpts := []vm.Option{ vm.WithGenerateName(prefix), vm.WithNamespace(namespace), - vm.WithCPU(1, ptr.To("100%")), + vm.WithCPU(1, ptr.To("20%")), vm.WithMemory(*resource.NewQuantity(Mi256, resource.BinarySI)), vm.WithLiveMigrationPolicy(v1alpha2.AlwaysSafeMigrationPolicy), - vm.WithVirtualMachineClass(DefaultVMClass), vm.WithProvisioningUserData(DefaultCloudInit), } baseOpts = append(baseOpts, opts...) diff --git a/test/e2e/internal/rewrite/types.go b/test/e2e/internal/rewrite/types.go index a18459d0d8..ba25766dd6 100644 --- a/test/e2e/internal/rewrite/types.go +++ b/test/e2e/internal/rewrite/types.go @@ -18,6 +18,7 @@ package rewrite import ( "k8s.io/apimachinery/pkg/runtime/schema" + virtv1 "kubevirt.io/api/core/v1" cdiv1beta1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" ) @@ -29,6 +30,14 @@ func rewriteCDIV1beta1(resource string) schema.GroupVersionResource { } } +func rewriteVirtualizationV1(resource string) schema.GroupVersionResource { + return schema.GroupVersionResource{ + Group: "internal.virtualization.deckhouse.io", + Version: "v1", + Resource: resource, + } +} + func rewriteInternalVirtualizationResource(resource string) string { return "internalvirtualization" + resource } @@ -41,3 +50,12 @@ func (StorageProfile) GVR() schema.GroupVersionResource { resource := rewriteInternalVirtualizationResource("storageprofiles") return rewriteCDIV1beta1(resource) } + +type VirtualMachineInstanceMigration struct { + *virtv1.VirtualMachineInstanceMigration +} + +func (VirtualMachineInstanceMigration) GVR() schema.GroupVersionResource { + resource := rewriteInternalVirtualizationResource("virtualmachineinstancemigrations") + return rewriteVirtualizationV1(resource) +} diff --git a/test/e2e/vm/target_migration.go b/test/e2e/vm/target_migration.go new file mode 100644 index 0000000000..f962208285 --- /dev/null +++ b/test/e2e/vm/target_migration.go @@ -0,0 +1,168 @@ +/* +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 vm + +import ( + "context" + "errors" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + virtv1 "kubevirt.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/builder/vm" + vmopbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vmop" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/test/e2e/internal/framework" + "github.com/deckhouse/virtualization/test/e2e/internal/object" + "github.com/deckhouse/virtualization/test/e2e/internal/rewrite" + "github.com/deckhouse/virtualization/test/e2e/internal/util" +) + +const hostnameLabelKey = "kubernetes.io/hostname" + +var _ = Describe("TargetMigration", func() { + var ( + virtualMachine *v1alpha2.VirtualMachine + targetMigrationVMOP *v1alpha2.VirtualMachineOperation + + initialNodeName string + targetNodeSelector map[string]string + + f = framework.NewFramework("vm-target-migration") + ) + + BeforeEach(func() { + DeferCleanup(f.After) + f.Before() + }) + + It("checks a `VirtualMachine` migrate to the target `Node`", func() { + By("Environment preparation", func() { + virtualDisk := object.NewHTTPVDAlpineBIOS( + "vd-root", + f.Namespace().Name, + ) + + virtualMachine = object.NewMinimalVM( + "vm-", + f.Namespace().Name, + vm.WithBootloader(v1alpha2.BIOS), + vm.WithDisks(virtualDisk), + vm.WithTolerations([]corev1.Toleration{ + { + Key: "node-role.kubernetes.io/control-plane", + Operator: corev1.TolerationOpExists, + Effect: corev1.TaintEffectNoSchedule, + }, + }), + ) + + err := f.CreateWithDeferredDeletion(context.Background(), virtualDisk, virtualMachine) + Expect(err).NotTo(HaveOccurred()) + + util.UntilObjectPhase(string(v1alpha2.MachineRunning), framework.LongTimeout, virtualMachine) + }) + + By("Migrate the `VirtualMachine`", func() { + virtualMachine, err := f.Clients.VirtClient().VirtualMachines(f.Namespace().Name).Get(context.Background(), virtualMachine.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + + initialNodeName = virtualMachine.Status.Node + targetNodeSelector, err = defineTargetNodeSelector(f, initialNodeName) + Expect(err).NotTo(HaveOccurred()) + + targetMigrationVMOP = newTargetMigrationVMOP(virtualMachine, targetNodeSelector) + err = f.CreateWithDeferredDeletion(context.Background(), targetMigrationVMOP) + Expect(err).NotTo(HaveOccurred()) + + util.UntilVMMigrationSucceeded(client.ObjectKeyFromObject(virtualMachine), framework.MaxTimeout) + util.UntilObjectPhase(string(v1alpha2.VMOPPhaseCompleted), framework.ShortTimeout, targetMigrationVMOP) + }) + + By("Check the result", func() { + targetMigrationVMOP, err := f.Clients.VirtClient().VirtualMachineOperations(f.Namespace().Name).Get(context.Background(), targetMigrationVMOP.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + + Expect(targetMigrationVMOP.Spec.Migrate).NotTo(BeNil()) + Expect(targetMigrationVMOP.Spec.Migrate.NodeSelector).To(HaveKey(hostnameLabelKey)) + + intvirtvmim, err := getVirtualMachineInstanceMigration(f, fmt.Sprintf("vmop-%s", targetMigrationVMOP.Name)) + Expect(err).NotTo(HaveOccurred()) + Expect(intvirtvmim).NotTo(BeNil()) + Expect(intvirtvmim.Spec.AddedNodeSelector).To(HaveKey(hostnameLabelKey)) + Expect(intvirtvmim.Status.Phase).To(Equal(virtv1.MigrationSucceeded)) + + virtualMachine, err := f.Clients.VirtClient().VirtualMachines(f.Namespace().Name).Get(context.Background(), virtualMachine.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + + Expect(initialNodeName).NotTo(Equal(virtualMachine.Status.Node)) + Expect(virtualMachine.Status.Node).To(Equal(targetNodeSelector[hostnameLabelKey])) + }) + }) +}) + +func newTargetMigrationVMOP(virtualMachine *v1alpha2.VirtualMachine, nodeSelector map[string]string) *v1alpha2.VirtualMachineOperation { + return vmopbuilder.New( + vmopbuilder.WithGenerateName(fmt.Sprintf("%s-migrate-", util.VmopE2ePrefix)), + vmopbuilder.WithNamespace(virtualMachine.Namespace), + vmopbuilder.WithType(v1alpha2.VMOPTypeMigrate), + vmopbuilder.WithVirtualMachine(virtualMachine.Name), + vmopbuilder.WithVMOPMigrateNodeSelector(nodeSelector), + ) +} + +func defineTargetNodeSelector(f *framework.Framework, currentNodeName string) (map[string]string, error) { + errMsg := "could not define a target node for the virtual machine" + + nodes := &corev1.NodeList{} + err := f.Clients.GenericClient().List(context.Background(), nodes) + if err != nil { + return nil, fmt.Errorf("%s: %w", errMsg, err) + } + + for _, node := range nodes.Items { + if node.Name == currentNodeName { + continue + } + + if hostname, ok := node.Labels[hostnameLabelKey]; ok { + if hostname != currentNodeName { + return map[string]string{hostnameLabelKey: hostname}, nil + } + } + } + + return nil, errors.New(errMsg) +} + +func getVirtualMachineInstanceMigration(f *framework.Framework, name string) (*virtv1.VirtualMachineInstanceMigration, error) { + obj := &rewrite.VirtualMachineInstanceMigration{} + err := f.RewriteClient().Get(context.Background(), name, obj, rewrite.InNamespace(f.Namespace().Name)) + if err != nil { + if k8serrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + return obj.VirtualMachineInstanceMigration, nil +}