diff --git a/images/virtualization-artifact/pkg/common/annotations/annotations.go b/images/virtualization-artifact/pkg/common/annotations/annotations.go index b72fa48663..37e8746a33 100644 --- a/images/virtualization-artifact/pkg/common/annotations/annotations.go +++ b/images/virtualization-artifact/pkg/common/annotations/annotations.go @@ -107,6 +107,14 @@ const ( // AnnVMRestartRequested is an annotation on KVVM that represents a request to restart a virtual machine. AnnVMRestartRequested = AnnAPIGroupV + "/vm-restart-requested" + // AnnVMRestorePowerState is an annotation on VirtualMachine that records the VM power state captured before a + // restore operation, so restore restores it. It is kept on the VM (not the KVVM, which is deleted during the + // restore maintenance window) so it survives KVVM recreation. The value is a MachinePhase string: + // "Running" means start the VM again after restore (see checkNeedStartVM); "Stopped" means keep it stopped, + // which for AlwaysOnUnlessStoppedManually requires overriding the implicit RunStrategy=Always on KVVM create + // (see createKVVM) to honor the "unless stopped manually" contract. + AnnVMRestorePowerState = AnnAPIGroupV + "/restore-power-state" + // AnnVMOPWorkloadUpdate is an annotation on vmop that represents a vmop created by workload-updater controller. AnnVMOPWorkloadUpdate = AnnAPIGroupV + "/workload-update" AnnVMOPWorkloadUpdateImage = AnnAPIGroupV + "/workload-update-image" diff --git a/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vm_restorer.go b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vm_restorer.go index b7b73e4614..768bd63063 100644 --- a/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vm_restorer.go +++ b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vm_restorer.go @@ -193,10 +193,18 @@ func (v *VirtualMachineHandler) ProcessRestore(ctx context.Context) error { vm.Annotations = make(map[string]string) } + // EnterMaintenance records the pre-restore power state on the live VM so it survives the KVVM deletion. + // The snapshot template does not carry it, so preserve it across the full annotation overwrite below; + // otherwise the VM would be left in the wrong power state after restore. + powerState, hasPowerState := vm.Annotations[annotations.AnnVMRestorePowerState] + vm.Spec = v.vm.Spec vm.Labels = v.vm.Labels vm.Annotations = v.vm.Annotations vm.Annotations[annotations.AnnVMOPRestore] = v.restoreUID + if hasPowerState { + vm.Annotations[annotations.AnnVMRestorePowerState] = powerState + } updErr := v.client.Update(ctx, vm) if updErr != nil { diff --git a/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vm_restorer_test.go b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vm_restorer_test.go index 8e54f3076e..9e3ad39176 100644 --- a/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vm_restorer_test.go +++ b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vm_restorer_test.go @@ -337,6 +337,38 @@ var _ = Describe("VirtualMachineRestorer", func() { }), ) + DescribeTable("preserves the pre-restore power state across the annotation overwrite", + func(powerState string) { + liveVM := vm.DeepCopy() + liveVM.Annotations = map[string]string{ + annotations.AnnVMRestorePowerState: powerState, + } + // Different spec so ProcessRestore takes the update path (full annotation overwrite). + liveVM.Spec.RunPolicy = v1alpha2.ManualPolicy + + var updatedVM *v1alpha2.VirtualMachine + ic := interceptor.Funcs{ + Update: func(_ context.Context, c client.WithWatch, obj client.Object, opts ...client.UpdateOption) error { + if got, ok := obj.(*v1alpha2.VirtualMachine); ok { + updatedVM = got.DeepCopy() + } + return c.Update(ctx, obj, opts...) + }, + } + fc, err := testutil.NewFakeClientWithInterceptorWithObjects(ic, liveVM) + Expect(err).ToNot(HaveOccurred()) + + handler := NewVirtualMachineHandler(fc, vm, restoreUID, v1alpha2.SnapshotOperationModeStrict) + Expect(handler.ProcessRestore(ctx)).To(Succeed()) + + Expect(updatedVM).ToNot(BeNil()) + Expect(updatedVM.Annotations).To(HaveKeyWithValue(annotations.AnnVMRestorePowerState, powerState)) + Expect(updatedVM.Annotations).To(HaveKeyWithValue(annotations.AnnVMOPRestore, restoreUID)) + }, + Entry("running", string(v1alpha2.MachineRunning)), + Entry("stopped", string(v1alpha2.MachineStopped)), + ) + Describe("Override", func() { var rules []v1alpha2.NameReplacement diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go index 6041ad9a6c..735c9203e7 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm.go @@ -414,6 +414,23 @@ func (h *SyncKvvmHandler) createKVVM(ctx context.Context, s state.VirtualMachine return fmt.Errorf("failed to make the internal virtual machine: %w", err) } + // Restore the pre-restore power state captured by EnterMaintenance onto the freshly (re)created KVVM. + // "Running" is turned into the regular start-request annotation so the existing power-state machinery + // starts the VM and retries on a failed first boot. "Stopped" must override the implicit RunStrategy=Always + // that AlwaysOnUnlessStoppedManually gets on create, to honor the "unless stopped manually" contract. + changed := s.VirtualMachine().Changed() + switch changed.GetAnnotations()[annotations.AnnVMRestorePowerState] { + case string(v1alpha2.MachineRunning): + annotations.AddAnnotation(kvvm, annotations.AnnVMStartRequested, "true") + delete(changed.Annotations, annotations.AnnVMRestorePowerState) + case string(v1alpha2.MachineStopped): + if changed.Spec.RunPolicy == v1alpha2.AlwaysOnUnlessStoppedManually { + runStrategy := virtv1.RunStrategyManual + kvvm.Spec.RunStrategy = &runStrategy + } + delete(changed.Annotations, annotations.AnnVMRestorePowerState) + } + err = h.client.Create(ctx, kvvm) if err != nil { if k8serrors.IsAlreadyExists(err) { diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm_test.go b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm_test.go index f79e78f8d2..5524bb1237 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm_test.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/sync_kvvm_test.go @@ -31,6 +31,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" vmbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vm" + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" "github.com/deckhouse/virtualization-controller/pkg/common/network" "github.com/deckhouse/virtualization-controller/pkg/common/testutil" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" @@ -285,6 +286,59 @@ var _ = Describe("SyncKvvmHandler", func() { } } + Context("restore-power-state intent on KVVM creation", func() { + It("requests start for a VM that was running before restore and clears the intent", func() { + vm := makeVM(v1alpha2.MachineStopped) + vm.Spec.RunPolicy = v1alpha2.ManualPolicy + vm.Annotations = map[string]string{annotations.AnnVMRestorePowerState: string(v1alpha2.MachineRunning)} + + fakeClient, reconcileObj, vmState = setupEnvironment(vm, makeVMIP(), makeVMClass()) + + reconcile() + + kvvm := &virtv1.VirtualMachine{} + Expect(fakeClient.Get(ctx, client.ObjectKeyFromObject(vm), kvvm)).To(Succeed()) + Expect(kvvm.Annotations).To(HaveKeyWithValue(annotations.AnnVMStartRequested, "true")) + + newVM := &v1alpha2.VirtualMachine{} + Expect(fakeClient.Get(ctx, client.ObjectKeyFromObject(vm), newVM)).To(Succeed()) + Expect(newVM.Annotations).NotTo(HaveKey(annotations.AnnVMRestorePowerState)) + }) + + It("keeps AlwaysOnUnlessStoppedManually VM stopped and clears the intent", func() { + vm := makeVM(v1alpha2.MachineStopped) + vm.Spec.RunPolicy = v1alpha2.AlwaysOnUnlessStoppedManually + vm.Annotations = map[string]string{annotations.AnnVMRestorePowerState: string(v1alpha2.MachineStopped)} + + fakeClient, reconcileObj, vmState = setupEnvironment(vm, makeVMIP(), makeVMClass()) + + reconcile() + + kvvm := &virtv1.VirtualMachine{} + Expect(fakeClient.Get(ctx, client.ObjectKeyFromObject(vm), kvvm)).To(Succeed()) + Expect(kvvm.Spec.RunStrategy).NotTo(BeNil()) + Expect(*kvvm.Spec.RunStrategy).To(Equal(virtv1.RunStrategyManual)) + + newVM := &v1alpha2.VirtualMachine{} + Expect(fakeClient.Get(ctx, client.ObjectKeyFromObject(vm), newVM)).To(Succeed()) + Expect(newVM.Annotations).NotTo(HaveKey(annotations.AnnVMRestorePowerState)) + }) + + It("starts AlwaysOnUnlessStoppedManually VM on create without the keep-stopped intent", func() { + vm := makeVM(v1alpha2.MachineStopped) + vm.Spec.RunPolicy = v1alpha2.AlwaysOnUnlessStoppedManually + + fakeClient, reconcileObj, vmState = setupEnvironment(vm, makeVMIP(), makeVMClass()) + + reconcile() + + kvvm := &virtv1.VirtualMachine{} + Expect(fakeClient.Get(ctx, client.ObjectKeyFromObject(vm), kvvm)).To(Succeed()) + Expect(kvvm.Spec.RunStrategy).NotTo(BeNil()) + Expect(*kvvm.Spec.RunStrategy).To(Equal(virtv1.RunStrategyAlways)) + }) + }) + DescribeTable("AwaitingRestart Condition Tests", func(phase v1alpha2.MachinePhase, needChange bool, expectedStatus metav1.ConditionStatus, expectedExistence bool) { ip := makeVMIP() diff --git a/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/step/enter_maintenance_step.go b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/step/enter_maintenance_step.go index 1a9a47cabd..f5c90ae011 100644 --- a/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/step/enter_maintenance_step.go +++ b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/step/enter_maintenance_step.go @@ -27,6 +27,7 @@ import ( "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/common/object" "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" "github.com/deckhouse/virtualization-controller/pkg/eventrecord" @@ -84,6 +85,32 @@ func (s EnterMaintenanceStep) Take(ctx context.Context, vmop *v1alpha2.VirtualMa return nil, nil } + // Preserve the VM power state across restore. The maintenance window deletes the KVVM (and with it the + // implicit run-strategy state), so capture whether the VM was running or stopped before restore and store it + // as an annotation. ProcessRestore preserves it across the annotation overwrite, and it is consumed once + // restore completes: a running VM is started again (see checkNeedStartVM), a stopped VM is kept stopped + // (see createKVVM for the AlwaysOnUnlessStoppedManually policy). + var powerState string + switch vm.Status.Phase { + case v1alpha2.MachineRunning, v1alpha2.MachinePending: + powerState = string(v1alpha2.MachineRunning) + case v1alpha2.MachineStopped: + powerState = string(v1alpha2.MachineStopped) + } + if powerState != "" && vm.Annotations[annotations.AnnVMRestorePowerState] != powerState { + if vm.Annotations == nil { + vm.Annotations = make(map[string]string) + } + vm.Annotations[annotations.AnnVMRestorePowerState] = powerState + if err = s.client.Update(ctx, vm); err != nil { + if apierrors.IsConflict(err) { + return &reconcile.Result{}, nil + } + s.recorder.Event(vmop, corev1.EventTypeWarning, v1alpha2.ReasonErrVMOPFailed, "Failed to record VM power state for restore: "+err.Error()) + return &reconcile.Result{}, err + } + } + conditions.SetCondition( conditions.NewConditionBuilder(vmcondition.TypeMaintenance). Generation(vm.GetGeneration()).