diff --git a/api/core/v1alpha2/vmopcondition/condition.go b/api/core/v1alpha2/vmopcondition/condition.go index 9326c306ec..9234b68ced 100644 --- a/api/core/v1alpha2/vmopcondition/condition.go +++ b/api/core/v1alpha2/vmopcondition/condition.go @@ -59,6 +59,9 @@ const ( // ReasonNotReadyToBeExecuted is a ReasonCompleted indicating that the operation is not ready to be executed. ReasonNotReadyToBeExecuted ReasonCompleted = "NotReadyToBeExecuted" + // ReasonSuperseded is a ReasonCompleted indicating that the operation has been superseded by another operation. + ReasonSuperseded ReasonCompleted = "Superseded" + // ReasonRestartInProgress is a ReasonCompleted indicating that the restart signal has been sent and restart is in progress. ReasonRestartInProgress ReasonCompleted = "RestartInProgress" diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 4d1bc68edc..8396837ae5 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -1756,6 +1756,10 @@ A list of possible operations is given in the table below: | `d8 v evict` | `Evict` | Evict the VM to another host | | `d8 v migrate` | `Migrate` | Migrate the VM to another host | +Only one active operation is executed for a VM at a time. If a new operation is compatible with an already active operation, it can supersede the older operation. The older operation is completed with `status.phase: Completed` and the `Completed` condition reason `Superseded`, while the new operation continues execution. For example, `Stop` can supersede an active `Start`, `Stop` with `force: true` can supersede a regular `Stop`, and `Restart` can supersede an active `Migrate` or `Evict`. + +If operations are incompatible, the new operation is rejected until the active operation finishes. Restore and clone operations do not supersede other VM operations. + How to perform the operation in the web interface: - Go to the "Projects" tab and select the desired project. diff --git a/docs/USER_GUIDE.ru.md b/docs/USER_GUIDE.ru.md index a5a6322270..aac9890d65 100644 --- a/docs/USER_GUIDE.ru.md +++ b/docs/USER_GUIDE.ru.md @@ -1774,6 +1774,10 @@ d8 v restart linux-vm | `d8 v evict` | `Evict` | Выселить ВМ на другой узел | | `d8 v migrate` | `Migrate` | Мигрировать ВМ на другой узел | +Для одной ВМ одновременно выполняется только одна активная операция. Если новая операция совместима с уже активной операцией, она может вытеснить более старую операцию. Более старая операция завершается с `status.phase: Completed` и причиной `Superseded` в условии `Completed`, а новая операция продолжает выполнение. Например, `Stop` может вытеснить активную операцию `Start`, `Stop` с `force: true` может вытеснить обычную операцию `Stop`, а `Restart` может вытеснить активную операцию `Migrate` или `Evict`. + +Если операции несовместимы, новая операция отклоняется до завершения активной операции. Операции восстановления и клонирования не вытесняют другие операции ВМ. + Как выполнить операцию в веб-интерфейсе: - Перейдите на вкладку «Проекты» и выберите нужный проект. diff --git a/images/virtualization-artifact/pkg/controller/vmop/powerstate/internal/service/restart.go b/images/virtualization-artifact/pkg/controller/vmop/powerstate/internal/service/restart.go index 60578cfabb..0d205a6b81 100644 --- a/images/virtualization-artifact/pkg/controller/vmop/powerstate/internal/service/restart.go +++ b/images/virtualization-artifact/pkg/controller/vmop/powerstate/internal/service/restart.go @@ -19,6 +19,7 @@ package service import ( "context" + apierrors "k8s.io/apimachinery/pkg/api/errors" virtv1 "kubevirt.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -53,6 +54,9 @@ func (o RestartOperation) Execute(ctx context.Context) error { if o.vmop.Spec.Force != nil && *o.vmop.Spec.Force { kvvmi := &virtv1.VirtualMachineInstance{} err = o.client.Get(ctx, key, kvvmi) + if apierrors.IsNotFound(err) { + return kvvmutil.AddStartAnnotation(ctx, o.client, kvvm) + } if err != nil { return err } diff --git a/images/virtualization-artifact/pkg/controller/vmop/powerstate/internal/service/stop.go b/images/virtualization-artifact/pkg/controller/vmop/powerstate/internal/service/stop.go index db15bce3eb..333a46aede 100644 --- a/images/virtualization-artifact/pkg/controller/vmop/powerstate/internal/service/stop.go +++ b/images/virtualization-artifact/pkg/controller/vmop/powerstate/internal/service/stop.go @@ -19,6 +19,7 @@ package service import ( "context" + apierrors "k8s.io/apimachinery/pkg/api/errors" virtv1 "kubevirt.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -42,6 +43,9 @@ type StopOperation struct { func (o StopOperation) Execute(ctx context.Context) error { kvvmi := &virtv1.VirtualMachineInstance{} err := o.client.Get(ctx, virtualMachineKeyByVmop(o.vmop), kvvmi) + if apierrors.IsNotFound(err) { + return nil + } if err != nil { return err } diff --git a/images/virtualization-artifact/pkg/controller/vmop/service/service.go b/images/virtualization-artifact/pkg/controller/vmop/service/service.go index 66a3d9d714..da8224dbb3 100644 --- a/images/virtualization-artifact/pkg/controller/vmop/service/service.go +++ b/images/virtualization-artifact/pkg/controller/vmop/service/service.go @@ -19,18 +19,22 @@ package service import ( "context" "fmt" + "sort" "time" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - "k8s.io/utils/ptr" + "k8s.io/client-go/util/retry" + virtv1 "kubevirt.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + kvvmutil "github.com/deckhouse/virtualization-controller/pkg/common/kvvm" "github.com/deckhouse/virtualization-controller/pkg/common/object" commonvmop "github.com/deckhouse/virtualization-controller/pkg/common/vmop" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/vmop/supersede" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" "github.com/deckhouse/virtualization/api/core/v1alpha2" "github.com/deckhouse/virtualization/api/core/v1alpha2/vmopcondition" @@ -49,32 +53,54 @@ func NewBaseVMOPService(client client.Client, recorder eventrecord.EventRecorder } func (s *BaseVMOPService) ShouldExecuteOrSetFailedPhase(ctx context.Context, vmop *v1alpha2.VirtualMachineOperation) (bool, error) { - should, err := s.ShouldExecute(ctx, vmop) + return s.ShouldExecuteOrSupersedeOrSetFailedPhase(ctx, vmop) +} + +func (s *BaseVMOPService) ShouldExecuteOrSupersedeOrSetFailedPhase(ctx context.Context, vmop *v1alpha2.VirtualMachineOperation) (bool, error) { + blockers, err := s.findOlderActiveVMOPs(ctx, vmop) if err != nil { return false, err } - if should { + if len(blockers) == 0 { return true, nil } - vmop.Status.Phase = v1alpha2.VMOPPhaseFailed - conditions.SetCondition( - conditions.NewConditionBuilder(vmopcondition.TypeCompleted). - Generation(vmop.GetGeneration()). - Reason(vmopcondition.ReasonNotReadyToBeExecuted). - Message("VMOP cannot be executed now. Previously created operation should finish first."). - Status(metav1.ConditionFalse), - &vmop.Status.Conditions) - return false, nil + for i := range blockers { + oldVMOP := &blockers[i] + if !supersede.CanSupersede(oldVMOP, vmop) { + s.setNotReadyToBeExecuted(vmop) + return false, nil + } + } + + for i := range blockers { + oldVMOP := &blockers[i] + if err := s.cleanupSupersededOperation(ctx, oldVMOP); err != nil { + return false, err + } + if err := s.markSuperseded(ctx, oldVMOP, vmop); err != nil { + return false, err + } + } + + return true, nil } func (s *BaseVMOPService) ShouldExecute(ctx context.Context, vmop *v1alpha2.VirtualMachineOperation) (bool, error) { - var vmopList v1alpha2.VirtualMachineOperationList - err := s.client.List(ctx, &vmopList, client.InNamespace(vmop.GetNamespace())) + blockers, err := s.findOlderActiveVMOPs(ctx, vmop) if err != nil { return false, err } + return len(blockers) == 0, nil +} + +func (s *BaseVMOPService) findOlderActiveVMOPs(ctx context.Context, vmop *v1alpha2.VirtualMachineOperation) ([]v1alpha2.VirtualMachineOperation, error) { + var vmopList v1alpha2.VirtualMachineOperationList + if err := s.client.List(ctx, &vmopList, client.InNamespace(vmop.GetNamespace())); err != nil { + return nil, err + } + blockers := make([]v1alpha2.VirtualMachineOperation, 0) for _, other := range vmopList.Items { if other.Spec.VirtualMachine != vmop.Spec.VirtualMachine { continue @@ -85,12 +111,90 @@ func (s *BaseVMOPService) ShouldExecute(ctx context.Context, vmop *v1alpha2.Virt if other.GetUID() == vmop.GetUID() { continue } - if other.CreationTimestamp.Before(ptr.To(vmop.CreationTimestamp)) { - return false, nil + if isOlderVMOP(&other, vmop) { + blockers = append(blockers, other) } } - return true, nil + sort.SliceStable(blockers, func(i, j int) bool { + return isOlderVMOP(&blockers[i], &blockers[j]) + }) + + return blockers, nil +} + +func isOlderVMOP(a, b *v1alpha2.VirtualMachineOperation) bool { + if !a.CreationTimestamp.Equal(&b.CreationTimestamp) { + return a.CreationTimestamp.Before(&b.CreationTimestamp) + } + return a.GetName() < b.GetName() +} + +func (s *BaseVMOPService) setNotReadyToBeExecuted(vmop *v1alpha2.VirtualMachineOperation) { + vmop.Status.Phase = v1alpha2.VMOPPhaseFailed + conditions.SetCondition( + conditions.NewConditionBuilder(vmopcondition.TypeCompleted). + Generation(vmop.GetGeneration()). + Reason(vmopcondition.ReasonNotReadyToBeExecuted). + Message("VMOP cannot be executed now. Previously created operation should finish first."). + Status(metav1.ConditionFalse), + &vmop.Status.Conditions) +} + +func (s *BaseVMOPService) cleanupSupersededOperation(ctx context.Context, oldVMOP *v1alpha2.VirtualMachineOperation) error { + key := types.NamespacedName{Name: oldVMOP.Spec.VirtualMachine, Namespace: oldVMOP.Namespace} + + switch oldVMOP.Spec.Type { + case v1alpha2.VMOPTypeStart: + kvvm := &virtv1.VirtualMachine{} + if err := s.client.Get(ctx, key, kvvm); err != nil { + return client.IgnoreNotFound(err) + } + return kvvmutil.RemoveStartAnnotation(ctx, s.client, kvvm) + case v1alpha2.VMOPTypeRestart: + kvvm := &virtv1.VirtualMachine{} + if err := s.client.Get(ctx, key, kvvm); err != nil { + return client.IgnoreNotFound(err) + } + if err := kvvmutil.RemoveRestartAnnotation(ctx, s.client, kvvm); err != nil { + return err + } + return kvvmutil.RemoveStartAnnotation(ctx, s.client, kvvm) + case v1alpha2.VMOPTypeMigrate, v1alpha2.VMOPTypeEvict: + mig := &virtv1.VirtualMachineInstanceMigration{} + if err := s.client.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("vmop-%s", oldVMOP.Name), Namespace: oldVMOP.Namespace}, mig); err != nil { + return client.IgnoreNotFound(err) + } + return client.IgnoreNotFound(s.client.Delete(ctx, mig)) + case v1alpha2.VMOPTypeStop: + return nil + default: + return nil + } +} + +func (s *BaseVMOPService) markSuperseded(ctx context.Context, oldVMOP, newVMOP *v1alpha2.VirtualMachineOperation) error { + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + current := &v1alpha2.VirtualMachineOperation{} + if err := s.client.Get(ctx, types.NamespacedName{Name: oldVMOP.Name, Namespace: oldVMOP.Namespace}, current); err != nil { + return client.IgnoreNotFound(err) + } + if commonvmop.IsFinished(current) { + return nil + } + + base := current.DeepCopy() + current.Status.Phase = v1alpha2.VMOPPhaseCompleted + conditions.SetCondition( + conditions.NewConditionBuilder(vmopcondition.TypeCompleted). + Generation(current.GetGeneration()). + Reason(vmopcondition.ReasonSuperseded). + Message(fmt.Sprintf("Superseded by %s with type %s", newVMOP.Name, newVMOP.Spec.Type)). + Status(metav1.ConditionTrue), + ¤t.Status.Conditions) + + return s.client.Status().Patch(ctx, current, client.MergeFrom(base)) + }) } func (s *BaseVMOPService) Init(vmop *v1alpha2.VirtualMachineOperation) { diff --git a/images/virtualization-artifact/pkg/controller/vmop/service/service_test.go b/images/virtualization-artifact/pkg/controller/vmop/service/service_test.go new file mode 100644 index 0000000000..7affa0750b --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/service/service_test.go @@ -0,0 +1,198 @@ +/* +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 service + +import ( + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + virtv1 "kubevirt.io/api/core/v1" + + "github.com/deckhouse/virtualization-controller/pkg/common/testutil" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vmopcondition" +) + +func TestService(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "VMOP Service Suite") +} + +var _ = Describe("BaseVMOPService", func() { + Describe("ShouldExecuteOrSupersedeOrSetFailedPhase", func() { + It("marks an allowed older operation as superseded", func(ctx SpecContext) { + oldVMOP := newVMOP("old-start", v1alpha2.VMOPTypeStart, false, time.Now().Add(-time.Minute)) + newVMOP := newVMOP("new-stop", v1alpha2.VMOPTypeStop, false, time.Now()) + + client, err := testutil.NewFakeClientWithObjects(oldVMOP) + Expect(err).NotTo(HaveOccurred()) + + svc := NewBaseVMOPService(client, &eventrecord.EventRecorderLoggerMock{}) + should, err := svc.ShouldExecuteOrSupersedeOrSetFailedPhase(ctx, newVMOP) + Expect(err).NotTo(HaveOccurred()) + Expect(should).To(BeTrue()) + + changed := &v1alpha2.VirtualMachineOperation{} + Expect(client.Get(ctx, types.NamespacedName{Name: oldVMOP.Name, Namespace: oldVMOP.Namespace}, changed)).To(Succeed()) + Expect(changed.Status.Phase).To(Equal(v1alpha2.VMOPPhaseCompleted)) + + completed, found := conditions.GetCondition(vmopcondition.TypeCompleted, changed.Status.Conditions) + Expect(found).To(BeTrue()) + Expect(completed.Status).To(Equal(metav1.ConditionTrue)) + Expect(completed.Reason).To(Equal(vmopcondition.ReasonSuperseded.String())) + }) + + It("marks an older stop operation as superseded by force stop", func(ctx SpecContext) { + oldVMOP := newVMOP("old-stop", v1alpha2.VMOPTypeStop, false, time.Now().Add(-time.Minute)) + newVMOP := newVMOP("new-force-stop", v1alpha2.VMOPTypeStop, true, time.Now()) + + client, err := testutil.NewFakeClientWithObjects(oldVMOP) + Expect(err).NotTo(HaveOccurred()) + + svc := NewBaseVMOPService(client, &eventrecord.EventRecorderLoggerMock{}) + should, err := svc.ShouldExecuteOrSupersedeOrSetFailedPhase(ctx, newVMOP) + Expect(err).NotTo(HaveOccurred()) + Expect(should).To(BeTrue()) + + changed := &v1alpha2.VirtualMachineOperation{} + Expect(client.Get(ctx, types.NamespacedName{Name: oldVMOP.Name, Namespace: oldVMOP.Namespace}, changed)).To(Succeed()) + Expect(changed.Status.Phase).To(Equal(v1alpha2.VMOPPhaseCompleted)) + + completed, found := conditions.GetCondition(vmopcondition.TypeCompleted, changed.Status.Conditions) + Expect(found).To(BeTrue()) + Expect(completed.Status).To(Equal(metav1.ConditionTrue)) + Expect(completed.Reason).To(Equal(vmopcondition.ReasonSuperseded.String())) + }) + + It("marks an older stop operation as superseded by force restart", func(ctx SpecContext) { + oldVMOP := newVMOP("old-stop", v1alpha2.VMOPTypeStop, false, time.Now().Add(-time.Minute)) + newVMOP := newVMOP("new-force-restart", v1alpha2.VMOPTypeRestart, true, time.Now()) + + client, err := testutil.NewFakeClientWithObjects(oldVMOP) + Expect(err).NotTo(HaveOccurred()) + + svc := NewBaseVMOPService(client, &eventrecord.EventRecorderLoggerMock{}) + should, err := svc.ShouldExecuteOrSupersedeOrSetFailedPhase(ctx, newVMOP) + Expect(err).NotTo(HaveOccurred()) + Expect(should).To(BeTrue()) + + changed := &v1alpha2.VirtualMachineOperation{} + Expect(client.Get(ctx, types.NamespacedName{Name: oldVMOP.Name, Namespace: oldVMOP.Namespace}, changed)).To(Succeed()) + Expect(changed.Status.Phase).To(Equal(v1alpha2.VMOPPhaseCompleted)) + + completed, found := conditions.GetCondition(vmopcondition.TypeCompleted, changed.Status.Conditions) + Expect(found).To(BeTrue()) + Expect(completed.Status).To(Equal(metav1.ConditionTrue)) + Expect(completed.Reason).To(Equal(vmopcondition.ReasonSuperseded.String())) + }) + + Describe("migration cleanup for restart supersede", func() { + entries := []struct { + name string + oldType v1alpha2.VMOPType + newForce bool + }{ + {name: "migrate by restart", oldType: v1alpha2.VMOPTypeMigrate}, + {name: "migrate by force restart", oldType: v1alpha2.VMOPTypeMigrate, newForce: true}, + {name: "evict by restart", oldType: v1alpha2.VMOPTypeEvict}, + {name: "evict by force restart", oldType: v1alpha2.VMOPTypeEvict, newForce: true}, + } + + for _, entry := range entries { + It("deletes migration side effect for "+entry.name, func(ctx SpecContext) { + oldVMOP := newVMOP("old-migration", entry.oldType, false, time.Now().Add(-time.Minute)) + newVMOP := newVMOP("new-restart", v1alpha2.VMOPTypeRestart, entry.newForce, time.Now()) + migration := newMigrationForVMOP(oldVMOP) + + client, err := testutil.NewFakeClientWithObjects(oldVMOP, migration) + Expect(err).NotTo(HaveOccurred()) + + svc := NewBaseVMOPService(client, &eventrecord.EventRecorderLoggerMock{}) + should, err := svc.ShouldExecuteOrSupersedeOrSetFailedPhase(ctx, newVMOP) + Expect(err).NotTo(HaveOccurred()) + Expect(should).To(BeTrue()) + + changed := &v1alpha2.VirtualMachineOperation{} + Expect(client.Get(ctx, types.NamespacedName{Name: oldVMOP.Name, Namespace: oldVMOP.Namespace}, changed)).To(Succeed()) + Expect(changed.Status.Phase).To(Equal(v1alpha2.VMOPPhaseCompleted)) + + deletedMigration := &virtv1.VirtualMachineInstanceMigration{} + err = client.Get(ctx, types.NamespacedName{Name: migration.Name, Namespace: migration.Namespace}, deletedMigration) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }) + } + }) + + It("fails a forbidden newer operation", func(ctx SpecContext) { + oldVMOP := newVMOP("old-stop", v1alpha2.VMOPTypeStop, false, time.Now().Add(-time.Minute)) + newVMOP := newVMOP("new-start", v1alpha2.VMOPTypeStart, false, time.Now()) + + client, err := testutil.NewFakeClientWithObjects(oldVMOP) + Expect(err).NotTo(HaveOccurred()) + + svc := NewBaseVMOPService(client, &eventrecord.EventRecorderLoggerMock{}) + should, err := svc.ShouldExecuteOrSupersedeOrSetFailedPhase(ctx, newVMOP) + Expect(err).NotTo(HaveOccurred()) + Expect(should).To(BeFalse()) + Expect(newVMOP.Status.Phase).To(Equal(v1alpha2.VMOPPhaseFailed)) + + completed, found := conditions.GetCondition(vmopcondition.TypeCompleted, newVMOP.Status.Conditions) + Expect(found).To(BeTrue()) + Expect(completed.Status).To(Equal(metav1.ConditionFalse)) + Expect(completed.Reason).To(Equal(vmopcondition.ReasonNotReadyToBeExecuted.String())) + }) + }) +}) + +func newMigrationForVMOP(vmop *v1alpha2.VirtualMachineOperation) *virtv1.VirtualMachineInstanceMigration { + return &virtv1.VirtualMachineInstanceMigration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vmop-" + vmop.Name, + Namespace: vmop.Namespace, + }, + Spec: virtv1.VirtualMachineInstanceMigrationSpec{ + VMIName: vmop.Spec.VirtualMachine, + }, + } +} + +func newVMOP(name string, vmopType v1alpha2.VMOPType, force bool, createdAt time.Time) *v1alpha2.VirtualMachineOperation { + return &v1alpha2.VirtualMachineOperation{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + UID: types.UID(name), + CreationTimestamp: metav1.NewTime(createdAt), + }, + Spec: v1alpha2.VirtualMachineOperationSpec{ + Type: vmopType, + VirtualMachine: "test-vm", + Force: ptr.To(force), + }, + Status: v1alpha2.VirtualMachineOperationStatus{ + Phase: v1alpha2.VMOPPhaseInProgress, + }, + } +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/supersede/supersede.go b/images/virtualization-artifact/pkg/controller/vmop/supersede/supersede.go new file mode 100644 index 0000000000..a486af4109 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/supersede/supersede.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 supersede + +import ( + "k8s.io/utils/ptr" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func CanSupersede(oldVMOP, newVMOP *v1alpha2.VirtualMachineOperation) bool { + if oldVMOP == nil || newVMOP == nil { + return false + } + if oldVMOP.Spec.VirtualMachine != newVMOP.Spec.VirtualMachine { + return false + } + + oldForce := ptr.Deref(oldVMOP.Spec.Force, false) + newForce := ptr.Deref(newVMOP.Spec.Force, false) + + switch oldVMOP.Spec.Type { + case v1alpha2.VMOPTypeStart: + return newVMOP.Spec.Type == v1alpha2.VMOPTypeStop + case v1alpha2.VMOPTypeStop: + if oldForce { + return false + } + return newVMOP.Spec.Type == v1alpha2.VMOPTypeStop && newForce || + newVMOP.Spec.Type == v1alpha2.VMOPTypeRestart && newForce + case v1alpha2.VMOPTypeMigrate, v1alpha2.VMOPTypeEvict: + return newVMOP.Spec.Type == v1alpha2.VMOPTypeStop || newVMOP.Spec.Type == v1alpha2.VMOPTypeRestart + case v1alpha2.VMOPTypeRestart: + if oldForce { + return false + } + return newVMOP.Spec.Type == v1alpha2.VMOPTypeStop && newForce || + newVMOP.Spec.Type == v1alpha2.VMOPTypeRestart && newForce + default: + return false + } +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/supersede/supersede_test.go b/images/virtualization-artifact/pkg/controller/vmop/supersede/supersede_test.go new file mode 100644 index 0000000000..524b82ea4f --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/supersede/supersede_test.go @@ -0,0 +1,135 @@ +/* +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 supersede + +import ( + "fmt" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/utils/ptr" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func TestSupersede(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Supersede Suite") +} + +var _ = Describe("CanSupersede", func() { + Describe("matrix combinations", func() { + for _, entry := range supersedeMatrixEntries() { + It(entry.name, func() { + Expect(CanSupersede(vmop(entry.oldType, entry.oldForce), vmop(entry.newType, entry.newForce))).To(Equal(entry.expected)) + }) + } + }) + + It("denies operations for different virtual machines", func() { + oldVMOP := vmop(v1alpha2.VMOPTypeStart, false) + newVMOP := vmop(v1alpha2.VMOPTypeStop, false) + newVMOP.Spec.VirtualMachine = "another-vm" + + Expect(CanSupersede(oldVMOP, newVMOP)).To(BeFalse()) + }) + + DescribeTable("denies nil operations", + func(oldVMOP, newVMOP *v1alpha2.VirtualMachineOperation) { + Expect(CanSupersede(oldVMOP, newVMOP)).To(BeFalse()) + }, + Entry("nil old operation", nil, vmop(v1alpha2.VMOPTypeStop, false)), + Entry("nil new operation", vmop(v1alpha2.VMOPTypeStart, false), nil), + Entry("nil operations", nil, nil), + ) +}) + +type supersedeMatrixEntry struct { + name string + oldType v1alpha2.VMOPType + oldForce bool + newType v1alpha2.VMOPType + newForce bool + expected bool +} + +func supersedeMatrixEntries() []supersedeMatrixEntry { + vmopTypes := []v1alpha2.VMOPType{ + v1alpha2.VMOPTypeStart, + v1alpha2.VMOPTypeStop, + v1alpha2.VMOPTypeMigrate, + v1alpha2.VMOPTypeEvict, + v1alpha2.VMOPTypeRestart, + v1alpha2.VMOPTypeRestore, + v1alpha2.VMOPTypeClone, + } + forces := []bool{false, true} + + var entries []supersedeMatrixEntry + for _, oldType := range vmopTypes { + for _, oldForce := range forces { + for _, newType := range vmopTypes { + for _, newForce := range forces { + entries = append(entries, supersedeMatrixEntry{ + name: fmt.Sprintf("%s force=%t by %s force=%t", oldType, oldForce, newType, newForce), + oldType: oldType, + oldForce: oldForce, + newType: newType, + newForce: newForce, + expected: expectedCanSupersede(oldType, oldForce, newType, newForce), + }) + } + } + } + } + + return entries +} + +func expectedCanSupersede(oldType v1alpha2.VMOPType, oldForce bool, newType v1alpha2.VMOPType, newForce bool) bool { + switch oldType { + case v1alpha2.VMOPTypeStart: + return newType == v1alpha2.VMOPTypeStop + case v1alpha2.VMOPTypeStop: + if oldForce { + return false + } + return newType == v1alpha2.VMOPTypeStop && newForce || + newType == v1alpha2.VMOPTypeRestart && newForce + case v1alpha2.VMOPTypeMigrate, v1alpha2.VMOPTypeEvict: + return newType == v1alpha2.VMOPTypeStop || newType == v1alpha2.VMOPTypeRestart + case v1alpha2.VMOPTypeRestart: + if oldForce { + return false + } + return newType == v1alpha2.VMOPTypeStop && newForce || + newType == v1alpha2.VMOPTypeRestart && newForce + default: + return false + } +} + +func vmop(vmopType v1alpha2.VMOPType, force bool) *v1alpha2.VirtualMachineOperation { + return &v1alpha2.VirtualMachineOperation{ + Spec: v1alpha2.VirtualMachineOperationSpec{ + Type: vmopType, + VirtualMachine: "test-vm", + Force: ptr.To(force), + }, + } +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/vmop_webhook.go b/images/virtualization-artifact/pkg/controller/vmop/vmop_webhook.go index 7889c376ae..b655002c86 100644 --- a/images/virtualization-artifact/pkg/controller/vmop/vmop_webhook.go +++ b/images/virtualization-artifact/pkg/controller/vmop/vmop_webhook.go @@ -31,6 +31,7 @@ import ( "github.com/deckhouse/deckhouse/pkg/log" commonvmop "github.com/deckhouse/virtualization-controller/pkg/common/vmop" "github.com/deckhouse/virtualization-controller/pkg/controller/validator" + "github.com/deckhouse/virtualization-controller/pkg/controller/vmop/supersede" "github.com/deckhouse/virtualization-controller/pkg/featuregates" "github.com/deckhouse/virtualization-controller/pkg/version" "github.com/deckhouse/virtualization/api/core/v1alpha2" @@ -43,6 +44,7 @@ func NewValidator(c client.Client, log *log.Logger) admission.CustomValidator { ).WithCreateValidators( &nodeSelectorValidator{}, &localStorageMigrationValidator{client: c}, + &activeVMOPValidator{client: c}, ) } @@ -81,6 +83,31 @@ type localStorageMigrationValidator struct { client client.Client } +type activeVMOPValidator struct { + client client.Client +} + +func (v *activeVMOPValidator) ValidateCreate(ctx context.Context, vmop *v1alpha2.VirtualMachineOperation) (admission.Warnings, error) { + var vmopList v1alpha2.VirtualMachineOperationList + if err := v.client.List(ctx, &vmopList, client.InNamespace(vmop.Namespace)); err != nil { + return nil, fmt.Errorf("failed to list VirtualMachineOperations: %w", err) + } + + for _, other := range vmopList.Items { + if other.Spec.VirtualMachine != vmop.Spec.VirtualMachine { + continue + } + if commonvmop.IsFinished(&other) { + continue + } + if !supersede.CanSupersede(&other, vmop) { + return nil, fmt.Errorf("VMOP cannot be executed now. Previously created operation %q should finish first", other.Name) + } + } + + return nil, nil +} + func (v *localStorageMigrationValidator) ValidateCreate(ctx context.Context, vmop *v1alpha2.VirtualMachineOperation) (admission.Warnings, error) { if version.GetEdition() != version.EditionCE { return nil, nil