From 7bf5e68ff1944abcc37eab05c7cc85bede78a7ef Mon Sep 17 00:00:00 2001 From: Yaroslav Borbat <86148689+yaroslavborbat@users.noreply.github.com> Date: Fri, 9 Feb 2024 14:38:55 +0300 Subject: [PATCH] feat(vmop): first implementation (#4) ## Description Implementation resource VirtualMachineOperation. The VirtualMachineOperation custom resource allows for declarative management of a working virtual machine. It enables actions such as stopping, starting, and restarting the virtual machine. ## Why do we need it, and what problem does it solve? The VirtualMachineOperation resource is needed to provide a high-level abstraction for managing virtual machines. It solves the problem of efficiently and easily controlling the state of virtual machines through a declarative approach, rather than relying on manual, time-consuming operations. ## What is the expected result? VirtualMachineOperation is an immutable resource. After deployment, it can have 4 phases of status: Completed, Failed, InProgress, Pending. --------- Signed-off-by: Yaroslav Borbat Signed-off-by: Yaroslav Borbat <86148689+yaroslavborbat@users.noreply.github.com> Co-authored-by: Ivan Mikheykin --- hack/upload-pvc.sh | 2 +- hack/upload.sh | 2 +- .../Taskfile.dist.yaml | 5 + .../api/v1alpha2/events.go | 9 + .../api/v1alpha2/finalizers.go | 2 + .../api/v1alpha2/register.go | 2 + .../api/v1alpha2/virtual_machine_operation.go | 56 ++++ .../api/v1alpha2/zz_generated.deepcopy.go | 93 +++++++ .../cmd/virtualization-controller/main.go | 6 + .../templates/validation-webhook.yaml | 16 +- .../pkg/common/kvvm/util.go | 138 ++++++++++ .../pkg/common/patch/patch.go | 2 +- .../pkg/controller/vmop/vmop_controller.go | 60 +++++ .../pkg/controller/vmop/vmop_reconciler.go | 242 ++++++++++++++++++ .../controller/vmop/vmop_reconciler_state.go | 190 ++++++++++++++ .../pkg/controller/vmop/vmop_webhook.go | 54 ++++ .../rbac-for-us.yaml | 10 + .../validation-webhook.yaml | 37 ++- 18 files changed, 905 insertions(+), 21 deletions(-) create mode 100644 images/virtualization-controller/api/v1alpha2/virtual_machine_operation.go create mode 100644 images/virtualization-controller/pkg/controller/vmop/vmop_controller.go create mode 100644 images/virtualization-controller/pkg/controller/vmop/vmop_reconciler.go create mode 100644 images/virtualization-controller/pkg/controller/vmop/vmop_reconciler_state.go create mode 100644 images/virtualization-controller/pkg/controller/vmop/vmop_webhook.go diff --git a/hack/upload-pvc.sh b/hack/upload-pvc.sh index 68a7c8837..2fbd3e403 100644 --- a/hack/upload-pvc.sh +++ b/hack/upload-pvc.sh @@ -25,7 +25,7 @@ fi cat < 0 + // If migration is completed - return the target pod. + if kvvmi.Status.MigrationState != nil && kvvmi.Status.MigrationState.Completed { + for _, pod := range podList.Items { + if pod.Name == kvvmi.Status.MigrationState.TargetPod { + return &pod, nil + } + } + } + // return the first running pod + for _, pod := range podList.Items { + if pod.Status.Phase == corev1.PodRunning { + return &pod, nil + } + } + return &podList.Items[0], nil +} + +// DeletePodByKVVMI deletes pod by kvvmi. +func DeletePodByKVVMI(ctx context.Context, cli client.Client, kvvmi *virtv1.VirtualMachineInstance, opts client.DeleteOption) error { + pod, err := FindPodByKVVMI(ctx, cli, kvvmi) + if err != nil { + return err + } + if pod == nil { + return nil + } + return helper.DeleteObject(ctx, cli, pod, opts) +} + +// GetChangeRequest returns the stop/start patch. +func GetChangeRequest(vm *virtv1.VirtualMachine, changes ...virtv1.VirtualMachineStateChangeRequest) ([]byte, error) { + jp := patch.NewJsonPatch() + verb := patch.PatchAddOp + // Special case: if there's no status field at all, add one. + newStatus := virtv1.VirtualMachineStatus{} + if equality.Semantic.DeepEqual(vm.Status, newStatus) { + newStatus.StateChangeRequests = changes + jp.Append(patch.NewJsonPatchOperation(verb, "/status", newStatus)) + } else { + failOnConflict := true + if len(changes) == 1 && changes[0].Action == virtv1.StopRequest { + // If this is a stopRequest, replace all existing StateChangeRequests. + failOnConflict = false + } + if len(vm.Status.StateChangeRequests) != 0 { + if failOnConflict { + return nil, fmt.Errorf("unable to complete request: stop/start already underway") + } else { + verb = patch.PatchReplaceOp + } + } + jp.Append(patch.NewJsonPatchOperation(verb, "/status/stateChangeRequests", changes)) + } + if vm.Status.StartFailure != nil { + jp.Append(patch.NewJsonPatchOperation(patch.PatchRemoveOp, "/status/startFailure", nil)) + } + return jp.Bytes() +} + +// StartKVVM starts kvvm. +func StartKVVM(ctx context.Context, cli client.Client, kvvm *virtv1.VirtualMachine) error { + if kvvm == nil { + return fmt.Errorf("kvvm must not be empty") + } + jp, err := GetChangeRequest(kvvm, + virtv1.VirtualMachineStateChangeRequest{Action: virtv1.StartRequest}) + if err != nil { + return err + } + return cli.Status().Patch(ctx, kvvm, client.RawPatch(types.JSONPatchType, jp), &client.SubResourcePatchOptions{}) +} + +// StopKVVM stops kvvm. +func StopKVVM(ctx context.Context, cli client.Client, kvvmi *virtv1.VirtualMachineInstance, force bool) error { + if kvvmi == nil { + return fmt.Errorf("kvvmi must not be empty") + } + if err := cli.Delete(ctx, kvvmi, &client.DeleteOptions{}); err != nil { + return err + } + if force { + return DeletePodByKVVMI(ctx, cli, kvvmi, &client.DeleteOptions{GracePeriodSeconds: util.GetPointer(int64(0))}) + } + return nil +} + +// RestartKVVM restarts kvvm. +func RestartKVVM(ctx context.Context, cli client.Client, kvvm *virtv1.VirtualMachine, kvvmi *virtv1.VirtualMachineInstance, force bool) error { + if kvvm == nil { + return fmt.Errorf("kvvm must not be empty") + } + if kvvmi == nil { + return fmt.Errorf("kvvmi must not be empty") + } + + jp, err := GetChangeRequest(kvvm, + virtv1.VirtualMachineStateChangeRequest{Action: virtv1.StopRequest, UID: &kvvmi.UID}, + virtv1.VirtualMachineStateChangeRequest{Action: virtv1.StartRequest}) + if err != nil { + return err + } + + err = cli.Status().Patch(ctx, kvvm, client.RawPatch(types.JSONPatchType, jp), &client.SubResourcePatchOptions{}) + if err != nil { + return err + } + if force { + return DeletePodByKVVMI(ctx, cli, kvvmi, &client.DeleteOptions{GracePeriodSeconds: util.GetPointer(int64(0))}) + } + return nil +} diff --git a/images/virtualization-controller/pkg/common/patch/patch.go b/images/virtualization-controller/pkg/common/patch/patch.go index 78c900cd3..f88dbd7a6 100644 --- a/images/virtualization-controller/pkg/common/patch/patch.go +++ b/images/virtualization-controller/pkg/common/patch/patch.go @@ -18,7 +18,7 @@ type JsonPatch struct { type JsonPatchOperation struct { Op string `json:"op"` Path string `json:"path"` - Value interface{} `json:"value"` + Value interface{} `json:"value,omitempty"` } func NewJsonPatch(patches ...JsonPatchOperation) *JsonPatch { diff --git a/images/virtualization-controller/pkg/controller/vmop/vmop_controller.go b/images/virtualization-controller/pkg/controller/vmop/vmop_controller.go new file mode 100644 index 000000000..7ec8de22f --- /dev/null +++ b/images/virtualization-controller/pkg/controller/vmop/vmop_controller.go @@ -0,0 +1,60 @@ +package vmop + +import ( + "context" + "time" + + "github.com/go-logr/logr" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/manager" + + "github.com/deckhouse/virtualization-controller/api/v1alpha2" + "github.com/deckhouse/virtualization-controller/pkg/sdk/framework/two_phase_reconciler" +) + +const ( + controllerName = "vmop-controller" +) + +func NewController( + ctx context.Context, + mgr manager.Manager, + log logr.Logger, +) (controller.Controller, error) { + reconciler := NewReconciler() + + reconcilerCore := two_phase_reconciler.NewReconcilerCore[*ReconcilerState]( + reconciler, + NewReconcilerState, + two_phase_reconciler.ReconcilerOptions{ + Client: mgr.GetClient(), + Cache: mgr.GetCache(), + Recorder: mgr.GetEventRecorderFor(controllerName), + Scheme: mgr.GetScheme(), + Log: log.WithName(controllerName), + }) + + c, err := controller.New(controllerName, mgr, controller.Options{ + Reconciler: reconcilerCore, + RateLimiter: workqueue.NewItemExponentialFailureRateLimiter(time.Second, 32*time.Second), + }) + if err != nil { + return nil, err + } + + if err := reconciler.SetupController(ctx, mgr, c); err != nil { + return nil, err + } + + if err = builder.WebhookManagedBy(mgr). + For(&v1alpha2.VirtualMachineOperation{}). + WithValidator(NewValidator(log)). + Complete(); err != nil { + return nil, err + } + + log.Info("Initialized VirtualMachineOperation controller") + return c, nil +} diff --git a/images/virtualization-controller/pkg/controller/vmop/vmop_reconciler.go b/images/virtualization-controller/pkg/controller/vmop/vmop_reconciler.go new file mode 100644 index 000000000..11f0fee07 --- /dev/null +++ b/images/virtualization-controller/pkg/controller/vmop/vmop_reconciler.go @@ -0,0 +1,242 @@ +package vmop + +import ( + "context" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + virtv2 "github.com/deckhouse/virtualization-controller/api/v1alpha2" + kvvmutil "github.com/deckhouse/virtualization-controller/pkg/common/kvvm" + "github.com/deckhouse/virtualization-controller/pkg/controller/common" + "github.com/deckhouse/virtualization-controller/pkg/sdk/framework/two_phase_reconciler" +) + +type Reconciler struct{} + +func NewReconciler() *Reconciler { + return &Reconciler{} +} + +func (r *Reconciler) SetupController(_ context.Context, mgr manager.Manager, ctr controller.Controller) error { + return ctr.Watch(source.Kind(mgr.GetCache(), &virtv2.VirtualMachineOperation{}), &handler.EnqueueRequestForObject{}) +} + +func (r *Reconciler) Sync(ctx context.Context, req reconcile.Request, state *ReconcilerState, opts two_phase_reconciler.ReconcilerOptions) error { + log := opts.Log.WithValues("vmop.name", state.VMOP.Current().GetName()) + + switch { + case state.IsDeletion(): + log.V(1).Info("Delete VMOP, remove protective finalizers") + return r.cleanupOnDeletion(ctx, state, opts) + case !state.IsProtected(): + // Set protective finalizer atomically. + if controllerutil.AddFinalizer(state.VMOP.Changed(), virtv2.FinalizerVMOPCleanup) { + state.SetReconcilerResult(&reconcile.Result{Requeue: true}) + return nil + } + case state.IsCompleted(): + log.V(2).Info("VMOP completed", "namespacedName", req.String()) + return r.removeVMFinalizers(ctx, state, opts) + + case state.IsFailed(): + log.V(2).Info("VMOP failed", "namespacedName", req.String()) + return r.removeVMFinalizers(ctx, state, opts) + case state.VmIsEmpty(): + state.SetReconcilerResult(&reconcile.Result{RequeueAfter: 2 * time.Second}) + return nil + } + found, err := state.OtherVMOPInProgress(ctx) + if err != nil { + return err + } + if found { + state.SetReconcilerResult(&reconcile.Result{Requeue: true}) + return nil + } + if !state.IsInProgress() { + state.SetInProgress() + state.SetReconcilerResult(&reconcile.Result{Requeue: true}) + return r.ensureVMFinalizers(ctx, state, opts) + } + + if !r.isOperationAllowed(state.VMOP.Current().Spec.Type, state) { + return nil + } + err = r.doOperation(ctx, state.VMOP.Current().Spec, state) + if err != nil { + msg := "The operation completed with an error." + state.SetOperationResult(false, fmt.Sprintf("%s %s", msg, err.Error())) + opts.Recorder.Event(state.VMOP.Current(), corev1.EventTypeWarning, virtv2.ReasonErrVMOPFailed, msg) + log.V(1).Error(err, msg, "vmop.name", state.VMOP.Current().GetName(), "vmop.namespace", state.VMOP.Current().GetNamespace()) + } else { + state.SetOperationResult(true, "") + msg := "The operation completed without errors." + opts.Recorder.Event(state.VMOP.Current(), corev1.EventTypeNormal, virtv2.ReasonVMOPSucceeded, msg) + log.V(2).Info(msg, "vmop.name", state.VMOP.Current().GetName(), "vmop.namespace", state.VMOP.Current().GetNamespace()) + } + return nil +} + +func (r *Reconciler) UpdateStatus(_ context.Context, _ reconcile.Request, state *ReconcilerState, opts two_phase_reconciler.ReconcilerOptions) error { + log := opts.Log.WithValues("vmop.name", state.VMOP.Current().GetName()) + log.V(2).Info("Update VMOP status", "vmop.name", state.VMOP.Current().GetName(), "vmop.namespace", state.VMOP.Current().GetNamespace()) + + if state.IsDeletion() { + return nil + } + + vmopStatus := state.VMOP.Current().Status.DeepCopy() + + switch { + case state.IsFailed(), state.IsCompleted(): + // No need to update status. + break + case vmopStatus.Phase == "": + vmopStatus.Phase = virtv2.VMOPPhasePending + state.SetReconcilerResult(&reconcile.Result{Requeue: true}) + case state.VmIsEmpty(): + vmopStatus.Phase = virtv2.VMOPPhasePending + case !r.isOperationAllowedForRunPolicy(state.VMOP.Current().Spec.Type, state.VM.Spec.RunPolicy): + vmopStatus.Phase = virtv2.VMOPPhaseFailed + vmopStatus.FailureReason = virtv2.ReasonErrVMOPNotPermitted + vmopStatus.FailureMessage = fmt.Sprintf("operation %q not permitted for vm.spec.runPolicy=%q", state.VMOP.Current().Spec.Type, state.VM.Spec.RunPolicy) + case !r.isOperationAllowedForVmPhase(state.VMOP.Current().Spec.Type, state.VM.Status.Phase): + vmopStatus.Phase = virtv2.VMOPPhaseFailed + vmopStatus.FailureReason = virtv2.ReasonErrVMOPNotPermitted + vmopStatus.FailureMessage = fmt.Sprintf("operation %q not permitted for vm.status.phase=%q", state.VMOP.Current().Spec.Type, state.VM.Status.Phase) + case state.GetInProgress(): + vmopStatus.Phase = virtv2.VMOPPhaseInProgress + } + + if result := state.GetOperationResult(); result != nil { + if result.WasSuccessful() { + vmopStatus.Phase = virtv2.VMOPPhaseCompleted + } else { + vmopStatus.Phase = virtv2.VMOPPhaseFailed + vmopStatus.FailureReason = virtv2.ReasonErrVMOPFailed + vmopStatus.FailureMessage = result.Message() + } + } + state.VMOP.Changed().Status = *vmopStatus + return nil +} + +func (r *Reconciler) IsProtected(obj client.Object) bool { + return controllerutil.ContainsFinalizer(obj, virtv2.FinalizerVMOPProtection) +} + +func (r *Reconciler) ensureVMFinalizers(ctx context.Context, state *ReconcilerState, opts two_phase_reconciler.ReconcilerOptions) error { + if state.VM != nil && controllerutil.AddFinalizer(state.VM, virtv2.FinalizerVMOPProtection) { + if err := opts.Client.Update(ctx, state.VM); err != nil { + return fmt.Errorf("error setting finalizer on a VM %q: %w", state.VM.Name, err) + } + } + return nil +} + +func (r *Reconciler) removeVMFinalizers(ctx context.Context, state *ReconcilerState, opts two_phase_reconciler.ReconcilerOptions) error { + if state.VM != nil && controllerutil.RemoveFinalizer(state.VM, virtv2.FinalizerVMOPProtection) { + if err := opts.Client.Update(ctx, state.VM); err != nil { + return fmt.Errorf("unable to remove VM %q finalizer %q: %w", state.VM.Name, virtv2.FinalizerVMOPProtection, err) + } + } + return nil +} + +func (r *Reconciler) cleanupOnDeletion(ctx context.Context, state *ReconcilerState, opts two_phase_reconciler.ReconcilerOptions) error { + if err := r.removeVMFinalizers(ctx, state, opts); err != nil { + return err + } + controllerutil.RemoveFinalizer(state.VMOP.Changed(), virtv2.FinalizerVMOPCleanup) + return nil +} + +func (r *Reconciler) doOperation(ctx context.Context, operationSpec virtv2.VirtualMachineOperationSpec, state *ReconcilerState) error { + switch operationSpec.Type { + case virtv2.VMOPOperationTypeStart: + return r.doOperationStart(ctx, state) + case virtv2.VMOPOperationTypeStop: + return r.doOperationStop(ctx, operationSpec.Force, state) + case virtv2.VMOPOperationTypeRestart: + return r.doOperationRestart(ctx, operationSpec.Force, state) + default: + return fmt.Errorf("unexpected operation %q. %w", operationSpec.Type, common.ErrUnknownValue) + } +} + +func (r *Reconciler) doOperationStart(ctx context.Context, state *ReconcilerState) error { + kvvm, err := state.GetKVVM(ctx) + if err != nil { + return fmt.Errorf("cannot get kvvm %q. %w", state.VM.Name, err) + } + return kvvmutil.StartKVVM(ctx, state.Client, kvvm) +} + +func (r *Reconciler) doOperationStop(ctx context.Context, force bool, state *ReconcilerState) error { + kvvmi, err := state.GetKVVMI(ctx) + if err != nil { + return fmt.Errorf("cannot get kvvmi %q. %w", state.VM.Name, err) + } + return kvvmutil.StopKVVM(ctx, state.Client, kvvmi, force) +} + +func (r *Reconciler) doOperationRestart(ctx context.Context, force bool, state *ReconcilerState) error { + kvvm, err := state.GetKVVM(ctx) + if err != nil { + return fmt.Errorf("cannot get kvvm %q. %w", state.VM.Name, err) + } + kvvmi, err := state.GetKVVMI(ctx) + if err != nil { + return fmt.Errorf("cannot get kvvmi %q. %w", state.VM.Name, err) + } + return kvvmutil.RestartKVVM(ctx, state.Client, kvvm, kvvmi, force) +} + +func (r *Reconciler) isOperationAllowed(op virtv2.VMOPOperation, state *ReconcilerState) bool { + if state.VmIsEmpty() { + return false + } + return r.isOperationAllowedForRunPolicy(op, state.VM.Spec.RunPolicy) && r.isOperationAllowedForVmPhase(op, state.VM.Status.Phase) +} + +func (r *Reconciler) isOperationAllowedForRunPolicy(op virtv2.VMOPOperation, runPolicy virtv2.RunPolicy) bool { + switch runPolicy { + case virtv2.AlwaysOnPolicy: + return op == virtv2.VMOPOperationTypeRestart + case virtv2.AlwaysOffPolicy: + return false + case virtv2.ManualPolicy, virtv2.AlwaysOnUnlessStoppedManualy: + return true + default: + return false + } +} + +func (r *Reconciler) isOperationAllowedForVmPhase(op virtv2.VMOPOperation, phase virtv2.MachinePhase) bool { + if phase == virtv2.MachineTerminating || + phase == virtv2.MachinePending || + phase == virtv2.MachineScheduling || + phase == virtv2.MachineMigrating { + return false + } + switch op { + case virtv2.VMOPOperationTypeStart: + return phase == virtv2.MachineStopped || phase == virtv2.MachineStopping + case virtv2.VMOPOperationTypeStop, virtv2.VMOPOperationTypeRestart: + return phase == virtv2.MachineRunning || + phase == virtv2.MachineFailed || + phase == virtv2.MachineStarting || + phase == virtv2.MachinePause + default: + return false + } +} diff --git a/images/virtualization-controller/pkg/controller/vmop/vmop_reconciler_state.go b/images/virtualization-controller/pkg/controller/vmop/vmop_reconciler_state.go new file mode 100644 index 000000000..5e516a6a7 --- /dev/null +++ b/images/virtualization-controller/pkg/controller/vmop/vmop_reconciler_state.go @@ -0,0 +1,190 @@ +package vmop + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/types" + virtv1 "kubevirt.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + virtv2 "github.com/deckhouse/virtualization-controller/api/v1alpha2" + "github.com/deckhouse/virtualization-controller/pkg/sdk/framework/helper" +) + +type ReconcilerState struct { + Client client.Client + Result *reconcile.Result + + VMOP *helper.Resource[*virtv2.VirtualMachineOperation, virtv2.VirtualMachineOperationStatus] + VM *virtv2.VirtualMachine + + operationResult *OperationResult + inProgress bool +} + +type OperationResult struct { + success bool + message string +} + +func (op *OperationResult) WasSuccessful() bool { + return op.success +} + +func (op *OperationResult) Message() string { + return op.message +} + +func NewReconcilerState(name types.NamespacedName, log logr.Logger, client client.Client, cache cache.Cache) *ReconcilerState { + state := &ReconcilerState{ + Client: client, + VMOP: helper.NewResource( + name, log, client, cache, + func() *virtv2.VirtualMachineOperation { return &virtv2.VirtualMachineOperation{} }, + func(obj *virtv2.VirtualMachineOperation) virtv2.VirtualMachineOperationStatus { return obj.Status }, + ), + } + return state +} + +func (state *ReconcilerState) Reload(ctx context.Context, req reconcile.Request, log logr.Logger, client client.Client) error { + err := state.VMOP.Fetch(ctx) + if err != nil { + return fmt.Errorf("unable to get %q: %w", req.NamespacedName, err) + } + if state.VMOP.IsEmpty() { + log.Info("Reconcile observe an absent VMOP: it may be deleted", "vmop.name", req.Name, "vmop.namespace", req.Namespace) + return nil + } + + vmName := state.VMOP.Current().Spec.VirtualMachineName + vm, err := helper.FetchObject(ctx, + types.NamespacedName{Name: vmName, Namespace: req.Namespace}, + client, + &virtv2.VirtualMachine{}) + if err != nil { + return fmt.Errorf("unable to get VM %q: %w", vmName, err) + } + state.VM = vm + + return nil +} + +func (state *ReconcilerState) ShouldReconcile(_ logr.Logger) bool { + return !state.VMOP.IsEmpty() +} + +func (state *ReconcilerState) ApplySync(ctx context.Context, _ logr.Logger) error { + if err := state.VMOP.UpdateMeta(ctx); err != nil { + return fmt.Errorf("unable to update VMOP %q meta: %w", state.VMOP.Name(), err) + } + return nil +} + +func (state *ReconcilerState) ApplyUpdateStatus(ctx context.Context, _ logr.Logger) error { + return state.VMOP.UpdateStatus(ctx) +} + +func (state *ReconcilerState) SetReconcilerResult(result *reconcile.Result) { + state.Result = result +} + +func (state *ReconcilerState) GetReconcilerResult() *reconcile.Result { + return state.Result +} + +func (state *ReconcilerState) IsDeletion() bool { + if state.VMOP.IsEmpty() { + return false + } + return state.VMOP.Current().DeletionTimestamp != nil +} + +func (state *ReconcilerState) IsProtected() bool { + return controllerutil.ContainsFinalizer(state.VMOP.Current(), virtv2.FinalizerVMOPCleanup) +} + +func (state *ReconcilerState) IsCompleted() bool { + if state.VMOP.IsEmpty() { + return false + } + return state.VMOP.Current().Status.Phase == virtv2.VMOPPhaseCompleted +} + +func (state *ReconcilerState) IsFailed() bool { + if state.VMOP.IsEmpty() { + return false + } + return state.VMOP.Current().Status.Phase == virtv2.VMOPPhaseFailed +} + +func (state *ReconcilerState) IsInProgress() bool { + if state.VMOP.IsEmpty() { + return false + } + return state.VMOP.Current().Status.Phase == virtv2.VMOPPhaseInProgress +} + +func (state *ReconcilerState) VmIsEmpty() bool { + return state.VM == nil +} + +func (state *ReconcilerState) OtherVMOPInProgress(ctx context.Context) (bool, error) { + vmops := virtv2.VirtualMachineOperationList{} + err := state.Client.List(ctx, &vmops, &client.ListOptions{Namespace: state.VMOP.Current().Namespace}) + if err != nil { + return false, err + } + vmName := state.VMOP.Current().Spec.VirtualMachineName + + for _, vmop := range vmops.Items { + if vmop.GetName() == state.VMOP.Current().GetName() || vmop.Spec.VirtualMachineName != vmName { + continue + } + if vmop.Status.Phase == virtv2.VMOPPhaseInProgress { + return true, nil + } + } + return false, nil +} + +func (state *ReconcilerState) SetOperationResult(result bool, msg string) { + state.operationResult = &OperationResult{message: msg, success: result} +} + +func (state *ReconcilerState) GetOperationResult() *OperationResult { + return state.operationResult +} + +func (state *ReconcilerState) SetInProgress() { + state.inProgress = true +} + +func (state *ReconcilerState) GetInProgress() bool { + return state.inProgress +} + +func (state *ReconcilerState) GetKVVM(ctx context.Context) (*virtv1.VirtualMachine, error) { + if state.VmIsEmpty() { + return nil, fmt.Errorf("VM %s not found", state.VMOP.Current().Spec.VirtualMachineName) + } + kvvm := &virtv1.VirtualMachine{} + key := types.NamespacedName{Name: state.VM.GetName(), Namespace: state.VM.GetNamespace()} + err := state.Client.Get(ctx, key, kvvm) + return kvvm, err +} + +func (state *ReconcilerState) GetKVVMI(ctx context.Context) (*virtv1.VirtualMachineInstance, error) { + if state.VmIsEmpty() { + return nil, fmt.Errorf("VM %s not found", state.VMOP.Current().Spec.VirtualMachineName) + } + kvvmi := &virtv1.VirtualMachineInstance{} + key := types.NamespacedName{Name: state.VM.GetName(), Namespace: state.VM.GetNamespace()} + err := state.Client.Get(ctx, key, kvvmi) + return kvvmi, err +} diff --git a/images/virtualization-controller/pkg/controller/vmop/vmop_webhook.go b/images/virtualization-controller/pkg/controller/vmop/vmop_webhook.go new file mode 100644 index 000000000..868abece2 --- /dev/null +++ b/images/virtualization-controller/pkg/controller/vmop/vmop_webhook.go @@ -0,0 +1,54 @@ +package vmop + +import ( + "context" + "fmt" + "reflect" + + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/deckhouse/virtualization-controller/api/v1alpha2" +) + +func NewValidator(log logr.Logger) *Validator { + return &Validator{ + log: log.WithName(controllerName).WithValues("webhook", "validation"), + } +} + +type Validator struct { + log logr.Logger +} + +func (v *Validator) ValidateCreate(_ context.Context, _ runtime.Object) (admission.Warnings, error) { + err := fmt.Errorf("misconfigured webhook rules: create operation not implemented") + v.log.Error(err, "Ensure the correctness of ValidatingWebhookConfiguration") + return nil, nil +} + +func (v *Validator) ValidateUpdate(_ context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + oldVmop, ok := oldObj.(*v1alpha2.VirtualMachineOperation) + if !ok { + return nil, fmt.Errorf("expected an old VirtualMachineOperation but got a %T", oldObj) + } + newVmop, ok := newObj.(*v1alpha2.VirtualMachineOperation) + if !ok { + return nil, fmt.Errorf("expected a new VirtualMachineOperation but got a %T", newObj) + } + + v.log.Info("Validate VMOP updating", "name", oldVmop.GetName()) + + if reflect.DeepEqual(oldVmop.Spec, newVmop.Spec) { + return nil, nil + } + err := fmt.Errorf("vmop %q is invalid. vmop.spec is immutable", oldVmop.GetName()) + return nil, err +} + +func (v *Validator) ValidateDelete(_ context.Context, _ runtime.Object) (admission.Warnings, error) { + err := fmt.Errorf("misconfigured webhook rules: delete operation not implemented") + v.log.Error(err, "Ensure the correctness of ValidatingWebhookConfiguration") + return nil, nil +} diff --git a/templates/virtualization-controller/rbac-for-us.yaml b/templates/virtualization-controller/rbac-for-us.yaml index 09d6c6aae..431cb9be8 100644 --- a/templates/virtualization-controller/rbac-for-us.yaml +++ b/templates/virtualization-controller/rbac-for-us.yaml @@ -111,6 +111,13 @@ rules: - get - list - watch +- apiGroups: + - x.virtualization.deckhouse.io + resources: + - virtualmachines/status + verbs: + - patch + - update - apiGroups: - subresources.kubevirt.io resources: @@ -131,6 +138,7 @@ rules: - virtualmachineblockdeviceattachments - virtualmachines - clustervirtualmachineimages + - virtualmachineoperations verbs: - create - delete @@ -150,6 +158,7 @@ rules: - clustervirtualmachineimages/finalizers - virtualmachineipaddressleases/finalizers - virtualmachineipaddressclaims/finalizers + - virtualmachineoperations/finalizers - virtualmachineipaddressclaims/status - virtualmachineipaddressleases/status - virtualmachinedisks/status @@ -158,6 +167,7 @@ rules: - virtualmachineblockdeviceattachments/status - virtualmachines/status - clustervirtualmachineimages/status + - virtualmachineoperations/status verbs: - patch - update diff --git a/templates/virtualization-controller/validation-webhook.yaml b/templates/virtualization-controller/validation-webhook.yaml index 44c2a77e1..8cda13fb9 100644 --- a/templates/virtualization-controller/validation-webhook.yaml +++ b/templates/virtualization-controller/validation-webhook.yaml @@ -6,7 +6,7 @@ webhooks: - name: "vm.virtualization-controller.validate.d8-virtualization" rules: - apiGroups: ["virtualization.deckhouse.io"] - apiVersions: ["v2alpha1"] + apiVersions: ["v1alpha2"] operations: ["CREATE", "UPDATE"] resources: ["virtualmachines"] scope: "Namespaced" @@ -14,7 +14,7 @@ webhooks: service: namespace: d8-{{ .Chart.Name }} name: virtualization-controller-admission-webhook - path: /validate-virtualization-deckhouse-io-v2alpha1-virtualmachine + path: /validate-virtualization-deckhouse-io-v1alpha2-virtualmachine port: 443 caBundle: | {{ .Values.virtualization.internal.admissionWebhookCert.ca }} @@ -23,7 +23,7 @@ webhooks: - name: "vmd.virtualization-controller.validate.d8-virtualization" rules: - apiGroups: ["virtualization.deckhouse.io"] - apiVersions: ["v2alpha1"] + apiVersions: ["v1alpha2"] operations: ["CREATE", "UPDATE"] resources: ["virtualmachinedisks"] scope: "Namespaced" @@ -31,7 +31,7 @@ webhooks: service: namespace: d8-{{ .Chart.Name }} name: virtualization-controller-admission-webhook - path: /validate-virtualization-deckhouse-io-v2alpha1-virtualmachinedisk + path: /validate-virtualization-deckhouse-io-v1alpha2-virtualmachinedisk port: 443 caBundle: | {{ .Values.virtualization.internal.admissionWebhookCert.ca }} @@ -40,7 +40,7 @@ webhooks: - name: "vmbda.virtualization-controller.validate.d8-virtualization" rules: - apiGroups: ["virtualization.deckhouse.io"] - apiVersions: ["v2alpha1"] + apiVersions: ["v1alpha2"] operations: ["CREATE", "UPDATE"] resources: ["virtualmachineblockdeviceattachments"] scope: "Namespaced" @@ -48,7 +48,7 @@ webhooks: service: namespace: d8-{{ .Chart.Name }} name: virtualization-controller-admission-webhook - path: /validate-virtualization-deckhouse-io-v2alpha1-virtualmachineblockdeviceattachment + path: /validate-virtualization-deckhouse-io-v1alpha2-virtualmachineblockdeviceattachment port: 443 caBundle: | {{ .Values.virtualization.internal.admissionWebhookCert.ca }} @@ -57,7 +57,7 @@ webhooks: - name: "vmip.virtualization-controller.validate.d8-virtualization" rules: - apiGroups: ["virtualization.deckhouse.io"] - apiVersions: ["v2alpha1"] + apiVersions: ["v1alpha2"] operations: ["CREATE", "UPDATE"] resources: ["virtualmachineipaddressclaims"] scope: "Namespaced" @@ -65,7 +65,7 @@ webhooks: service: namespace: d8-{{ .Chart.Name }} name: virtualization-controller-admission-webhook - path: /validate-virtualization-deckhouse-io-v2alpha1-virtualmachineipaddressclaim + path: /validate-virtualization-deckhouse-io-v1alpha2-virtualmachineipaddressclaim port: 443 caBundle: | {{ .Values.virtualization.internal.admissionWebhookCert.ca }} @@ -74,7 +74,7 @@ webhooks: - name: "vmipl.virtualization-controller.validate.d8-virtualization" rules: - apiGroups: ["virtualization.deckhouse.io"] - apiVersions: ["v2alpha1"] + apiVersions: ["v1alpha2"] operations: ["CREATE"] resources: ["virtualmachineipaddressleases"] scope: "Cluster" @@ -82,7 +82,24 @@ webhooks: service: namespace: d8-{{ .Chart.Name }} name: virtualization-controller-admission-webhook - path: /validate-virtualization-deckhouse-io-v2alpha1-virtualmachineipaddresslease + path: /validate-virtualization-deckhouse-io-v1alpha2-virtualmachineipaddresslease + port: 443 + caBundle: | + {{ .Values.virtualization.internal.admissionWebhookCert.ca }} + admissionReviewVersions: ["v1"] + sideEffects: None + - name: "vmop.virtualization-controller.validate.d8-virtualization" + rules: + - apiGroups: ["virtualization.deckhouse.io"] + apiVersions: ["v1alpha2"] + operations: ["UPDATE"] + resources: ["virtualmachineoperations"] + scope: "Namespaced" + clientConfig: + service: + namespace: d8-{{ .Chart.Name }} + name: virtualization-controller-admission-webhook + path: /validate-virtualization-deckhouse-io-v1alpha2-virtualmachineoperation port: 443 caBundle: | {{ .Values.virtualization.internal.admissionWebhookCert.ca }}