diff --git a/.prettierignore b/.prettierignore index e1b45c5caa..40ab026e2e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,7 @@ **/*.git **/.svn **/.hg +images/**/mount-points.yaml **/werf*.yaml **/werf*.yml .werf/** diff --git a/.werf/defines/image-mountpoints.tmpl b/.werf/defines/image-mountpoints.tmpl new file mode 100644 index 0000000000..9c76a3f917 --- /dev/null +++ b/.werf/defines/image-mountpoints.tmpl @@ -0,0 +1,32 @@ +{{/* + +Template to bake mount points in the image. These static mount points +are required so containerd can start a container with image integrity check. + +Problem: each directory specified in volumeMounts items should exist +in image, containerd is unable to create mount point for us when +integrity check is enabled. + +Solution: define all possible mount points in mount-points.yaml file and +include this template in git section of the werf.inc.yaml. + +*/}} +{{/* NOTE: Keep in sync with version in Deckhouse CSE */}} +{{- define "image mount points" }} +{{- $mountPoints := ($.Files.Get (printf "images/%s/mount-points.yaml" $.ImageName) | fromYaml) }} +{{- $context := . }} +{{- range $v := $mountPoints.dirs }} +- add: /tools/mounts/mountdir + to: {{ $v | trimSuffix "/" }} + stageDependencies: + install: + - "**/*" +{{- end }} +{{- range $v := $mountPoints.files }} +- add: /tools/mounts/mountfile + to: {{ $v }} + stageDependencies: + install: + - "**/*" +{{- end }} +{{- end }} diff --git a/.werf/defines/images.tmpl b/.werf/defines/images.tmpl index 86f19cf68b..51152c5e52 100644 --- a/.werf/defines/images.tmpl +++ b/.werf/defines/images.tmpl @@ -46,4 +46,4 @@ Result: {{- end }} {{- end -}} {{- end }} -{{ end }} \ No newline at end of file +{{ end }} diff --git a/CHANGELOG/CHANGELOG-v1.0.0.yml b/CHANGELOG/CHANGELOG-v1.0.0.yml new file mode 100644 index 0000000000..7a080f8683 --- /dev/null +++ b/CHANGELOG/CHANGELOG-v1.0.0.yml @@ -0,0 +1,45 @@ +api: + features: + - summary: add metadata preservation for VirtualDisk + pull_request: https://github.com/deckhouse/virtualization/pull/1426 + fixes: + - summary: Errors in test cases should not stop the Ginkgo process. + pull_request: https://github.com/deckhouse/virtualization/pull/1435 + - summary: Fixed kubebuilder annotations to generate CRDs with correct categories and short names. + pull_request: https://github.com/deckhouse/virtualization/pull/1421 +ci: + fixes: + - summary: fix release branches scan + pull_request: https://github.com/deckhouse/virtualization/pull/1431 + - summary: >- + The `run:ci` script should be executed from within the script itself to avoid issues with + shell processing on a GitHub runner. + pull_request: https://github.com/deckhouse/virtualization/pull/1427 +core: + features: + - summary: Add VM restore feature using new type Restore for VirtualMachineOperation + pull_request: https://github.com/deckhouse/virtualization/pull/1307 + fixes: + - summary: add missing variable 'ModuleNamePrefix' in images.tmpl + pull_request: https://github.com/deckhouse/virtualization/pull/1439 + - summary: fix CVE-2025-47907 + pull_request: https://github.com/deckhouse/virtualization/pull/1413 +vd: + fixes: + - summary: Set disk to failed when image pull fails from registry + pull_request: https://github.com/deckhouse/virtualization/pull/1400 +vm: + fixes: + - summary: fix `cores` and `coreFraction` validation in sizing policy + pull_request: https://github.com/deckhouse/virtualization/pull/1420 + - summary: >- + fix incorrect data encoding during snapshot creation and restoration by removing redundant + base64 encoding when storing JSON in Kubernetes Secrets. + pull_request: https://github.com/deckhouse/virtualization/pull/1419 + - summary: fix message in NetworkReady condition + pull_request: https://github.com/deckhouse/virtualization/pull/1414 + - summary: Add display of `.status.network` if `.spec.network` is empty + pull_request: https://github.com/deckhouse/virtualization/pull/1412 + - summary: Block network spec changes when SDN feature gate is disabled + pull_request: https://github.com/deckhouse/virtualization/pull/1408 + diff --git a/CHANGELOG/CHANGELOG-v1.0.md b/CHANGELOG/CHANGELOG-v1.0.md new file mode 100644 index 0000000000..a5b935599a --- /dev/null +++ b/CHANGELOG/CHANGELOG-v1.0.md @@ -0,0 +1,29 @@ +# Changelog v1.0 + +## Features + + + - **[api]** add metadata preservation for VirtualDisk [#1426](https://github.com/deckhouse/virtualization/pull/1426) + - **[core]** Add VM restore feature using new type Restore for VirtualMachineOperation [#1307](https://github.com/deckhouse/virtualization/pull/1307) + +## Fixes + + + - **[api]** Fixed kubebuilder annotations to generate CRDs with correct categories and short names. [#1421](https://github.com/deckhouse/virtualization/pull/1421) + - **[core]** fix CVE-2025-47907 [#1413](https://github.com/deckhouse/virtualization/pull/1413) + - **[vd]** Set disk to failed when image pull fails from registry [#1400](https://github.com/deckhouse/virtualization/pull/1400) + - **[vm]** fix `cores` and `coreFraction` validation in sizing policy [#1420](https://github.com/deckhouse/virtualization/pull/1420) + - **[vm]** fix incorrect data encoding during snapshot creation and restoration by removing redundant base64 encoding when storing JSON in Kubernetes Secrets. [#1419](https://github.com/deckhouse/virtualization/pull/1419) + - **[vm]** fix message in NetworkReady condition [#1414](https://github.com/deckhouse/virtualization/pull/1414) + - **[vm]** Add display of `.status.network` if `.spec.network` is empty [#1412](https://github.com/deckhouse/virtualization/pull/1412) + - **[vm]** Block network spec changes when SDN feature gate is disabled [#1408](https://github.com/deckhouse/virtualization/pull/1408) + +## Chore + + + - **[api]** Updated CRD short names to remove plural forms and reorganized resource categories. [#1407](https://github.com/deckhouse/virtualization/pull/1407) + - **[core]** Reduce kubevirt components restarts. [#1449](https://github.com/deckhouse/virtualization/pull/1449) + - **[module]** Reduce module restarts during installation. [#1445](https://github.com/deckhouse/virtualization/pull/1445) + - **[module]** Support "in-cluster" upload when publicDomainTemplate is empty. [#1440](https://github.com/deckhouse/virtualization/pull/1440) + - **[vm]** Check is first block device bootable. [#1359](https://github.com/deckhouse/virtualization/pull/1359) + diff --git a/README.md b/README.md index 9ab4f55daf..3a046e7e82 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,7 @@ The following minimum resources are recommended for infrastructure nodes, depend ### Requirements for platform nodes: -- Linux-based OS: - - CentOS 7, 8, 9 - - Debian 10, 11, 12 - - Rocky Linux 8, 9 - - Ubuntu 18.04, 20.04, 22.04, 24.04 +- [Supported Linux-based OS](https://deckhouse.io/products/kubernetes-platform/documentation/v1/supported_versions.html#linux) - Linux kernel version >= 5.7 - CPU with x86_64 c architecture with support for Intel-VT (vmx) or AMD-V (svm) instructions @@ -47,8 +43,10 @@ The following minimum resources are recommended for infrastructure nodes, depend - [SDS-Local-volume](https://deckhouse.io/modules/sds-local-volume/stable/) - [CSI-nfs](https://deckhouse.io/modules/csi-nfs/stable/) - [CSI-CEPH](https://deckhouse.io/modules/csi-ceph/stable/) + ... + +3. [Set](https://deckhouse.io/products/kubernetes-platform/documentation/v1/storage/admin/supported-storage.html#how-to-set-the-default-storageclass) default `StorageClass`. -3. [Set](https://kubernetes.io/docs/tasks/administer-cluster/change-default-storage-class/) default `StorageClass`. 4. Turn on the [console](https://deckhouse.ru/modules/console/stable/) module, which will allow you to manage virtualization components through via UI (This feature is available only to users of the EE edition). 5. Enable the `virtualization` module: diff --git a/api/core/v1alpha2/events.go b/api/core/v1alpha2/events.go index 4a028e1ce4..9563718cd5 100644 --- a/api/core/v1alpha2/events.go +++ b/api/core/v1alpha2/events.go @@ -40,6 +40,9 @@ const ( // ReasonVMStartFailed is an event reason indicating that the start of the VM failed. ReasonVMStartFailed = "Failed" + // ReasonVMStopFailed is an event reason indicating that the stop of the VM failed. + ReasonVMStopFailed = "Failed" + // ReasonVMLastAppliedSpecIsInvalid is event reason that JSON in last-applied-spec annotation is invalid. ReasonVMLastAppliedSpecIsInvalid = "LastAppliedSpecIsInvalid" diff --git a/api/core/v1alpha2/virtual_machine_operation.go b/api/core/v1alpha2/virtual_machine_operation.go index 2542f04fdf..09bd01ef41 100644 --- a/api/core/v1alpha2/virtual_machine_operation.go +++ b/api/core/v1alpha2/virtual_machine_operation.go @@ -45,6 +45,7 @@ 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." type VirtualMachineOperationSpec struct { Type VMOPType `json:"type"` // Name of the virtual machine the operation is performed for. @@ -54,14 +55,90 @@ type VirtualMachineOperationSpec struct { // * 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. Force *bool `json:"force,omitempty"` + // Restore defines the restore operation. + Restore *VirtualMachineOperationRestoreSpec `json:"restore,omitempty"` } +// VirtualMachineOperationRestoreSpec defines the restore operation. +type VirtualMachineOperationRestoreSpec struct { + Mode VMOPRestoreMode `json:"mode"` + // VirtualMachineSnapshotName defines the source of the restore operation. + VirtualMachineSnapshotName string `json:"virtualMachineSnapshotName"` +} + +// VMOPRestoreMode defines the kind of the restore operation. +// * `DryRun`: DryRun run without any changes. Compatibility shows in status. +// * `Strict`: Strict restore as is in the snapshot. +// * `BestEffort`: BestEffort restore without deleted external missing dependencies. +// +kubebuilder:validation:Enum={DryRun,Strict,BestEffort} +type VMOPRestoreMode string + +const ( + VMOPRestoreModeDryRun VMOPRestoreMode = "DryRun" + VMOPRestoreModeStrict VMOPRestoreMode = "Strict" + VMOPRestoreModeBestEffort VMOPRestoreMode = "BestEffort" +) + type VirtualMachineOperationStatus struct { Phase VMOPPhase `json:"phase"` // The latest detailed observations of the VirtualMachineOperation resource. Conditions []metav1.Condition `json:"conditions,omitempty"` // Resource generation last processed by the controller. ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // Resources contains the list of resources that are affected by the snapshot operation. + Resources []VirtualMachineOperationResource `json:"resources,omitempty"` +} + +// VMOPResourceKind defines the kind of the resource affected by the operation. +// * `VirtualDisk`: VirtualDisk resource. +// * `VirtualMachine`: VirtualMachine resource. +// * `VirtualImage`: VirtualImage resource. +// * `ClusterVirtualImage`: ClusterVirtualImage resource. +// * `VirtualMachineIPAddress`: VirtualMachineIPAddress resource. +// * `VirtualMachineIPAddressLease`: VirtualMachineIPAddressLease resource. +// * `VirtualMachineClass`: VirtualMachineClass resource. +// * `VirtualMachineOperation`: VirtualMachineOperation resource. +// +kubebuilder:validation:Enum={VMOPResourceSecret,VMOPResourceNetwork,VMOPResourceVirtualDisk,VMOPResourceVirtualImage,VMOPResourceVirtualMachine,VMOPResourceClusterNetwork,VMOPResourceClusterVirtualImage,VMOPResourceVirtualMachineIPAddress,VMOPResourceVirtualMachineMacAddress,VMOPResourceVirtualMachineBlockDeviceAttachment} +type VMOPResourceKind string + +const ( + VMOPResourceSecret VMOPResourceKind = "Secret" + VMOPResourceNetwork VMOPResourceKind = "Network" + VMOPResourceVirtualDisk VMOPResourceKind = "VirtualDisk" + VMOPResourceVirtualImage VMOPResourceKind = "VirtualImage" + VMOPResourceVirtualMachine VMOPResourceKind = "VirtualMachine" + VMOPResourceClusterNetwork VMOPResourceKind = "ClusterNetwork" + VMOPResourceClusterVirtualImage VMOPResourceKind = "ClusterVirtualImage" + VMOPResourceVirtualMachineIPAddress VMOPResourceKind = "VirtualMachineIPAddress" + VMOPResourceVirtualMachineMacAddress VMOPResourceKind = "VirtualMachineMacAddress" + VMOPResourceVirtualMachineBlockDeviceAttachment VMOPResourceKind = "VirtualMachineBlockDeviceAttachment" +) + +const ( + VMOPResourceStatusInProgress VMOPResourceStatusPhase = "InProgress" + VMOPResourceStatusCompleted VMOPResourceStatusPhase = "Completed" + VMOPResourceStatusFailed VMOPResourceStatusPhase = "Failed" +) + +// Current phase of the resource: +// * `InProgress`: The operation for resource is in progress. +// * `Completed`: The operation for resource has been completed successfully. +// * `Failed`: The operation for resource failed. For details, refer to the `Message` field. +// +kubebuilder:validation:Enum={InProgress,Completed,Failed} +type VMOPResourceStatusPhase string + +// VirtualMachineOperationResource defines the resource affected by the operation. +type VirtualMachineOperationResource struct { + // API version of the resource. + APIVersion string `json:"apiVersion"` + // Name of the resource. + Name string `json:"name"` + // Kind of the resource. + Kind string `json:"kind"` + // Status of the resource. + Status VMOPResourceStatusPhase `json:"status"` + // Message about the resource. + Message string `json:"message"` } // VirtualMachineOperationList contains a list of VirtualMachineOperation resources. @@ -95,7 +172,8 @@ const ( // * `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. -// +kubebuilder:validation:Enum={Restart,Start,Stop,Migrate,Evict} +// * `Restore`: Restore the virtual machine from a snapshot. +// +kubebuilder:validation:Enum={Restart,Start,Stop,Migrate,Evict,Restore} type VMOPType string const ( @@ -104,4 +182,5 @@ const ( VMOPTypeStop VMOPType = "Stop" VMOPTypeMigrate VMOPType = "Migrate" VMOPTypeEvict VMOPType = "Evict" + VMOPTypeRestore VMOPType = "Restore" ) diff --git a/api/core/v1alpha2/vmopcondition/condition.go b/api/core/v1alpha2/vmopcondition/condition.go index 33aa19eb46..a5f884b58e 100644 --- a/api/core/v1alpha2/vmopcondition/condition.go +++ b/api/core/v1alpha2/vmopcondition/condition.go @@ -28,6 +28,12 @@ const ( // TypeSignalSent is a type for condition that indicates operation signal has been sent. TypeSignalSent Type = "SignalSent" + + // TypeRestoreCompleted is a type for condition that indicates success of restore. + TypeRestoreCompleted Type = "RestoreCompleted" + + // TypeMaintenanceMode is a type for condition that indicates VMOP has put VM in maintenance mode. + TypeMaintenanceMode Type = "MaintenanceMode" ) // ReasonCompleted represents specific reasons for the 'Completed' condition type. @@ -62,6 +68,9 @@ const ( // ReasonStopInProgress is a ReasonCompleted indicating that the stop signal has been sent and stop is in progress. ReasonStopInProgress ReasonCompleted = "StopInProgress" + // ReasonRestoreInProgress is a ReasonCompleted indicating that the restore operation is in progress. + ReasonRestoreInProgress ReasonCompleted = "RestoreInProgress" + // ReasonMigrationPending is a ReasonCompleted indicating that the migration process has been initiated but not yet started. ReasonMigrationPending ReasonCompleted = "MigrationPending" @@ -87,6 +96,27 @@ const ( ReasonOperationCompleted ReasonCompleted = "OperationCompleted" ) +// ReasonRestoreCompleted represents specific reasons for the 'RestoreCompleted' condition type. +type ReasonRestoreCompleted string + +func (r ReasonRestoreCompleted) String() string { + return string(r) +} + +const ( + // ReasonRestoreInProgress is a ReasonRestoreCompleted indicating that the restore operation is in progress. + ReasonRestoreOperationInProgress ReasonRestoreCompleted = "RestoreInProgress" + + // ReasonRestoreOperationCompleted is a ReasonRestoreCompleted indicating that the restore operation has completed successfully. + ReasonRestoreOperationCompleted ReasonRestoreCompleted = "RestoreCompleted" + + // ReasonDryRunOperationCompleted is a ReasonRestoreCompleted indicating that the restore dry run operation has completed successfully. + ReasonDryRunOperationCompleted ReasonRestoreCompleted = "RestoreDryRunCompleted" + + // ReasonRestoreOperationFailed is a ReasonRestoreCompleted indicating that operation has failed. + ReasonRestoreOperationFailed ReasonRestoreCompleted = "RestoreFailed" +) + // ReasonCompleted represents specific reasons for the 'SignalSent' condition type. type ReasonSignalSent string @@ -101,3 +131,21 @@ const ( // ReasonSignalSentSuccess is a ReasonCompleted indicating that signal is sent to the VM. ReasonSignalSentSuccess ReasonSignalSent = "SignalSentSuccess" ) + +// ReasonMaintenanceMode represents specific reasons for the 'MaintenanceMode' condition type. +type ReasonMaintenanceMode string + +func (r ReasonMaintenanceMode) String() string { + return string(r) +} + +const ( + // ReasonMaintenanceModeEnabled is a ReasonMaintenanceMode indicating that VM is in maintenance mode for restore operation. + ReasonMaintenanceModeEnabled ReasonMaintenanceMode = "MaintenanceModeEnabled" + + // ReasonMaintenanceModeDisabled is a ReasonMaintenanceMode indicating that VM has exited maintenance mode. + ReasonMaintenanceModeDisabled ReasonMaintenanceMode = "MaintenanceModeDisabled" + + // ReasonMaintenanceModeFailure is a ReasonMaintenanceMode indicating that maintenance mode operation failed. + ReasonMaintenanceModeFailure ReasonMaintenanceMode = "MaintenanceModeFailure" +) diff --git a/api/core/v1alpha2/zz_generated.deepcopy.go b/api/core/v1alpha2/zz_generated.deepcopy.go index 0b4c99dbf6..603d7426fb 100644 --- a/api/core/v1alpha2/zz_generated.deepcopy.go +++ b/api/core/v1alpha2/zz_generated.deepcopy.go @@ -2515,6 +2515,38 @@ 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 *VirtualMachineOperationResource) DeepCopyInto(out *VirtualMachineOperationResource) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMachineOperationResource. +func (in *VirtualMachineOperationResource) DeepCopy() *VirtualMachineOperationResource { + if in == nil { + return nil + } + out := new(VirtualMachineOperationResource) + 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 + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VirtualMachineOperationRestoreSpec. +func (in *VirtualMachineOperationRestoreSpec) DeepCopy() *VirtualMachineOperationRestoreSpec { + if in == nil { + return nil + } + out := new(VirtualMachineOperationRestoreSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VirtualMachineOperationSpec) DeepCopyInto(out *VirtualMachineOperationSpec) { *out = *in @@ -2523,6 +2555,11 @@ func (in *VirtualMachineOperationSpec) DeepCopyInto(out *VirtualMachineOperation *out = new(bool) **out = **in } + if in.Restore != nil { + in, out := &in.Restore, &out.Restore + *out = new(VirtualMachineOperationRestoreSpec) + **out = **in + } return } @@ -2546,6 +2583,11 @@ func (in *VirtualMachineOperationStatus) DeepCopyInto(out *VirtualMachineOperati (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = make([]VirtualMachineOperationResource, len(*in)) + copy(*out, *in) + } return } diff --git a/api/pkg/apiserver/api/generated/openapi/zz_generated.openapi.go b/api/pkg/apiserver/api/generated/openapi/zz_generated.openapi.go index c42ddfa956..a423f0e01d 100644 --- a/api/pkg/apiserver/api/generated/openapi/zz_generated.openapi.go +++ b/api/pkg/apiserver/api/generated/openapi/zz_generated.openapi.go @@ -4859,6 +4859,90 @@ func schema_virtualization_api_core_v1alpha2_VirtualMachineOperationList(ref com } } +func schema_virtualization_api_core_v1alpha2_VirtualMachineOperationResource(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "VirtualMachineOperationResource defines the resource affected by the operation.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "API version of the resource.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "name": { + SchemaProps: spec.SchemaProps{ + Description: "Name of the resource.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind of the resource.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "status": { + SchemaProps: spec.SchemaProps{ + Description: "Status of the resource.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "message": { + SchemaProps: spec.SchemaProps{ + Description: "Message about the resource.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"apiVersion", "name", "kind", "status", "message"}, + }, + }, + } +} + +func schema_virtualization_api_core_v1alpha2_VirtualMachineOperationRestoreSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "VirtualMachineOperationRestoreSpec defines the restore operation.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "mode": { + SchemaProps: spec.SchemaProps{ + Description: "Mode defines the restore mode.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "virtualMachineSnapshotName": { + SchemaProps: spec.SchemaProps{ + Description: "VirtualMachineSnapshotName defines the source of the restore operation.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"mode", "virtualMachineSnapshotName"}, + }, + }, + } +} + func schema_virtualization_api_core_v1alpha2_VirtualMachineOperationSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -4887,10 +4971,18 @@ func schema_virtualization_api_core_v1alpha2_VirtualMachineOperationSpec(ref com Format: "", }, }, + "restore": { + SchemaProps: spec.SchemaProps{ + Description: "Restore defines the restore operation.", + Ref: ref("github.com/deckhouse/virtualization/api/core/v1alpha2.VirtualMachineOperationRestoreSpec"), + }, + }, }, Required: []string{"type", "virtualMachineName"}, }, }, + Dependencies: []string{ + "github.com/deckhouse/virtualization/api/core/v1alpha2.VirtualMachineOperationRestoreSpec"}, } } @@ -4928,12 +5020,26 @@ func schema_virtualization_api_core_v1alpha2_VirtualMachineOperationStatus(ref c Format: "int64", }, }, + "resources": { + SchemaProps: spec.SchemaProps{ + Description: "Resources contains the list of resources that are affected by the snapshot operation.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/deckhouse/virtualization/api/core/v1alpha2.VirtualMachineOperationResource"), + }, + }, + }, + }, + }, }, Required: []string{"phase"}, }, }, Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.Condition"}, + "github.com/deckhouse/virtualization/api/core/v1alpha2.VirtualMachineOperationResource", "k8s.io/apimachinery/pkg/apis/meta/v1.Condition"}, } } diff --git a/build/components/versions.yml b/build/components/versions.yml index 5fb98de68c..7bccdb4672 100644 --- a/build/components/versions.yml +++ b/build/components/versions.yml @@ -3,8 +3,8 @@ firmware: libvirt: v10.9.0 edk2: stable202411 core: - 3p-kubevirt: v1.3.1-v12n.10 - 3p-containerized-data-importer: v1.60.3-v12n.9 + 3p-kubevirt: v1.3.1-v12n.12 + 3p-containerized-data-importer: v1.60.3-v12n.10 distribution: 2.8.3 package: acl: v2.3.1 diff --git a/crds/doc-ru-virtualmachineoperations.yaml b/crds/doc-ru-virtualmachineoperations.yaml index 7162406664..fc93ceced2 100644 --- a/crds/doc-ru-virtualmachineoperations.yaml +++ b/crds/doc-ru-virtualmachineoperations.yaml @@ -16,7 +16,8 @@ spec: * `Stop` - остановить виртуальную машину; * `Restart` - перезапустить виртуальную машину; * `Migrate` (устаревшее значение) - мигрировать виртуальную машину на другой узел, доступный для запуска данной ВМ; - * `Evict` - мигрировать виртуальную машину на другой узел, доступный для запуска данной ВМ. + * `Evict` - мигрировать виртуальную машину на другой узел, доступный для запуска данной ВМ; + * `Restore` - восстановить виртуальную машину из снимка. virtualMachineName: description: | Имя виртуальной машины, для которой выполняется операция. @@ -25,7 +26,21 @@ spec: Форсирует выполнение операции. * Для операций `Restart` и `Stop`: выполнить операцию немедленно. - * Для операции `Evict` и `Migrate`: включить или выключить замедление CPU, если для живой миграции используются политики `PreferSafe` или `PreferForced`. + * Для операции `Evict` и `Migrate`: включить функцию AutoConverge для принудительной миграции через замедление CPU, если для живой миграции используются политики `PreferSafe` или `PreferForced`. + restore: + description: | + Определяет операцию восстановления из снимка. + properties: + mode: + description: | + Режим восстановления: + + * `DryRun` — запуск без выполнения восстановления. Конфликты и несоответствия фиксируются в статусе операции. + * `Strict` — строгий режим восстановления «как в снимке». Отсутствие внешних зависимостей может привести к тому, что виртуальная машина после восстановления будет находиться в состоянии Pending; + * `BestEffort` — режим восстановления с удалением отсутствующих внешних зависимостей (ClusterVirtualImage, VirtualImage, Secret) из спецификации виртуальной машины. + virtualMachineSnapshotName: + description: | + Имя снимка виртуальной машины, который используется как источник для операции восстановления. status: properties: conditions: @@ -62,3 +77,20 @@ spec: observedGeneration: description: | Поколение ресурса, которое в последний раз обрабатывалось контроллером. + resources: + description: | + Содержит список ресурсов, затронутых операцией восстановления снимка. + items: + description: | + Определяет ресурс, затронутый операцией. + properties: + apiVersion: + description: API версия ресурса. + kind: + description: Тип ресурса. + message: + description: Сообщение о ресурсе. + name: + description: Имя ресурса. + status: + description: Статус ресурса. diff --git a/crds/virtualmachineoperations.yaml b/crds/virtualmachineoperations.yaml index 4429ce4b72..2866ad3051 100644 --- a/crds/virtualmachineoperations.yaml +++ b/crds/virtualmachineoperations.yaml @@ -71,6 +71,29 @@ 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 + restore: + description: Restore defines the restore operation. + properties: + mode: + description: |- + VMOPRestoreMode defines the kind of the restore operation. + * `DryRun`: DryRun run without any changes. Compatibility shows in status. + * `Strict`: Strict restore as is in the snapshot. + * `BestEffort`: BestEffort restore without deleted external missing dependencies. + enum: + - DryRun + - Strict + - BestEffort + type: string + virtualMachineSnapshotName: + description: + VirtualMachineSnapshotName defines the source of + the restore operation. + type: string + required: + - mode + - virtualMachineSnapshotName + type: object type: description: |- Type of the operation to execute on a virtual machine: @@ -79,12 +102,14 @@ spec: * `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. + * `Restore`: Restore the virtual machine from a snapshot. enum: - Restart - Start - Stop - Migrate - Evict + - Restore type: string virtualMachineName: description: @@ -104,6 +129,8 @@ spec: rule: "self.type == 'Migrate' ? !has(self.force) || !self.force : true" + - message: Restore requires restore field. + rule: "self.type == 'Restore' ? has(self.restore) : true" status: properties: conditions: @@ -185,6 +212,38 @@ spec: - Failed - Terminating type: string + resources: + description: + Resources contains the list of resources that are affected + by the snapshot operation. + items: + description: + VirtualMachineOperationResource defines the resource + affected by the operation. + properties: + apiVersion: + description: API version of the resource. + type: string + kind: + description: Kind of the resource. + type: string + message: + description: Message about the resource. + type: string + name: + description: Name of the resource. + type: string + status: + description: Status of the resource. + type: string + required: + - apiVersion + - kind + - message + - name + - status + type: object + type: array required: - phase type: object diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 9132a74dfa..d8629328d3 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -214,20 +214,20 @@ The distribution of components across cluster nodes depends on the cluster's con The table lists the management plane components and the node types for their placement. Components are distributed by priority only if the corresponding nodes are available in the cluster configuration. -| Name | Node group for running components | Comment | -| ----------------------------- | ------------------------------------------------------------- | -------------------------------------------- | -| `cdi-operator-*` | system/worker | | -| `cdi-apiserver-*` | master | | -| `cdi-deployment-*` | system/worker | | -| `virt-api-*` | master | | -| `virt-controller-*` | system/worker | | -| `virt-operator-*` | system/worker | | -| `virtualization-api-*` | master | | -| `virtualization-controller-*` | master | | -| `virtualization-audit-*` | system/worker | | -| `dvcr-*` | system/worker | Storage availability on the node is required | -| `virt-handler-*` | All cluster nodes / or nodes specified in the module settings | | -| `vm-route-forge-*` | All cluster nodes | | +| Name | Node group for running components | Comment | +| ----------------------------- | --------------------------------- | -------------------------------------------- | +| `cdi-operator-*` | system/worker | | +| `cdi-apiserver-*` | master | | +| `cdi-deployment-*` | system/worker | | +| `virt-api-*` | master | | +| `virt-controller-*` | system/worker | | +| `virt-operator-*` | system/worker | | +| `virtualization-api-*` | master | | +| `virtualization-controller-*` | master | | +| `virtualization-audit-*` | system/worker | | +| `dvcr-*` | system/worker | Storage must be available on the node | +| `virt-handler-*` | All cluster nodes | | +| `vm-route-forge-*` | All cluster nodes | | ## Module update diff --git a/docs/INSTALL.ru.md b/docs/INSTALL.ru.md index be774dabec..e7b0ad5bc2 100644 --- a/docs/INSTALL.ru.md +++ b/docs/INSTALL.ru.md @@ -218,20 +218,20 @@ weight: 15 В таблице указаны компоненты плоскости управления и типы узлов для их размещения. Компоненты распределяются по приоритету, только если соответствующие узлы доступны в конфигурации кластера. -| Название | Группа узлов для запуска компонент | Комментарий | -| ----------------------------- | ---------------------------------------------------------- | --------------------------------------- | -| `cdi-operator-*` | system/worker | | -| `cdi-apiserver-*` | master | | -| `cdi-deployment-*` | system/worker | | -| `virt-api-*` | master | | -| `virt-controller-*` | system/worker | | -| `virt-operator-*` | system/worker | | -| `virtualization-api-*` | master | | -| `virtualization-controller-*` | master | | -| `virtualization-audit-*` | system/worker | | -| `dvcr-*` | system/worker | Требуется доступность хранилища на узле | -| `virt-handler-*` | Все узлы кластера / либо узлы, указанные настройках модуля | | -| `vm-route-forge-*` | Все узлы кластера | | +| Название | Группа узлов для запуска компонент | Комментарий | +| ----------------------------- | ---------------------------------- | --------------------------------------- | +| `cdi-operator-*` | system/worker | | +| `cdi-apiserver-*` | master | | +| `cdi-deployment-*` | system/worker | | +| `virt-api-*` | master | | +| `virt-controller-*` | system/worker | | +| `virt-operator-*` | system/worker | | +| `virtualization-api-*` | master | | +| `virtualization-controller-*` | master | | +| `virtualization-audit-*` | system/worker | | +| `dvcr-*` | system/worker | На узле должно быть доступно хранилище | +| `virt-handler-*` | Все узлы кластера | | +| `vm-route-forge-*` | Все узлы кластера | | ## Обновление модуля diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md new file mode 100644 index 0000000000..dfdcbbbf01 --- /dev/null +++ b/docs/RELEASE_NOTES.md @@ -0,0 +1,54 @@ +--- +title: "Release Notes" +weight: 70 +--- +# v1.0.0 + +## New features + +* [vm] Added protection to prevent a cloud image (`VirtualImage` \ `ClusterVirtualImage`) from being connected as the first disk. Previously, this caused the VM to fail to start with the "No bootable device" error. +* [vmop] Added `Restore` operation to restore a VM from a previously created snapshot. + +## Fixes + +* [vmsnapshot] When restoring a virtual machine from a snapshot, all annotations and labels that were present on the resources at the time of the snapshot are now restored correctly. +* [module] Fixed an issue with queue blocking when the `settings.modules.publicClusterDomain` parameter was empty in the global ModuleConfig resource. +* [module] Optimized hook performance during module installation. +* [vmclass] Fixed core/coreFraction validation in the `VirtualMachineClass` resource. +* [module] When the SDN module is disabled, the configuration of additional networks in the VM is not available. + + +## Security + +* Fixed CVE-2025-47907 + +# v0.25.0 + +## Important notes before update + +In version v0.25.0, support for the module's operation with CRI containerd V2 has been added. +After upgrading CRI from containerd v1 to containerd v2, it is necessary to recreate the images that were created using virtualization module version v0.24.0 and earlier. + +## New Features + +- [observability] New Prometheus metrics have been added to track the phase of resources such as `VirtualMachineSnapshot`, `VirtualDiskSnapshot`, `VirtualImage`, and `ClusterVirtualImage`. +- [vm] MAC address management for additional network interfaces has been added using the `VirtualMachineMACAddress` and `VirtualMachineMACAddressLease` resources. +- [vm] Added the ability to attach additional network interfaces to a virtual machine for networks provided by the `SDN` module. For this, the `SDN` module must be enabled in the cluster. +- [vmclass] An annotation has been added to set the default `VirtualMachineClass`. You can designate a `VirtualMachineClass` as the default by adding the annotation + `virtualmachineclass.virtualization.deckhouse.io/is-default-class=true`. +This allows creating VMs with an empty `spec.virtualMachineClassName` field, which will be automatically filled with the default class. + + +## Fixes + +- [module] Added validation to ensure that virtual machine subnets do not overlap with system subnets (`podSubnetCIDR` and `serviceSubnetCIDR`). +- [vi] To create a virtual image on a `PersistentVolumeClaim`, the storage must support the `RWX` and `Block` modes; otherwise, a warning will be displayed. +- [vm] Fixed an issue where changing the operating system type caused the machine to enter a reboot loop. +- [vm] Fixed an issue where a virtual machine would hang in the Starting phase when project quotas were insufficient. A quota shortage message will now be displayed in the virtual machine's status. To allow the machine to continue starting, the project quotas need to be increased. + +## Other + +- [vm] Improved the garbage collector (GC) for completed virtual machine operations: + - Runs daily at 00:00. + - Removes successfully completed operations (`Completed` / `Failed`) after their TTL (24 hours) expires. + - Retains only the last 10 completed operations. diff --git a/docs/RELEASE_NOTES.ru.md b/docs/RELEASE_NOTES.ru.md new file mode 100644 index 0000000000..c9fa0d2af9 --- /dev/null +++ b/docs/RELEASE_NOTES.ru.md @@ -0,0 +1,51 @@ +--- +title: "Релизы" +weight: 70 +--- + +# v1.0.0 + +## Новые возможности + +* [vm] Добавлена защита от подключения cloud-образа (`VirtualImage` \ `ClusterVirtualImage`) в качестве первого диска. Ранее это приводило к невозможности запуска ВМ с ошибкой "No bootable device". +* [vmop] Добавлена операция с типом `Restore` для восстановления ВМ из ранее созданного снимка. + +## Исправления + +* [vmsnapshot] Теперь при восстановлении виртуальной машины из снимка корректно восстанавливаются все аннотации и лейблы, которые были у ресурсов в момент снимка. +* [module] Исправлена проблема с блокировкой очереди, когда параметр `settings.modules.publicClusterDomain` был пустым в глобальном ресурсе ModuleConfig. +* [module] Оптимизирована производительность хука во время установки модуля. +* [vmclass] Исправлена валидация core/coreFraction в ресурсе VirtualMachineClass. +* [module] При выключенном модуле SDN конфигурация дополнительных сетей в ВМ недоступна. + +## Безопасность + +* Устранено CVE-2025-47907 + +# v0.25.0 + +## Важное + +В версии v.0.25.0 добавлена поддержка работы модуля с CRI containerd V2. +После обновления CRI с containerd v1 до containerd v2 необходимо пересоздать образы, которые были созданы с использованием версии модуля виртуализации v0.24.0 и ранее. + +## Новые возможности + +* [vm] Добавлена возможность подключения к виртуальной машине дополнительных сетевых интерфейсов к сетям, предоставляемым модулем `SDN`. Для этого модуль `SDN` должен быть включен в кластере. +* [vmmac] Для дополнительных сетевых интерфейсов добавлено управление MAC-адресами с использованием ресурсов `VirtualMachineMACAddress` и `VirtualMachineMACAddressLease`. +* [vmclass] Добавлена аннотация для установки класса виртуальной машины по умолчанию. Чтобы назначить `VirtualMachineClass` по умолчанию, необходимо добавить на него аннотацию `virtualmachineclass.virtualization.deckhouse.io/is-default-class=true`. Это позволяет создавать ВМ с пустым полем `spec.virtualMachineClassName`, автоматически заполняя его классом по умолчанию. +* [observability] Добавлены новые метрики Prometheus для отслеживания фазы ресурсов, таких как `VirtualMachineSnapshot`, `VirtualDiskSnapshot`, `VirtualImage` и `ClusterVirtualImage`. + +## Исправления + +* [vm] Исправили проблему: при изменении типа операционной системы машина уходила в циклическую перезагрузку. +* [vm] Исправили зависание виртуальной машины в фазе Starting при нехватке квот проекта. Сообщение о нехватке квот будет отображаться в статусе виртуальной машины. Чтобы машина продолжила запуск, необходимо будет увеличить квоты проекта. +* [vi] Для создания виртуального образа на `PersistentVolumeClaim` должно быть использовано хранилище в режиме `RWX` и `Block`, в противном случае будет отображено предупреждение об ошибке. +* [module] Добавили валидацию, проверяющую, что подсети виртуальных машин не пересекаются с системными подсетями (`podSubnetCIDR` и `serviceSubnetCIDR`). + +## Прочее + +- [vm] Улучшили сборщик мусора (GC) для отработавших операций виртуальной машины: + - GC запускается каждый день в 00:00; + - GC будет удалять успешно завершённые операции (`Completed` \ `Failed`), если истёк их TTL (24 часа); + - GC подчищает все завершённые операции (`Completed` \ `Failed`), оставляя только 10 последних. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index e530c06502..9237ccf099 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -269,6 +269,10 @@ Project image two storage options are supported: - `ContainerRegistry` - the default type in which the image is stored in `DVCR`. - `PersistentVolumeClaim` - the type that uses `PVC` as the storage for the image. This option is preferred if you are using storage that supports `PVC` fast cloning, which allows you to create disks from images faster. +{{< alert level="warning" >}} +Using an image with the `storage: PersistentVolumeClaim` parameter is only supported for creating disks in the same storage class (StorageClass). +{{< /alert >}} + A full description of the `VirtualImage` resource configuration settings can be found at [link](cr.html#virtualimage). ### Creating image from HTTP server @@ -613,11 +617,9 @@ VolumeBindingMode property: AccessMode: -- `ReadWriteOnce (RWO)` - only one instance of the virtual machine is granted access to the disk. Live migration of virtual machines with such disks is possible only in the DVP commercial editions. +- `ReadWriteOnce (RWO)` - only one instance of the virtual machine is granted access to the disk. - `ReadWriteMany (RWX)` - multiple disk access. Live migration of virtual machines with such disks is possible. -![vd-rwo-vs-rwx](images/vd-rwo-vs-rwx.png) - When creating a disk, the controller will independently determine the most optimal parameters supported by the storage. Attention: It is impossible to create disks from iso-images! @@ -1763,7 +1765,7 @@ How to configure VM AntiAffinity on nodes in the web interface in the [Placement - Select one of the options in the "Select options" section. - Click the "Save" button that appears. -### Static and dynamic block devices +### Attaching block devices (disks and images) Block devices can be divided into two types based on how they are connected: static and dynamic (hotplug). @@ -2689,32 +2691,6 @@ An inconsistent snapshot may not reflect the consistent state of the virtual mac There is a risk of data loss or integrity violation when restoring from such a snapshot. {{< /alert >}} -#### Scenarios for using snapshots - -Snapshots can be used to realize the following scenarios: - -- [Restoring the VM at the time the snapshot was created](#restore-a-virtual-machine) -- [Creating a VM clone / Using the snapshot as a template for VM creation](#creating-a-vm-clone--using-a-vm-snapshot-as-a-template-for-creating-a-vm) - -![vm-restore-clone](./images/vm-restore-clone.png) - -If you plan to use the snapshot as a template, perform the following steps in the guest OS before creating it: - -- Deleting personal data (files, passwords, command history). -- Install critical OS updates. -- Clearing system logs. -- Reset network settings. -- Removing unique identifiers (e.g. via `sysprep` for Windows). -- Optimizing disk space. -- Resetting initialization configurations (`cloud-init clean`). -- Create a snapshot with a clear indication not to save the IP address: `keepIPAddress: Never` - -When creating an image, follow these recommendations: - -- Disconnect all images if they were connected to the virtual machine. -- Do not use a static IP address for VirtualMachineIPAddress. If a static address has been used, change it to automatic. -- Create a snapshot with an explicit indication not to save the IP address: `keepIPAddress: Never`. - #### Creating snapshots Creating a virtual machine snapshot will fail if at least one of the following conditions is met: @@ -2780,107 +2756,66 @@ How to create a VM snapshot in the web interface: - Click the "Create" button. - The snapshot status is displayed at the top left, under the snapshot name. -### Restore from snapshots - -The `VirtualMachineRestore` resource is used to restore a virtual machine from a snapshot. During the restore process, the following objects are automatically created in the cluster: - -- VirtualMachine: The main VM resource with the configuration from the snapshot. -- VirtualDisk: Disks connected to the VM at the moment of snapshot creation. -- VirtualBlockDeviceAttachment: Disk connections to the VM (if they existed in the original configuration). -- VirtualMachineIPAddress: The IP address of the virtual machine (if the `keepIPAddress: Always` parameter was specified at the time of snapshot creation). -- VirtualMachineMACAddress: The MAC address of additional network interfaces when a VM is connected to additional networks. -- Secret: Secrets with cloud-init or sysprep settings (if they were involved in the original VM). +Restore a virtual machine -Important: resources are created only if they were present in the VM configuration at the time the snapshot was created. This ensures that an exact copy of the environment is restored, including all dependencies and settings. +To restore a VM from a snapshot, use the `VirtualMachineOperation` resource with the `restore` type. -#### Restore a virtual machine - -There are two modes used for restoring a virtual machine. They are defined by the restoreMode parameter of the VirtualMachineRestore resource: +Example: ```yaml +apiVersion: virtualisation.deckhouse.io/v1alpha2 +kind: VirtualMachineOperation +metadata: + name: restore-vm spec: - restoreMode: Safe | Forced + type: Restore + virtualMachineName: + restore: + mode: DryRun | Strict | BestEffort + virtualMachineSnapshotName: ``` -`Safe` is used by default. +One of three modes can be used for this operation: -{{< alert level="warning">}} -To restore a virtual machine in `Safe` mode, you must delete its current configuration and all associated disks. This is because the restoration process returns the virtual machine and its disks to the state recorded at the snapshot's creation time. -{{< /alert >}} +- `DryRun`: Idle run of the restore operation, used to check for possible conflicts, which will be displayed in the resource status (`status.resources`). +- `Strict`: Strict recovery mode, used when the VM must be restored exactly as captured in the snapshot; missing external dependencies may cause the VM to remain in `Pending` status after recovery. +- `BestEffort`: Missing external dependencies (`ClusterVirtualImage`, `VirtualImage`) are ignored and removed from the VM configuration. -The `Forced` mode is used to bring an already existing virtual machine to the state at the time of the snapshot. +Restoring a virtual machine from a snapshot is only possible if all the following conditions are met: +- The VM to be restored exists in the cluster (the `VirtualMachine` resource exists and its `.metadata.uid` matches the identifier used when creating the snapshot). +- The disks to be restored (identified by name) are either not attached to other VMs or do not exist in the cluster. +- The IP address to be restored is either not used by any other VM or does not exist in the cluster. +- The MAC addresses to be restored are either not used by any other VMs or do not exist in the cluster. {{< alert level="warning" >}} -`Forced` may disrupt the operation of the existing virtual machine because it will be stopped during restoration, and `VirtualDisks` and `VirtualMachineBlockDeviceAttachments` resources will be deleted for subsequent restoration. +If some resources on which the VM depends (for example, `VirtualMachineClass`, `VirtualImage`, `ClusterVirtualImage`) are missing from the cluster but existed when the snapshot was taken, the VM will remain in the `Pending` state after recovery. +In this case, you must manually edit the VM configuration to update or remove the missing dependencies. {{< /alert >}} -Example manifest for restoring a virtual machine from a snapshot in `Safe` mode: +You can view information about conflicts when restoring a VM from a snapshot in the resource status: -```yaml -d8 k apply -f - < -spec: - restoreMode: Safe - virtualMachineSnapshotName: -EOF +```bash +d8 k get vmop -o json | jq “.status.resources” ``` -#### Creating a VM clone / Using a VM snapshot as a template for creating a VM - -A snapshot of a virtual machine can be used both to create its exact copy (clone) and as a template for deploying new VMs with a similar configuration. +{{< alert level="warning" >}} +It is not recommended to cancel the restore operation (delete the `VirtualMachineOperation` resource in the `InProgress` phase) from a snapshot, which can result in an inconsistent state of the restored virtual machine. +{{< /alert >}} -This requires creating a `VirtualMachineRestore` resource and setting the renaming parameters in the `.spec.nameReplacements` block to avoid name conflicts. +## Data export -The list of resources and their names are available in the VM snapshot status in the `status.resources` block. +DVP allows you to export virtual machine disks and disk images using the `d8` utility (version 1.17 and above). -Example manifest for restoring a VM from a snapshot: +Example: export a disk (run on a cluster node): -```yaml -d8 k apply -f - < -spec: - virtualMachineSnapshotName: - nameReplacements: - - From: - kind: VirtualMachine - name: - to: - - from: - kind: VirtualDisk - name: - to: - - from: - kind: VirtualDisk - name: - to: - - from: - kind: VirtualMachineBlockDeviceAttachment - name: - to: -EOF +```bash +d8 download -n vd/ -o file.img ``` -When restoring a virtual machine from a snapshot, it is important to consider the following conditions: - -1. If the `VirtualMachineIPAddress` resource already exists in the cluster, it must not be assigned to another VM . -2. For static IP addresses (`type: Static`) the value must be exactly the same as what was captured in the snapshot. -3. Automation-related secrets (such as cloud-init or sysprep configuration) must exactly match the configuration being restored. - -Failure to do so will result in a restore error, and the VirtualMachineRestore resource will enter the `Failed` state. This is because the system checks the integrity of the configuration and the uniqueness of the resources to prevent conflicts in the cluster. - -When restoring or cloning a virtual machine, the operation may be successful, but the VM will remain in `Pending` state. -This occurs if the VM depends on resources (such as disk images or virtual machine classes) or their configurations that have been changed or deleted at the time of restoration. - -Check the VM's conditions block using the command: +Example: export a disk snapshot (run on a cluster node): ```bash -d8 k vm get -o json | jq ‘.status.conditions’ +d8 download -n vds/ -o file.img ``` -Check the output for errors related to missing or changed resources. Manually update the VM configuration to remove dependencies that are no longer available in the cluster. +To export resources outside the cluster, you must also use the `--publish` flag. diff --git a/docs/USER_GUIDE.ru.md b/docs/USER_GUIDE.ru.md index bbdfe7e670..263bdf5d20 100644 --- a/docs/USER_GUIDE.ru.md +++ b/docs/USER_GUIDE.ru.md @@ -272,6 +272,10 @@ weight: 50 - `ContainerRegistry` - тип по умолчанию, при котором образ хранится в `DVCR`. - `PersistentVolumeClaim` - тип, при котором в качестве хранилища для образа используется `PVC`. Этот вариант предпочтителен, если используется хранилище с поддержкой быстрого клонирования `PVC`, что позволяет быстрее создавать диски из образов. +{{< alert level="warning" >}} +Использование образа с параметром `storage: PersistentVolumeClaim` поддерживается только для создания дисков в том же классе хранения (StorageClass). +{{< /alert >}} + С полным описанием параметров конфигурации ресурса `VirtualImage` можно ознакомиться [в документации к ресурсу](cr.html#virtualimage). ### Создание образа с HTTP-сервера @@ -621,7 +625,7 @@ EOF Режим доступа AccessMode: -- `ReadWriteOnce (RWO)` - доступ к диску предоставляется только одному экземпляру виртуальной машины. Живая миграция виртуальных машин с такими дисками возможна только для платных редакций DVP. +- `ReadWriteOnce (RWO)` - доступ к диску предоставляется только одному экземпляру виртуальной машины. - `ReadWriteMany (RWX)` - множественный доступ к диску. Живая миграция виртуальных машин с такими дисками возможна. При создании диска контроллер самостоятельно определит наиболее оптимальные параметры поддерживаемые хранилищем. @@ -1392,7 +1396,7 @@ d8 v restart linux-vm | `d8 v stop` | `Stop` | Остановить ВМ | | `d8 v start` | `Start` | Запустить ВМ | | `d8 v restart` | `Restart` | Перезапустить ВМ | -| `d8 v evict` | `Evict` | Мигрировать ВМ на другой узел | +| `d8 v evict` | `Evict` | Мигрировать ВМ на другой узел | Как выполнить операцию в веб-интерфейсе: @@ -1780,7 +1784,7 @@ spec: - Выберите одну из опций в разделе «Выберите опции». - Нажмите на появившуюся кнопку «Сохранить». -### Статические и динамические блочные устройства +### Подключение блочных устройств (диски и образы) Блочные устройства можно разделить на два типа по способу их подключения: статические и динамические (hotplug). @@ -2123,7 +2127,7 @@ EOF ### Живая миграция ВМ -Живая миграция виртуальных машин (ВМ) — это процесс перемещения работающей ВМ с одного физического узла на другой без её отключения. Эта функция играет ключевую роль в управлении виртуализованной инфраструктурой, обеспечивая непрерывность работы приложений во время технического обслуживания, балансировки нагрузки или обновлений. +Живая миграция виртуальных машин — это процесс перемещения работающей ВМ с одного физического узла на другой без её отключения. Эта функция играет ключевую роль в управлении виртуализованной инфраструктурой, обеспечивая непрерывность работы приложений во время технического обслуживания, балансировки нагрузки или обновлений. #### Как работает живая миграция @@ -2723,32 +2727,6 @@ EOF Существует риск потери данных или нарушения их целостности при восстановлении из такого снимка. {{< /alert >}} -#### Сценарии использования снимков - -Снимки виртуальных машин можно использовать для реализации следующих сценариев: - -- [Восстановление ВМ на момент создания снимка](#восстановление-виртуальной-машины) -- [Создание клона ВМ / Использование снимка как шаблона для создания ВМ](#создание-клона-вм--использование-снимка-как-шаблона-для-создания-вм) - -![](./images/vm-restore-clone.ru.png) - -Если снимок планируется использовать как шаблон для создания других машин или клонов, перед его созданием выполните в гостевой ОС: - -- Удаление персональных данных (файлы, пароли, история команд). -- Установку критических обновлений ОС. -- Очистку системных журналов. -- Сброс сетевых настроек. -- Удаление уникальных идентификаторов (например, через `sysprep` для Windows). -- Оптимизацию дискового пространства. -- Сброс конфигураций инициализации (`cloud-init clean`). -- Создавайте снимок с явным указанием не сохранять IP-адрес: `keepIPAddress: Never` - -При создании снимка следуйте следующим рекомендациям: - -- Отключите все образы, если они были подключены к виртуальной машине. -- Не используйте статический IP-адрес в VirtualMachineIPAddress. Если использовался статический адрес, сконвертируйте его в автоматический. -- Создавайте снимок с явным указанием не сохранять IP-адрес: `keepIPAddress: Never`. - #### Создание снимков Создание снимка виртуальной машины будет неудачным, если выполнится хотя бы одно из следующих условий: @@ -2814,107 +2792,65 @@ status: - Нажмите кнопку «Создать». - Статус снимка отображается слева вверху, под именем снимка. -### Восстановление из снимков - -Для восстановления виртуальной машины из снимка используется ресурс `VirtualMachineRestore` . В процессе восстановления в кластере автоматически создаются следующие объекты: - -- VirtualMachine — основной ресурс ВМ с конфигурацией из снимка; -- VirtualDisk — диски, подключенные к ВМ на момент создания снимка; -- VirtualBlockDeviceAttachment — связи дисков с ВМ (если они существовали в исходной конфигурации); -- VirtualMachineIPAddress — IP адрес виртуальной машины(если при создании снимка был указан параметр `keepIPAddress: Always`); -- VirtualMachineMACAddress — MAC адрес дополнительных сетевых интерфейсов, при подключении ВМ к дополнительным сетям; -- Secret — секреты с настройками cloud-init или sysprep (если они были задействованы в оригинальной ВМ). +### Восстановление ВМ -Важно: ресурсы создаются только в том случае , если они присутствовали в конфигурации ВМ на момент создания снимка. Это гарантирует восстановление точной копии среды, включая все зависимости и настройки. +Для восстановления ВМ из снимка используется ресурс `VirtualMachineOperation` с типом `restore`: -#### Восстановление ВМ - -Для восстановления виртуальной машины используются два режима. Они определяются параметром `restoreMode` ресурса `VirtualMachineRestore`: ```yaml +apiVersion: virtualization.deckhouse.io/v1alpha2 +kind: VirtualMachineOperation +metadata: + name: spec: - restoreMode: Safe | Forced + type: Restore + virtualMachineName: <название ВМ, которую требуется восстановить> + restore: + mode: DryRun | Strict | BestEffort + virtualMachineSnapshotName: <название снимка ВМ из которого требуется восстановить> ``` -`Safe` используется по умолчанию. +Для данной операции возможно использовать один из трех режимов: -{{< alert level="warning" >}} -Чтобы восстановить виртуальную машину в режиме `Safe`, необходимо удалить её текущую конфигурацию и все связанные диски. Это связано с тем, что процесс восстановления возвращает виртуальную машину и её диски к состоянию, зафиксированному в момент создания резервного снимка. -{{< /alert >}} +- `DryRun` — холостой запуск операции восстановления, необходим для проверки возможных конфликтов, которые будут отображены в статусе ресурса (`status.resources`). +- `Strict` — режим строгого восстановления, когда требуется восстановление ВМ "как в снимке", отсутствующие внешние зависимости могут привести к тому, что ВМ после восстановления будет в `Pending`. +- `BestEffort` — отсутствующие внешние зависимости (`ClusterVirtualImage`, `VirtualImage`) игнорируются и удаляются из конфигурации ВМ. -`Forced` режим используется для того, чтобы привести уже существующую виртуальную машину к состоянию на момент создания снимка. +Восстановление виртуальной машины из снимка возможно только при выполнении всех следующих условий: +- Восстанавливаемая ВМ присутствует в кластере (ресурс `VirtualMachine` существует, а его `.metadata.uid` совпадает с идентификатором, использованным при создании снимка). +- Восстанавливаемые диски (определяются по имени) либо не подключены к другим ВМ, либо отсутствуют в кластере. +- Восстанавливаемый IP-адрес либо не занят другой ВМ, либо отсутствует в кластере. +- Восстанавливаемые MAC-адреса либо не используются другими ВМ, либо отсутствуют в кластере. {{< alert level="warning" >}} -`Forced` может нарушить работу существующей виртуальной машины, так как во время восстановления она будет остановлена, ресурсы `VirtualDisks` и `VirtualMachineBlockDeviceAttachments` будут удалены для последующего восстановления. +Если некоторые ресурсы, от которых зависит ВМ (например, `VirtualMachineClass`, `VirtualImage`, `ClusterVirtualImage`), отсутствуют в кластере, но существовали на момент создания снимка, ВМ после восстановления останется в состоянии `Pending`. +В этом случае необходимо вручную отредактировать конфигурацию ВМ и обновить или удалить отсутствующие зависимости. {{< /alert >}} -Пример манифеста для восстановления виртуальной машины из снимка в режиме `Safe`: +Информацию о конфликтах при восстановлении ВМ из снимка можно посмотреть в статусе ресурса: -```yaml -d8 k apply -f - < -spec: - restoreMode: Safe - virtualMachineSnapshotName: -EOF +```bash +d8 k get vmop -o json | jq '.status.resources' ``` -#### Создание клона ВМ / Использование снимка как шаблона для создания ВМ - -Снимок виртуальной машины может использоваться как для создания её точной копии (клона), так и в качестве шаблона для развёртывания новых ВМ с аналогичной конфигурацией. +{{< alert level="warning" >}} +Не рекомендуется отменять операцию восстановления (удалять ресурс `VirtualMachineOperation` в фазе `InProgress`) из снимка, так как это может привести к неконсистентному состоянию восстанавливаемой виртуальной машины. +{{< /alert >}} -Для этого требуется создать ресурс `VirtualMachineRestore` и задать параметры переименования в блоке `.spec.nameReplacements`, чтобы избежать конфликтов имён. +## Экспорт данных -Перечень ресурсов и их имена доступны в статусе снимка ВМ в блоке `status.resources`. +DVP позволяет экспортировать диски и снимки дисков виртуальных машин с использованием утилиты `d8` (версия 1.17 и выше). -Пример манифеста для восстановления ВМ из снимка: +Пример: экспорт диска (выполняется на узле кластера): -```yaml -d8 k apply -f - < -spec: - virtualMachineSnapshotName: - nameReplacements: - - from: - kind: VirtualMachine - name: - to: - - from: - kind: VirtualDisk - name: - to: - - from: - kind: VirtualDisk - name: - to: - - from: - kind: VirtualMachineBlockDeviceAttachment - name: - to: -EOF +```bash +d8 download -n vd/ -o file.img ``` -При восстановлении виртуальной машины из снимка важно учитывать следующие условия: - -1. Если ресурс `VirtualMachineIPAddress` уже существует в кластере, он не должен быть назначен другой ВМ . -2. Для статических IP-адресов (`type: Static`) значение должно полностью совпадать с тем, что было зафиксировано в снимке. -3. Секреты, связанные с автоматизацией (например, конфигурация cloud-init или sysprep), должны точно соответствовать восстанавливаемой конфигурации. - -Несоблюдение этих требований приведёт к ошибке восстановления, и ресурс VirtualMachineRestore перейдет в состояние `Failed`. Это связано с тем, что система проверяет целостность конфигурации и уникальность ресурсов для предотвращения конфликтов в кластере. - -При восстановлении или клонировании виртуальной машины операция может быть выполнена успешно, но ВМ останется в статусе `Pending`. -Это происходит, если ВМ зависит от ресурсов (например, образов дисков или классов виртуальных машин) или их конфигураций, которые были изменены или удалены на момент восстановления. - -Проверьте блок условий ВМ с помощью команды: +Пример: экспорт снимка диска (выполняется на узле кластера): ```bash -d8 k vm get -o json | jq '.status.conditions' +d8 download -n vds/ -o file.img ``` -Проверьте вывод на наличие ошибок, связанных с отсутствующими или изменёнными ресурсами. Вручную обновите конфигурацию ВМ, чтобы устранить зависимости, которые больше не доступны в кластере. +Для экспорта ресурсов за пределы кластера необходимо также использовать флаг `--publish`. diff --git a/docs/images/vm-restore-clone.drawio b/docs/images/vm-restore-clone.drawio index b9ad4fa2ab..42ad8e441b 100644 --- a/docs/images/vm-restore-clone.drawio +++ b/docs/images/vm-restore-clone.drawio @@ -1,11 +1,11 @@ - + - + @@ -34,7 +34,7 @@ - + @@ -49,13 +49,13 @@ - + - + @@ -72,10 +72,10 @@ - + - + @@ -123,31 +123,23 @@ - - - - - - - - - - - - - - + + + + + + - + @@ -168,61 +160,11 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + diff --git a/docs/images/vm-restore-clone.png b/docs/images/vm-restore-clone.png index a57ae0f76f..67e237ffd6 100644 Binary files a/docs/images/vm-restore-clone.png and b/docs/images/vm-restore-clone.png differ diff --git a/docs/images/vm-restore-clone.ru.png b/docs/images/vm-restore-clone.ru.png index a57ae0f76f..67e237ffd6 100644 Binary files a/docs/images/vm-restore-clone.ru.png and b/docs/images/vm-restore-clone.ru.png differ diff --git a/docs/internal/cdi_kubevirt_patching.md b/docs/internal/cdi_kubevirt_patching.md index b11c0a3c82..aa54570249 100644 --- a/docs/internal/cdi_kubevirt_patching.md +++ b/docs/internal/cdi_kubevirt_patching.md @@ -62,7 +62,7 @@ cp 000-bundle-images.patch ../../images/cdi-artifact/patches/000-bundle-images.p cp 003-apiserver-node-selector-and-tolerations.patch ../../images/cdi-artifact/patches/003-apiserver-node-selector-and-tolerations.patch ``` -#### Сlean +#### Clean ```bash cd ../../ rm -rf tmp/cdi @@ -94,7 +94,7 @@ git diff --patch "" HEAD > 004-replicas.patch cp 004-replicas.patch ../../images/cdi-artifact/patches/004-replicas.patch git add ../../images/cdi-artifact/patches/004-replicas.patch ``` -#### Сlean +#### Clean ```bash cd ../../ rm -rf tmp/cdi diff --git a/images/cdi-apiserver/mount-points.yaml b/images/cdi-apiserver/mount-points.yaml new file mode 100644 index 0000000000..7f9f0c920b --- /dev/null +++ b/images/cdi-apiserver/mount-points.yaml @@ -0,0 +1,7 @@ +# A list of pre-created mount points for containerd strict mode. + +dirs: + # Create dirs in /run, as /var/run is a symlink to /run. + - /run/certs/cdi-apiserver-signer-bundle + - /run/certs/cdi-apiserver-server-cert + - /kubeconfig.local diff --git a/images/cdi-apiserver/werf.inc.yaml b/images/cdi-apiserver/werf.inc.yaml index fe1a3e7539..a005cef951 100644 --- a/images/cdi-apiserver/werf.inc.yaml +++ b/images/cdi-apiserver/werf.inc.yaml @@ -1,6 +1,8 @@ --- image: {{ .ModuleNamePrefix }}{{ .ImageName }} fromImage: {{ .ModuleNamePrefix }}distroless +git: + {{- include "image mount points" . }} import: - image: {{ .ModuleNamePrefix }}cdi-artifact add: /cdi-binaries diff --git a/images/cdi-cloner/mount-points.yaml b/images/cdi-cloner/mount-points.yaml new file mode 100644 index 0000000000..4b3de32b9b --- /dev/null +++ b/images/cdi-cloner/mount-points.yaml @@ -0,0 +1,7 @@ +# A list of pre-created mount points for containerd strict mode. +# +# See https://github.com/deckhouse/3p-containerized-data-importer/blob/80d763d788e06b3decaf22e4762076cec64582b3/pkg/controller/clone-controller.go#L699 + +dirs: + # Create dirs in /run, as /var/run is a symlink to /run. + - /run/cdi/clone/source diff --git a/images/cdi-cloner/werf.inc.yaml b/images/cdi-cloner/werf.inc.yaml index 3f4976946f..f08ea278ed 100644 --- a/images/cdi-cloner/werf.inc.yaml +++ b/images/cdi-cloner/werf.inc.yaml @@ -1,6 +1,8 @@ --- image: {{ .ModuleNamePrefix }}{{ .ImageName }} fromImage: {{ .ModuleNamePrefix }}distroless +git: + {{- include "image mount points" . }} import: - image: {{ .ModuleNamePrefix }}{{ .ImageName }}-bins add: /relocate diff --git a/images/cdi-controller/mount-points.yaml b/images/cdi-controller/mount-points.yaml new file mode 100644 index 0000000000..d68ce54296 --- /dev/null +++ b/images/cdi-controller/mount-points.yaml @@ -0,0 +1,13 @@ +# A list of pre-created mount points for containerd strict mode. +# +# Some volume mounts are ignored: +# - /tmp - already in the 'distroless' base image. + +dirs: + # Create dirs in /run, as /var/run is a symlink to /run. + - /run/cdi/token/keys + - /run/certs/cdi-uploadserver-signer + - /run/certs/cdi-uploadserver-client-signer + - /run/ca-bundle/cdi-uploadserver-signer-bundle + - /run/ca-bundle/cdi-uploadserver-client-signer-bundle + - /kubeconfig.local diff --git a/images/cdi-controller/werf.inc.yaml b/images/cdi-controller/werf.inc.yaml index a01afca53e..814dde77fa 100644 --- a/images/cdi-controller/werf.inc.yaml +++ b/images/cdi-controller/werf.inc.yaml @@ -1,6 +1,8 @@ --- image: {{ .ModuleNamePrefix }}{{ .ImageName }} fromImage: {{ .ModuleNamePrefix }}distroless +git: + {{- include "image mount points" . }} import: - image: {{ .ModuleNamePrefix }}{{ .ImageName }}-bins add: /relocate diff --git a/images/cdi-importer/mount-points.yaml b/images/cdi-importer/mount-points.yaml new file mode 100644 index 0000000000..f926961f28 --- /dev/null +++ b/images/cdi-importer/mount-points.yaml @@ -0,0 +1,17 @@ +# A list of pre-created mount points for containerd strict mode. +# +# See https://github.com/deckhouse/3p-containerized-data-importer/blob/d5fa5124b8a645521843814fffecdf385b74b379/pkg/controller/import-controller.go#L962 +# +# Some volume mounts are ignored: +# - /extraheaders - Etra headers not implemented in virtualization-controller. +# - /google - No support for GCS data source in VirtualImage. +# - /tmp - already in the 'distroless' base image. + +dirs: + - /certs + - /data + - /opt + - /proxycerts + - /scratch + - /shared + diff --git a/images/cdi-importer/werf.inc.yaml b/images/cdi-importer/werf.inc.yaml index 078f2b2b30..aea532b28c 100644 --- a/images/cdi-importer/werf.inc.yaml +++ b/images/cdi-importer/werf.inc.yaml @@ -1,6 +1,8 @@ --- image: {{ .ModuleNamePrefix }}{{ .ImageName }} fromImage: {{ .ModuleNamePrefix }}distroless +git: + {{- include "image mount points" . }} import: - image: {{ .ModuleNamePrefix }}{{ .ImageName }}-bins add: /relocate diff --git a/images/cdi-operator/mount-points.yaml b/images/cdi-operator/mount-points.yaml new file mode 100644 index 0000000000..624df72961 --- /dev/null +++ b/images/cdi-operator/mount-points.yaml @@ -0,0 +1,4 @@ +# A list of pre-created mount points for containerd strict mode. + +dirs: + - /kubeconfig.local diff --git a/images/cdi-operator/werf.inc.yaml b/images/cdi-operator/werf.inc.yaml index 5b6030cd58..c720c33d50 100644 --- a/images/cdi-operator/werf.inc.yaml +++ b/images/cdi-operator/werf.inc.yaml @@ -1,6 +1,8 @@ --- image: {{ .ModuleNamePrefix }}{{ .ImageName }} fromImage: {{ .ModuleNamePrefix }}distroless +git: + {{- include "image mount points" . }} import: - image: {{ .ModuleNamePrefix }}cdi-artifact add: /cdi-binaries diff --git a/images/distroless/werf.inc.yaml b/images/distroless/werf.inc.yaml index ac7d5ce6ac..da9e12b236 100644 --- a/images/distroless/werf.inc.yaml +++ b/images/distroless/werf.inc.yaml @@ -27,13 +27,18 @@ shell: install: - | mkdir -p /relocate/etc/{pki,ssl} /relocate/usr/{bin,sbin,share,lib,lib64} - + cd /relocate for dir in {bin,sbin,lib,lib64};do ln -s usr/$dir $dir done + # /var/run -> ../run symlink to prevent making /var/run a directory during the build. + # It is needed for better compatibility with containerd default top layer. + mkdir -p run + mkdir -p var + ln -s var/run ../run cd / - + cp -pr /tmp /relocate cp -pr /etc/passwd /etc/group /etc/hostname /etc/hosts /etc/shadow /etc/protocols /etc/services /etc/nsswitch.conf /relocate/etc cp -pr /usr/share/ca-certificates /relocate/usr/share @@ -41,6 +46,7 @@ shell: cp -pr /etc/pki/tls/cert.pem /relocate/etc/ssl cp -pr /etc/pki/tls/certs /relocate/etc/ssl cp -pr /etc/pki/ca-trust/ /relocate/etc/ + # Create 'deckhouse' user to run without root. echo "deckhouse:x:64535:64535:deckhouse:/:/sbin/nologin" >> /relocate/etc/passwd echo "deckhouse:x:64535:" >> /relocate/etc/group echo "deckhouse:!::0:::::" >> /relocate/etc/shadow diff --git a/images/dvcr-importer/mount-points.yaml b/images/dvcr-importer/mount-points.yaml new file mode 100644 index 0000000000..1795c5aae4 --- /dev/null +++ b/images/dvcr-importer/mount-points.yaml @@ -0,0 +1,7 @@ +# A list of pre-created mount points for containerd strict mode. + +dirs: + - /dvcr-src-auth + - /dvcr-auth + - /certs + - /proxycerts diff --git a/images/dvcr-importer/werf.inc.yaml b/images/dvcr-importer/werf.inc.yaml index 331c26202e..6afb9ec24e 100644 --- a/images/dvcr-importer/werf.inc.yaml +++ b/images/dvcr-importer/werf.inc.yaml @@ -1,6 +1,8 @@ --- image: {{ .ModuleNamePrefix }}{{ .ImageName }} fromImage: {{ .ModuleNamePrefix }}distroless +git: + {{- include "image mount points" . }} import: - image: {{ .ModuleNamePrefix }}dvcr-artifact-bins add: /relocate diff --git a/images/dvcr-uploader/mount-points.yaml b/images/dvcr-uploader/mount-points.yaml new file mode 100644 index 0000000000..14d3dcb3d0 --- /dev/null +++ b/images/dvcr-uploader/mount-points.yaml @@ -0,0 +1,4 @@ +# A list of pre-created mount points for containerd strict mode. + +dirs: + - /dvcr-auth diff --git a/images/dvcr-uploader/werf.inc.yaml b/images/dvcr-uploader/werf.inc.yaml index 0eedc4ca25..fcd1090632 100644 --- a/images/dvcr-uploader/werf.inc.yaml +++ b/images/dvcr-uploader/werf.inc.yaml @@ -1,6 +1,8 @@ --- image: {{ .ModuleNamePrefix }}{{ .ImageName }} fromImage: {{ .ModuleNamePrefix }}distroless +git: + {{- include "image mount points" . }} import: - image: {{ .ModuleNamePrefix }}dvcr-artifact-bins add: /relocate diff --git a/images/dvcr/mount-points.yaml b/images/dvcr/mount-points.yaml new file mode 100644 index 0000000000..b844c9dc7c --- /dev/null +++ b/images/dvcr/mount-points.yaml @@ -0,0 +1,7 @@ +# A list of pre-created mount points for containerd strict mode. + +dirs: + - /etc/docker/registry + - /etc/ssl/docker + - /var/lib/registry + - /auth diff --git a/images/dvcr/werf.inc.yaml b/images/dvcr/werf.inc.yaml index 2d6a1672fc..b1a24c19a6 100644 --- a/images/dvcr/werf.inc.yaml +++ b/images/dvcr/werf.inc.yaml @@ -19,6 +19,8 @@ shell: --- image: {{ .ModuleNamePrefix }}{{ .ImageName }} fromImage: {{ .ModuleNamePrefix }}distroless +git: + {{- include "image mount points" . }} import: - image: {{ .ModuleNamePrefix }}{{ .ImageName }}-builder add: /container-registry-binary diff --git a/images/hooks/pkg/hooks/discovery-clusterip-service-for-dvcr/hook.go b/images/hooks/pkg/hooks/discovery-clusterip-service-for-dvcr/hook.go index a22e37293b..5ca150f46b 100644 --- a/images/hooks/pkg/hooks/discovery-clusterip-service-for-dvcr/hook.go +++ b/images/hooks/pkg/hooks/discovery-clusterip-service-for-dvcr/hook.go @@ -39,7 +39,8 @@ const ( var _ = registry.RegisterFunc(configDiscoveryService, handleDiscoveryService) var configDiscoveryService = &pkg.HookConfig{ - OnBeforeHelm: &pkg.OrderedConfig{Order: 5}, + // Note: this hook should run before TLS certificate generator for DVCR. Order should be lower than 5. + OnBeforeHelm: &pkg.OrderedConfig{Order: 3}, Kubernetes: []pkg.KubernetesConfig{ { Name: discoveryService, diff --git a/images/hooks/pkg/hooks/discovery-workload-nodes/hook.go b/images/hooks/pkg/hooks/discovery-workload-nodes/hook.go index f41e45db7b..61cf15004f 100644 --- a/images/hooks/pkg/hooks/discovery-workload-nodes/hook.go +++ b/images/hooks/pkg/hooks/discovery-workload-nodes/hook.go @@ -36,6 +36,11 @@ const ( nodeLabelValue = "true" virtHandlerNodeCountPath = "virtualization.internal.virtHandler.nodeCount" + + kubevirtConfigSnapshot = "kubevirt-config" + + virtConfigPhasePath = "virtualization.internal.virtConfig.phase" + virtConfigParallelMigrationsPerClusterPath = "virtualization.internal.virtConfig.parallelMigrationsPerCluster" ) var _ = registry.RegisterFunc(configDiscoveryService, handleDiscoveryNodes) @@ -55,6 +60,21 @@ var configDiscoveryService = &pkg.HookConfig{ }, ExecuteHookOnSynchronization: ptr.To(false), }, + { + Name: kubevirtConfigSnapshot, + APIVersion: "internal.virtualization.deckhouse.io/v1", + Kind: "InternalVirtualizationKubeVirt", + JqFilter: `{ "phase": .status.phase, "parallelMigrationsPerCluster": .spec.configuration.migrations.parallelMigrationsPerCluster }`, + NamespaceSelector: &pkg.NamespaceSelector{ + NameSelector: &pkg.NameSelector{ + MatchNames: []string{"d8-virtualization"}, + }, + }, + NameSelector: &pkg.NameSelector{ + MatchNames: []string{"config"}, + }, + ExecuteHookOnSynchronization: ptr.To(false), + }, }, Queue: fmt.Sprintf("modules/%s", settings.ModuleName), @@ -63,5 +83,35 @@ var configDiscoveryService = &pkg.HookConfig{ func handleDiscoveryNodes(_ context.Context, input *pkg.HookInput) error { nodeCount := len(input.Snapshots.Get(discoveryNodesSnapshot)) input.Values.Set(virtHandlerNodeCountPath, nodeCount) + + kvCfgState, err := virtConfigStateFromSnapshot(input) + if err != nil { + return err + } + if kvCfgState != nil { + input.Values.Set(virtConfigPhasePath, kvCfgState.Phase) + input.Values.Set(virtConfigParallelMigrationsPerClusterPath, kvCfgState.ParallelMigrationsPerCluster) + } + return nil } + +type virtConfigState struct { + Phase string `json:"phase"` + ParallelMigrationsPerCluster int `json:"parallelMigrationsPerCluster"` +} + +func virtConfigStateFromSnapshot(input *pkg.HookInput) (*virtConfigState, error) { + snap := input.Snapshots.Get(kubevirtConfigSnapshot) + if len(snap) == 0 { + return nil, nil + } + + var kvCfgState virtConfigState + err := snap[0].UnmarshalTo(&kvCfgState) + if err != nil { + return nil, err + } + + return &kvCfgState, nil +} diff --git a/images/kube-api-rewriter/mount-points.yaml b/images/kube-api-rewriter/mount-points.yaml new file mode 100644 index 0000000000..fa5ef6daed --- /dev/null +++ b/images/kube-api-rewriter/mount-points.yaml @@ -0,0 +1,7 @@ +# A list of pre-created mount points for containerd strict mode. + +dirs: + - /etc/virt-operator/certificates + - /etc/virt-api/certificates + # Create dirs in /run, as /var/run is a symlink to /run. + - /run/certs/cdi-apiserver-server-cert diff --git a/images/kube-api-rewriter/werf.inc.yaml b/images/kube-api-rewriter/werf.inc.yaml index b698b1fe31..0b4f559c24 100644 --- a/images/kube-api-rewriter/werf.inc.yaml +++ b/images/kube-api-rewriter/werf.inc.yaml @@ -35,13 +35,22 @@ shell: image: {{ .ModuleNamePrefix }}{{ .ImageName }} fromImage: builder/scratch +git: + {{- include "image mount points" . }} import: - image: {{ .ModuleNamePrefix }}{{ .ImageName }}-builder add: /src/kube-api-rewriter/kube-api-rewriter to: /app/kube-api-rewriter after: install + # Make containerd compatible directories structure. + - image: {{ .ModuleNamePrefix }}{{ .ImageName }}-builder + add: /var + to: /var + includePaths: + - run + after: install imageSpec: config: - user: "65532:65532" + user: "64535:64535" workingDir: "/app" entrypoint: ["/app/kube-api-rewriter"] diff --git a/images/packages/glib2/werf.inc.yaml b/images/packages/glib2/werf.inc.yaml index c5b7c8415b..7d33c9b5cd 100644 --- a/images/packages/glib2/werf.inc.yaml +++ b/images/packages/glib2/werf.inc.yaml @@ -13,8 +13,6 @@ packages: {{- $version := get .PackageVersion .ImageName }} {{- $gitRepoUrl := "GNOME/glib.git" }} -{{/* Temporarily exclude images from build as submodule. TODO remove 'if' when this image is used in import section. */}} -{{- if eq .ModuleNamePrefix "" }} --- image: {{ .ModuleNamePrefix }}{{ .PackagePath }}/{{ .ImageName }} final: false @@ -95,5 +93,3 @@ shell: -Dstrip=true meson compile -C _build DESTDIR=${OUTDIR} meson install -C _build - -{{- end}} diff --git a/images/virt-api/mount-points.yaml b/images/virt-api/mount-points.yaml new file mode 100644 index 0000000000..eb2d220cf6 --- /dev/null +++ b/images/virt-api/mount-points.yaml @@ -0,0 +1,10 @@ +# A list of pre-created mount points for containerd strict mode. +# +# Some volume mounts are ignored: +# - /tmp - already in the 'distroless' base image. + +dirs: + - /etc/virt-api/certificates + - /etc/virt-handler/clientcertificates + - /profile-data + - /kubeconfig.local diff --git a/images/virt-api/werf.inc.yaml b/images/virt-api/werf.inc.yaml index 47432f599f..bb6bd3757a 100644 --- a/images/virt-api/werf.inc.yaml +++ b/images/virt-api/werf.inc.yaml @@ -1,6 +1,8 @@ --- image: {{ .ModuleNamePrefix }}{{ .ImageName }} fromImage: {{ .ModuleNamePrefix }}distroless +git: + {{- include "image mount points" . }} import: - image: {{ .ModuleNamePrefix }}virt-artifact add: /kubevirt-binaries/ diff --git a/images/virt-controller/mount-points.yaml b/images/virt-controller/mount-points.yaml new file mode 100644 index 0000000000..183768973f --- /dev/null +++ b/images/virt-controller/mount-points.yaml @@ -0,0 +1,7 @@ +# A list of pre-created mount points for containerd strict mode. + +dirs: + - /etc/virt-controller/certificates + - /etc/virt-controller/exportca + - /profile-data + - /kubeconfig.local diff --git a/images/virt-controller/werf.inc.yaml b/images/virt-controller/werf.inc.yaml index 3ad212b26c..ede2c542d7 100644 --- a/images/virt-controller/werf.inc.yaml +++ b/images/virt-controller/werf.inc.yaml @@ -1,6 +1,8 @@ --- image: {{ .ModuleNamePrefix }}{{ .ImageName }} fromImage: {{ .ModuleNamePrefix }}distroless +git: + {{- include "image mount points" . }} import: - image: {{ .ModuleNamePrefix }}virt-artifact add: /kubevirt-binaries/ diff --git a/images/virt-handler/mount-points.yaml b/images/virt-handler/mount-points.yaml new file mode 100644 index 0000000000..680aedd7f9 --- /dev/null +++ b/images/virt-handler/mount-points.yaml @@ -0,0 +1,21 @@ +# A list of pre-created mount points for containerd strict mode. +# +# Some volume mounts are ignored: +# - /tmp - already in the 'distroless' base image. + +dirs: + - /etc/virt-handler/clientcertificates + - /etc/virt-handler/servercertificates + - /kubeconfig.local + - /profile-data + - /etc/podinfo + - /pods + - /var/lib/kubevirt + - /var/lib/kubelet/device-plugins + - /var/lib/kubelet/pods + - /var/lib/kubevirt-node-labeller + # Create dirs in /run, as /var/run is a symlink to /run. + - /run/kubevirt + - /run/kubevirt-libvirt-runtimes + - /run/kubevirt-private + diff --git a/images/virt-handler/werf.inc.yaml b/images/virt-handler/werf.inc.yaml index cd44dff356..fb1b970762 100644 --- a/images/virt-handler/werf.inc.yaml +++ b/images/virt-handler/werf.inc.yaml @@ -1,6 +1,8 @@ --- image: {{ .ModuleNamePrefix }}{{ .ImageName }} fromImage: {{ .ModuleNamePrefix }}distroless +git: + {{- include "image mount points" . }} import: - image: {{ .ModuleNamePrefix }}{{ .ImageName }}-bins add: /relocate diff --git a/images/virt-launcher/mount-points.yaml b/images/virt-launcher/mount-points.yaml new file mode 100644 index 0000000000..643cfdb1fe --- /dev/null +++ b/images/virt-launcher/mount-points.yaml @@ -0,0 +1,48 @@ +# A list of pre-created mount points for containerd strict mode. +# +# See https://github.com/deckhouse/3p-kubevirt/blob/8aed630/pkg/virt-controller/services/rendervolumes.go +# +# Some volume mounts are ignored: +# - /tmp - already in the 'distroless' base image. +# - /var/run - already in the 'distroless' base image. +# No need to pre-create a plethora of /var/run descendants, +# as deckhouse/3p-kubevirt is patched to mount /var/run as emptyDir: +# - /var/run/libvirt +# - /var/run/kubevirt-ephemeral-disks +# - /var/run/kubevirt-hooks +# - /var/run/kubevirt-private +# - /var/run/kubevirt-private/sysprep/ +# - /var/run/kubevirt-private/secret/cloudinit/userdata +# - /var/run/kubevirt-private/secret/cloudinit/userData +# - /var/run/kubevirt-private/secret/cloudinit/networkdata +# - /var/run/kubevirt-private/secret/cloudinit/networkData +# - /var/run/kubevirt-private/config-map +# - /var/run/kubevirt-private/downwardapi +# - /var/run/kubevirt-private/downwardapi-disks +# - /var/run/kubevirt-private/vmi-disks +# - /var/run/kubevirt-private/libvirt +# - /var/run/kubevirt-private/libvirt/qemu +# - /var/run/kubevirt-private/libvirt/qemu/nvram +# - /var/run/kubevirt-private/libvirt/qemu/swtpm +# - /var/run/kubevirt-private/var/lib/swtpm-localca +# - There are more dirs in /var/run/kubevirt-private/ +# - /var/run/kubevirt +# - /var/run/kubevirt/container-disks +# - /var/run/kubevirt/sockets +# - /var/run/kubevirt/hotplug-disks +# - /var/run/kubevirt/virtiofs-containers +# /var/log is mounted as emptyDir too: +# - /var/log/libvirt + +dirs: + - /etc/libvirt + - /etc/podinfo + - /var/cache/libvirt + - /var/lib/libvirt + - /var/lib/libvirt/swtpm + - /var/lib/libvirt/qemu/nvram + - /var/lib/kubevirt-node-labeller + - /var/lib/swtpm-localca + - /var/log + - /path # For hot-plugged disks, used in "hp Pods". + - /init/usr/bin # For attaching images as "container disks". diff --git a/images/virt-launcher/werf.inc.yaml b/images/virt-launcher/werf.inc.yaml index ebb162d3ab..b6a6bea65c 100644 --- a/images/virt-launcher/werf.inc.yaml +++ b/images/virt-launcher/werf.inc.yaml @@ -2,6 +2,8 @@ image: {{ .ModuleNamePrefix }}{{ .ImageName }} final: true fromImage: {{ .ModuleNamePrefix }}distroless +git: + {{- include "image mount points" . }} import: - image: {{ .ModuleNamePrefix }}{{ .ImageName }}-binaries add: /relocate diff --git a/images/virt-operator/mount-points.yaml b/images/virt-operator/mount-points.yaml new file mode 100644 index 0000000000..3c674da58c --- /dev/null +++ b/images/virt-operator/mount-points.yaml @@ -0,0 +1,6 @@ +# A list of pre-created mount points for containerd strict mode. + +dirs: + - /etc/virt-operator/certificates + - /profile-data + - /kubeconfig.local diff --git a/images/virt-operator/werf.inc.yaml b/images/virt-operator/werf.inc.yaml index 022ad77e2a..dda81277a1 100644 --- a/images/virt-operator/werf.inc.yaml +++ b/images/virt-operator/werf.inc.yaml @@ -1,6 +1,8 @@ --- image: {{ .ModuleNamePrefix }}{{ .ImageName }} fromImage: {{ .ModuleNamePrefix }}distroless +git: + {{- include "image mount points" . }} import: - image: {{ .ModuleNamePrefix }}virt-artifact add: /kubevirt-binaries/ diff --git a/images/virtualization-api/mount-points.yaml b/images/virtualization-api/mount-points.yaml new file mode 100644 index 0000000000..cab24f0ee2 --- /dev/null +++ b/images/virtualization-api/mount-points.yaml @@ -0,0 +1,6 @@ +# A list of pre-created mount points for containerd strict mode. + +dirs: + - /etc/virtualization-api/certificates + - /etc/virtualization-api-proxy/certificates + - /etc/virt-api/certificates diff --git a/images/virtualization-api/werf.inc.yaml b/images/virtualization-api/werf.inc.yaml index 108b3a98e4..a9d75809c3 100644 --- a/images/virtualization-api/werf.inc.yaml +++ b/images/virtualization-api/werf.inc.yaml @@ -1,6 +1,8 @@ --- image: {{ .ModuleNamePrefix }}{{ .ImageName }} fromImage: {{ .ModuleNamePrefix }}distroless +git: + {{- include "image mount points" . }} import: - image: {{ .ModuleNamePrefix }}virtualization-artifact add: /out/virtualization-api diff --git a/images/virtualization-artifact/pkg/apiserver/api/generated/openapi/zz_generated.openapi.go b/images/virtualization-artifact/pkg/apiserver/api/generated/openapi/zz_generated.openapi.go index f8d36a0ad6..6fd912d181 100644 --- a/images/virtualization-artifact/pkg/apiserver/api/generated/openapi/zz_generated.openapi.go +++ b/images/virtualization-artifact/pkg/apiserver/api/generated/openapi/zz_generated.openapi.go @@ -137,6 +137,8 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/deckhouse/virtualization/api/core/v1alpha2.VirtualMachineMigrationState": schema_virtualization_api_core_v1alpha2_VirtualMachineMigrationState(ref), "github.com/deckhouse/virtualization/api/core/v1alpha2.VirtualMachineOperation": schema_virtualization_api_core_v1alpha2_VirtualMachineOperation(ref), "github.com/deckhouse/virtualization/api/core/v1alpha2.VirtualMachineOperationList": schema_virtualization_api_core_v1alpha2_VirtualMachineOperationList(ref), + "github.com/deckhouse/virtualization/api/core/v1alpha2.VirtualMachineOperationResource": schema_virtualization_api_core_v1alpha2_VirtualMachineOperationResource(ref), + "github.com/deckhouse/virtualization/api/core/v1alpha2.VirtualMachineOperationRestoreSpec": schema_virtualization_api_core_v1alpha2_VirtualMachineOperationRestoreSpec(ref), "github.com/deckhouse/virtualization/api/core/v1alpha2.VirtualMachineOperationSpec": schema_virtualization_api_core_v1alpha2_VirtualMachineOperationSpec(ref), "github.com/deckhouse/virtualization/api/core/v1alpha2.VirtualMachineOperationStatus": schema_virtualization_api_core_v1alpha2_VirtualMachineOperationStatus(ref), "github.com/deckhouse/virtualization/api/core/v1alpha2.VirtualMachinePhaseTransitionTimestamp": schema_virtualization_api_core_v1alpha2_VirtualMachinePhaseTransitionTimestamp(ref), @@ -4890,6 +4892,89 @@ func schema_virtualization_api_core_v1alpha2_VirtualMachineOperationList(ref com } } +func schema_virtualization_api_core_v1alpha2_VirtualMachineOperationResource(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "VirtualMachineOperationResource defines the resource affected by the operation.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "API version of the resource.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "name": { + SchemaProps: spec.SchemaProps{ + Description: "Name of the resource.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind of the resource.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "status": { + SchemaProps: spec.SchemaProps{ + Description: "Status of the resource.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "message": { + SchemaProps: spec.SchemaProps{ + Description: "Message about the resource.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"apiVersion", "name", "kind", "status", "message"}, + }, + }, + } +} + +func schema_virtualization_api_core_v1alpha2_VirtualMachineOperationRestoreSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "VirtualMachineOperationRestoreSpec defines the restore operation.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "mode": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "virtualMachineSnapshotName": { + SchemaProps: spec.SchemaProps{ + Description: "VirtualMachineSnapshotName defines the source of the restore operation.", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"mode", "virtualMachineSnapshotName"}, + }, + }, + } +} + func schema_virtualization_api_core_v1alpha2_VirtualMachineOperationSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -4918,10 +5003,18 @@ func schema_virtualization_api_core_v1alpha2_VirtualMachineOperationSpec(ref com Format: "", }, }, + "restore": { + SchemaProps: spec.SchemaProps{ + Description: "Restore defines the restore operation.", + Ref: ref("github.com/deckhouse/virtualization/api/core/v1alpha2.VirtualMachineOperationRestoreSpec"), + }, + }, }, Required: []string{"type", "virtualMachineName"}, }, }, + Dependencies: []string{ + "github.com/deckhouse/virtualization/api/core/v1alpha2.VirtualMachineOperationRestoreSpec"}, } } @@ -4959,12 +5052,26 @@ func schema_virtualization_api_core_v1alpha2_VirtualMachineOperationStatus(ref c Format: "int64", }, }, + "resources": { + SchemaProps: spec.SchemaProps{ + Description: "Resources contains the list of resources that are affected by the snapshot operation.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/deckhouse/virtualization/api/core/v1alpha2.VirtualMachineOperationResource"), + }, + }, + }, + }, + }, }, Required: []string{"phase"}, }, }, Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.Condition"}, + "github.com/deckhouse/virtualization/api/core/v1alpha2.VirtualMachineOperationResource", "k8s.io/apimachinery/pkg/apis/meta/v1.Condition"}, } } diff --git a/images/virtualization-artifact/pkg/audit/events/vm/vm_control.go b/images/virtualization-artifact/pkg/audit/events/vm/vm_control.go index a58c3fe818..4f5ef8abe6 100644 --- a/images/virtualization-artifact/pkg/audit/events/vm/vm_control.go +++ b/images/virtualization-artifact/pkg/audit/events/vm/vm_control.go @@ -25,6 +25,7 @@ import ( "github.com/deckhouse/deckhouse/pkg/log" "github.com/deckhouse/virtualization-controller/pkg/audit/events" "github.com/deckhouse/virtualization-controller/pkg/audit/util" + vmutil "github.com/deckhouse/virtualization-controller/pkg/common/vm" ) func NewVMControl(options events.EventLoggerOptions) *VMControl { @@ -73,7 +74,7 @@ func (m *VMControl) Fill() error { var terminatedStatuses string for _, status := range pod.Status.ContainerStatuses { - if status.Name == "compute" && status.State.Terminated != nil { + if vmutil.IsComputeContainer(status.Name) && status.State.Terminated != nil { terminatedStatuses = status.State.Terminated.Message } } diff --git a/images/virtualization-artifact/pkg/audit/events/vm/vm_control_test.go b/images/virtualization-artifact/pkg/audit/events/vm/vm_control_test.go index 87e81d9744..8c41e18abf 100644 --- a/images/virtualization-artifact/pkg/audit/events/vm/vm_control_test.go +++ b/images/virtualization-artifact/pkg/audit/events/vm/vm_control_test.go @@ -86,7 +86,7 @@ var _ = Describe("VMOP Events", func() { Spec: corev1.PodSpec{ Containers: []corev1.Container{ { - Name: "compute", + Name: "d8v-compute", Image: "test-image", }, }, @@ -95,7 +95,7 @@ var _ = Describe("VMOP Events", func() { Status: corev1.PodStatus{ ContainerStatuses: []corev1.ContainerStatus{ { - Name: "compute", + Name: "d8v-compute", State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{Message: "guest-shutdown"}}, }, }, diff --git a/images/virtualization-artifact/pkg/common/annotations/annotations.go b/images/virtualization-artifact/pkg/common/annotations/annotations.go index d734de75cc..5c6a00b78d 100644 --- a/images/virtualization-artifact/pkg/common/annotations/annotations.go +++ b/images/virtualization-artifact/pkg/common/annotations/annotations.go @@ -47,8 +47,9 @@ const ( // AnnPodRetainAfterCompletion is PVC annotation for retaining transfer pods after completion AnnPodRetainAfterCompletion = AnnAPIGroup + "/storage.pod.retainAfterCompletion" - // AnnUploadURL provides a const for CVMI/VMI/VMD uploadURL annotation. - AnnUploadURL = AnnAPIGroup + "/upload.url" + // AnnUploadURLDeprecated provides a const for CVMI/VMI/VMD uploadURL annotation. + // TODO remove annotation and its usages after version 1.0 becomes Stable. + AnnUploadURLDeprecated = AnnAPIGroup + "/upload.url" // AnnTolerationsHash provides a const for annotation with hash of applied tolerations. AnnTolerationsHash = AnnAPIGroup + "/tolerations-hash" @@ -97,6 +98,16 @@ const ( // AnnVMOPEvacuation is an annotation on vmop that represents a vmop created by evacuation controller AnnVMOPEvacuation = AnnAPIGroupV + "/evacuation" + // AnnVMOPRestore is an annotation on a resource that indicates it was created by the vmop snapshot controller; the value is the UID of the `VirtualMachineOperation` resource. + AnnVMOPRestore = AnnAPIGroupV + "/vmoprestore" + // AnnVMRestoreDeleted is an annotation on a resource that indicates it was deleted by the vmop snapshot controller; the value is the UID of the `VirtualMachineOperation` resource. + AnnVMOPRestoreDeleted = AnnAPIGroupV + "/vmoprestoredeleted" + + // AnnUploadURL is an annotation on Ingress with full URL to upload image from outside the cluster. + AnnUploadURL = AnnAPIGroupV + "/upload.url" + // AnnUploadPath is an annotation on Ingress with the URL path to upload image. + AnnUploadPath = AnnAPIGroupV + "/upload.path" + // LabelsPrefix is a prefix for virtualization-controller labels. LabelsPrefix = "virtualization.deckhouse.io" diff --git a/images/virtualization-artifact/pkg/common/consts.go b/images/virtualization-artifact/pkg/common/consts.go index fb16ef545b..1a78a19bf3 100644 --- a/images/virtualization-artifact/pkg/common/consts.go +++ b/images/virtualization-artifact/pkg/common/consts.go @@ -23,11 +23,11 @@ const ( OwnerUID = "OWNER_UID" // BounderContainerName provides a constant to use as a name for bounder Container - BounderContainerName = "bounder" + BounderContainerName = "d8v-dvcr-bounder" // ImporterContainerName provides a constant to use as a name for importer Container - ImporterContainerName = "importer" + ImporterContainerName = "d8v-dvcr-importer" // UploaderContainerName provides a constant to use as a name for uploader Container - UploaderContainerName = "uploader" + UploaderContainerName = "d8v-dvcr-uploader" // UploaderPortName provides a constant to use as a port name for uploader Service UploaderPortName = "uploader" // UploaderPort provides a constant to use as a port for uploader Service @@ -79,10 +79,6 @@ const ( ImportProxyNoProxy = "no_proxy" // ImporterProxyCertDirVar provides a constant to capture our env variable "IMPORTER_PROXY_CERT_DIR" ImporterProxyCertDirVar = "IMPORTER_PROXY_CERT_DIR" - // ImporterExtraHeader provides a constant to include extra HTTP headers, as the prefix to a format string - ImporterExtraHeader = "IMPORTER_EXTRA_HEADER_" - // ImporterSecretExtraHeadersDir is where the secrets containing extra HTTP headers will be mounted - ImporterSecretExtraHeadersDir = "/extraheaders" // ImporterDestinationAuthConfigDir is a mount directory for auth Secret. ImporterDestinationAuthConfigDir = "/dvcr-auth" @@ -101,10 +97,8 @@ const ( UploaderDestinationEndpoint = "UPLOADER_DESTINATION_ENDPOINT" UploaderDestinationAuthConfigVar = "UPLOADER_DESTINATION_AUTH_CONFIG" - UploaderExtraHeader = "UPLOADER_EXTRA_HEADER_" UploaderDestinationAuthConfigDir = "/dvcr-auth" UploaderDestinationAuthConfigFile = "/dvcr-auth/.dockerconfigjson" - UploaderSecretExtraHeadersDir = "/extraheaders" DockerRegistrySchemePrefix = "docker://" diff --git a/images/virtualization-artifact/pkg/common/steptaker/runner.go b/images/virtualization-artifact/pkg/common/steptaker/runner.go index 990be89b77..fa015bbe43 100644 --- a/images/virtualization-artifact/pkg/common/steptaker/runner.go +++ b/images/virtualization-artifact/pkg/common/steptaker/runner.go @@ -26,7 +26,7 @@ import ( ) type Resource interface { - *virtv2.VirtualDisk | *virtv2.VirtualImage | *virtv2.VirtualMachineIPAddress | *virtv2.VirtualMachineMACAddress + *virtv2.VirtualDisk | *virtv2.VirtualImage | *virtv2.VirtualMachineIPAddress | *virtv2.VirtualMachineMACAddress | *virtv2.VirtualMachineOperation } type StepTaker[R Resource] interface { diff --git a/images/virtualization-artifact/pkg/common/testutil/testutil.go b/images/virtualization-artifact/pkg/common/testutil/testutil.go index 1785bf86e4..3d29063a63 100644 --- a/images/virtualization-artifact/pkg/common/testutil/testutil.go +++ b/images/virtualization-artifact/pkg/common/testutil/testutil.go @@ -28,6 +28,7 @@ import ( cdiv1 "kubevirt.io/containerized-data-importer-api/pkg/apis/core/v1beta1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" "github.com/deckhouse/deckhouse/pkg/log" "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" @@ -62,6 +63,34 @@ func NewFakeClientWithObjects(objs ...client.Object) (client.WithWatch, error) { return b.Build(), nil } +func NewFakeClientWithInterceptorWithObjects(interceptor interceptor.Funcs, objs ...client.Object) (client.WithWatch, error) { + scheme := apiruntime.NewScheme() + for _, f := range []func(*apiruntime.Scheme) error{ + virtv2.AddToScheme, + virtv1.AddToScheme, + cdiv1.AddToScheme, + clientgoscheme.AddToScheme, + } { + err := f(scheme) + if err != nil { + return nil, err + } + } + var newObjs []client.Object + for _, obj := range objs { + if reflect.ValueOf(obj).IsNil() { + continue + } + newObjs = append(newObjs, obj) + } + b := fake.NewClientBuilder().WithScheme(scheme).WithObjects(newObjs...).WithStatusSubresource(newObjs...).WithInterceptorFuncs(interceptor) + for _, fn := range indexer.IndexGetters { + b.WithIndex(fn()) + } + + return b.Build(), nil +} + func NewNoOpLogger() *log.Logger { return log.NewNop() } diff --git a/images/virtualization-artifact/pkg/common/vm/vm.go b/images/virtualization-artifact/pkg/common/vm/vm.go index 4eeca7b6ce..d246617be7 100644 --- a/images/virtualization-artifact/pkg/common/vm/vm.go +++ b/images/virtualization-artifact/pkg/common/vm/vm.go @@ -17,9 +17,15 @@ limitations under the License. package vm import ( + "strings" + virtv2 "github.com/deckhouse/virtualization/api/core/v1alpha2" ) +// VMContainerNameSuffix - a name suffix for container with virt-launcher, libvirt and qemu processes. +// Container name is "d8v-compute", but previous versions may have "compute" container. +const VMContainerNameSuffix = "compute" + // CalculateCoresAndSockets calculates the number of sockets and cores per socket needed to achieve // the desired total number of CPU cores. // The function tries to minimize the number of sockets while ensuring the desired core count. @@ -59,3 +65,7 @@ func ApprovalMode(vm *virtv2.VirtualMachine) virtv2.RestartApprovalMode { } return vm.Spec.Disruptions.RestartApprovalMode } + +func IsComputeContainer(name string) bool { + return strings.HasSuffix(name, VMContainerNameSuffix) +} diff --git a/images/virtualization-artifact/pkg/config/load_dvcr_settings.go b/images/virtualization-artifact/pkg/config/load_dvcr_settings.go index 89f6996e4e..b9b674f668 100644 --- a/images/virtualization-artifact/pkg/config/load_dvcr_settings.go +++ b/images/virtualization-artifact/pkg/config/load_dvcr_settings.go @@ -66,12 +66,6 @@ func LoadDVCRSettingsFromEnvs(controllerNamespace string) (*dvcr.Settings, error if dvcrSettings.RegistryURL == "" { return nil, fmt.Errorf("environment variable %q undefined, specify DVCR settings", DVCRRegistryURLVar) } - if dvcrSettings.UploaderIngressSettings.Host == "" { - return nil, fmt.Errorf("environment variable %q undefined, specify DVCR settings", UploaderIngressHostVar) - } - if dvcrSettings.UploaderIngressSettings.Class == "" { - return nil, fmt.Errorf("environment variable %q undefined, specify DVCR settings", UploaderIngressClassVar) - } if dvcrSettings.AuthSecret != "" && dvcrSettings.AuthSecretNamespace == "" { dvcrSettings.AuthSecretNamespace = controllerNamespace diff --git a/images/virtualization-artifact/pkg/controller/importer/importer_pod.go b/images/virtualization-artifact/pkg/controller/importer/importer_pod.go index 5aae08181d..b3aa834760 100644 --- a/images/virtualization-artifact/pkg/controller/importer/importer_pod.go +++ b/images/virtualization-artifact/pkg/controller/importer/importer_pod.go @@ -18,8 +18,6 @@ package importer import ( "context" - "fmt" - "path" "strconv" corev1 "k8s.io/api/core/v1" @@ -52,9 +50,6 @@ const ( // ProxyCertVolName is the name of the volumecontaining certs proxyCertVolName = "cdi-proxy-cert-vol" - // secretExtraHeadersVolumeName is the format string that specifies where extra HTTP header secrets will be mounted - secretExtraHeadersVolumeName = "import-extra-headers-vol-%d" - // destinationAuthVol is the name of the volume containing DVCR docker auth config. destinationAuthVol = "dvcr-secret-vol" @@ -389,21 +384,6 @@ func (imp *Importer) addVolumes(pod *corev1.Pod, container *corev1.Container) { }, ) } - - // Mount extra headers Secrets. - for index, header := range imp.EnvSettings.SecretExtraHeaders { - volName := fmt.Sprintf(secretExtraHeadersVolumeName, index) - mountPath := path.Join(common.ImporterSecretExtraHeadersDir, fmt.Sprint(index)) - envName := fmt.Sprintf("%s%d", common.ImporterExtraHeader, index) - podutil.AddVolume(pod, container, - podutil.CreateSecretVolume(volName, header), - podutil.CreateVolumeMount(volName, mountPath), - corev1.EnvVar{ - Name: envName, - Value: header, - }, - ) - } } type PodNamer interface { diff --git a/images/virtualization-artifact/pkg/controller/importer/settings.go b/images/virtualization-artifact/pkg/controller/importer/settings.go index 25e93e3917..91888b14f1 100644 --- a/images/virtualization-artifact/pkg/controller/importer/settings.go +++ b/images/virtualization-artifact/pkg/controller/importer/settings.go @@ -61,7 +61,6 @@ type Settings struct { NoProxy string CertConfigMapProxy string ExtraHeaders []string - SecretExtraHeaders []string DestinationEndpoint string DestinationInsecureTLS string DestinationAuthSecret string diff --git a/images/virtualization-artifact/pkg/controller/powerstate/shutdown_reason.go b/images/virtualization-artifact/pkg/controller/powerstate/shutdown_reason.go index a06fec4d32..cbc49ae3bc 100644 --- a/images/virtualization-artifact/pkg/controller/powerstate/shutdown_reason.go +++ b/images/virtualization-artifact/pkg/controller/powerstate/shutdown_reason.go @@ -22,14 +22,13 @@ import ( corev1 "k8s.io/api/core/v1" kvv1 "kubevirt.io/api/core/v1" + + vmutil "github.com/deckhouse/virtualization-controller/pkg/common/vm" ) type GuestSignalReason string const ( - // DefaultVMContainerName - a container name with virt-launcher, libvirt and qemu processes. - DefaultVMContainerName = "compute" - // GuestResetReason - a reboot command was issued from inside the VM. GuestResetReason GuestSignalReason = "guest-reset" @@ -65,10 +64,9 @@ func ShutdownReason(kvvmi *kvv1.VirtualMachineInstance, kvPods *corev1.PodList) return ShutdownInfo{} } - // Extract termination mesage from the "compute" container. + // Extract termination message from the container with VM. for _, contStatus := range recentPod.Status.ContainerStatuses { - // "compute" is a default container name for VM Pod. - if contStatus.Name != DefaultVMContainerName { + if !vmutil.IsComputeContainer(contStatus.Name) { continue } msg := "" diff --git a/images/virtualization-artifact/pkg/controller/service/restorer/common/common.go b/images/virtualization-artifact/pkg/controller/service/restorer/common/common.go new file mode 100644 index 0000000000..9dcb407dff --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/restorer/common/common.go @@ -0,0 +1,55 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +import ( + "errors" + + virtv2 "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +var ( + ErrAlreadyInUse = errors.New("already in use") + ErrRestoring = errors.New("will be restored") + ErrUpdating = errors.New("will be updated") + ErrWaitingForDeletion = errors.New("waiting for deletion to complete") + ErrVMNotInMaintenance = errors.New("the virtual machine is not in maintenance mode") + ErrVMMaintenanceCondNotFound = errors.New("the virtual machine maintenance condition is not found") + ErrVirtualImageNotFound = errors.New("the virtual image is not found") + ErrVirtualDiskSnapshotNotFound = errors.New("not found") + ErrClusterVirtualImageNotFound = errors.New("the virtual image is not found") + ErrSecretHasDifferentData = errors.New("the secret has different data") +) + +// OverrideName overrides the name of the resource with the given rules +func OverrideName(kind, name string, rules []virtv2.NameReplacement) string { + if name == "" { + return "" + } + + for _, rule := range rules { + if rule.From.Kind != "" && rule.From.Kind != kind { + continue + } + + if rule.From.Name == name { + return rule.To + } + } + + return name +} diff --git a/images/virtualization-artifact/pkg/controller/service/restorer/interfaces.go b/images/virtualization-artifact/pkg/controller/service/restorer/interfaces.go new file mode 100644 index 0000000000..3b2026ac86 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/restorer/interfaces.go @@ -0,0 +1,32 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package restorer + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +//go:generate go tool moq -rm -out mock.go . ObjectHandler +type ObjectHandler interface { + Object() client.Object + ValidateRestore(ctx context.Context) error + ProcessRestore(ctx context.Context) error + ValidateClone(ctx context.Context) error + ProcessClone(ctx context.Context) error +} diff --git a/images/virtualization-artifact/pkg/controller/service/restorer/restorers/provisioner_restorer.go b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/provisioner_restorer.go new file mode 100644 index 0000000000..b69844909a --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/provisioner_restorer.go @@ -0,0 +1,131 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package restorer + +import ( + "bytes" + "context" + "fmt" + "maps" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/controller/service/restorer/common" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type ProvisionerHandler struct { + secret *corev1.Secret + client client.Client + restoreUID string +} + +func NewProvisionerHandler(client client.Client, secretTmpl corev1.Secret, vmRestoreUID string) *ProvisionerHandler { + if secretTmpl.Annotations == nil { + secretTmpl.Annotations = make(map[string]string) + } + secretTmpl.Annotations[annotations.AnnVMOPRestore] = vmRestoreUID + return &ProvisionerHandler{ + secret: &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: secretTmpl.Kind, + APIVersion: secretTmpl.APIVersion, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: secretTmpl.Name, + Namespace: secretTmpl.Namespace, + Annotations: secretTmpl.Annotations, + Labels: secretTmpl.Labels, + }, + Immutable: secretTmpl.Immutable, + Data: secretTmpl.Data, + StringData: secretTmpl.StringData, + Type: secretTmpl.Type, + }, + client: client, + restoreUID: vmRestoreUID, + } +} + +func (v *ProvisionerHandler) Override(rules []v1alpha2.NameReplacement) { + v.secret.Name = common.OverrideName(v.secret.Kind, v.secret.Name, rules) +} + +func (v *ProvisionerHandler) ValidateRestore(ctx context.Context) error { + secretKey := types.NamespacedName{Namespace: v.secret.Namespace, Name: v.secret.Name} + existed, err := object.FetchObject(ctx, secretKey, v.client, &corev1.Secret{}) + if err != nil { + return err + } + + if existed == nil { + return nil + } + + if value, ok := existed.Annotations[annotations.AnnVMOPRestore]; ok && value == v.restoreUID { + return nil + } + + if !maps.EqualFunc(existed.Data, v.secret.Data, bytes.Equal) { + return fmt.Errorf("the provisioner secret %q %w", secretKey.Name, common.ErrSecretHasDifferentData) + } + + return nil +} + +func (v *ProvisionerHandler) ValidateClone(ctx context.Context) error { + return nil +} + +func (v *ProvisionerHandler) ProcessRestore(ctx context.Context) error { + err := v.ValidateRestore(ctx) + if err != nil { + return err + } + + secretKey := types.NamespacedName{Namespace: v.secret.Namespace, Name: v.secret.Name} + existed, err := object.FetchObject(ctx, secretKey, v.client, &corev1.Secret{}) + if err != nil { + return err + } + + if existed != nil { + if value, ok := existed.Annotations[annotations.AnnVMOPRestore]; ok && value == v.restoreUID { + return nil + } + } else { + err = v.client.Create(ctx, v.secret) + if err != nil { + return fmt.Errorf("failed to create the `Secret`: %w", err) + } + } + + return nil +} + +func (v *ProvisionerHandler) ProcessClone(ctx context.Context) error { + return nil +} + +func (v *ProvisionerHandler) Object() client.Object { + return v.secret +} diff --git a/images/virtualization-artifact/pkg/controller/service/restorer/restorers/provisioner_restorer_test.go b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/provisioner_restorer_test.go new file mode 100644 index 0000000000..4df6f10412 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/provisioner_restorer_test.go @@ -0,0 +1,202 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package restorer + +import ( + "context" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + "github.com/deckhouse/virtualization-controller/pkg/common/testutil" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type ProvisionerTestArgs struct { + secretExists bool + secretDiffData bool + + failValidation bool + failProcess bool + + shouldBeCreated bool +} + +func TestRestorers(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Module Restorers Test Suite") +} + +var _ = Describe("ProvisionerRestorer", func() { + var ( + ctx context.Context + err error + + uid string + name string + namespace string + + intercept interceptor.Funcs + secretCreated bool + + objects []client.Object + secret corev1.Secret + handler *ProvisionerHandler + fakeClient client.WithWatch + ) + + BeforeEach(func() { + ctx = context.Background() + uid = "0000-1111-2222-4444" + name = "test-secret" + namespace = "default" + secretCreated = false + + objects = []client.Object{} + + secret = corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Data: map[string][]byte{"data": []byte("data")}, + } + + intercept = interceptor.Funcs{ + Create: func(_ context.Context, _ client.WithWatch, obj client.Object, _ ...client.CreateOption) error { + if obj.GetName() == secret.Name { + _, ok := obj.(*corev1.Secret) + Expect(ok).To(BeTrue()) + secretCreated = true + } + + return nil + }, + } + }) + + DescribeTable("restore", + func(args ProvisionerTestArgs) { + if args.secretDiffData { + secret.Data = map[string][]byte{"data": []byte("different-data")} + } + + if args.secretExists { + objects = append(objects, &secret) + } + + fakeClient, err = testutil.NewFakeClientWithInterceptorWithObjects(intercept, objects...) + Expect(err).ToNot(HaveOccurred()) + Expect(fakeClient).ToNot(BeNil()) + + secret.Data = map[string][]byte{"data": []byte("data")} + handler = NewProvisionerHandler(fakeClient, secret, uid) + Expect(handler).ToNot(BeNil()) + + err = handler.ValidateRestore(ctx) + if args.failValidation { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).ToNot(HaveOccurred()) + } + + err = handler.ProcessRestore(ctx) + if args.failProcess { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).ToNot(HaveOccurred()) + } + + Expect(secretCreated).To(Equal(args.shouldBeCreated)) + }, + Entry("secret exists; different data", ProvisionerTestArgs{ + secretExists: true, + secretDiffData: true, + + failValidation: true, + failProcess: true, + + shouldBeCreated: false, + }), + Entry("secret exists; same data", ProvisionerTestArgs{ + secretExists: true, + secretDiffData: false, + + failValidation: false, + failProcess: false, + + shouldBeCreated: false, + }), + Entry("secret doesn't exist", ProvisionerTestArgs{ + secretExists: false, + secretDiffData: false, + + failValidation: false, + failProcess: false, + + shouldBeCreated: true, + }), + ) + + Describe("Override", func() { + var rules []v1alpha2.NameReplacement + + BeforeEach(func() { + rules = []v1alpha2.NameReplacement{ + { + From: v1alpha2.NameReplacementFrom{ + Kind: "Secret", + Name: name, + }, + To: "new-secret-name", + }, + } + + fakeClient, err = testutil.NewFakeClientWithInterceptorWithObjects(intercept) + Expect(err).ToNot(HaveOccurred()) + + handler = NewProvisionerHandler(fakeClient, secret, uid) + }) + + It("should override secret name", func() { + handler.Override(rules) + Expect(handler.secret.Name).To(Equal("new-secret-name")) + }) + + It("should not override non-matching names", func() { + nonMatchingRules := []v1alpha2.NameReplacement{ + { + From: v1alpha2.NameReplacementFrom{ + Kind: "Secret", + Name: "different-secret", + }, + To: "should-not-apply", + }, + } + + originalName := handler.secret.Name + handler.Override(nonMatchingRules) + Expect(handler.secret.Name).To(Equal(originalName)) + }) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vd_restorer.go b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vd_restorer.go new file mode 100644 index 0000000000..acbaaf3638 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vd_restorer.go @@ -0,0 +1,181 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package restorer + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/controller/service/restorer/common" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type VirtualDiskHandler struct { + vd *v1alpha2.VirtualDisk + client client.Client + restoreUID string +} + +func NewVirtualDiskHandler(client client.Client, vdTmpl v1alpha2.VirtualDisk, vmRestoreUID string) *VirtualDiskHandler { + if vdTmpl.Annotations != nil { + vdTmpl.Annotations[annotations.AnnVMOPRestore] = vmRestoreUID + } else { + vdTmpl.Annotations = make(map[string]string) + vdTmpl.Annotations[annotations.AnnVMOPRestore] = vmRestoreUID + } + return &VirtualDiskHandler{ + vd: &v1alpha2.VirtualDisk{ + TypeMeta: metav1.TypeMeta{ + Kind: vdTmpl.Kind, + APIVersion: vdTmpl.APIVersion, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: vdTmpl.Name, + Namespace: vdTmpl.Namespace, + Annotations: vdTmpl.Annotations, + Labels: vdTmpl.Labels, + }, + Spec: vdTmpl.Spec, + Status: vdTmpl.Status, + }, + client: client, + restoreUID: vmRestoreUID, + } +} + +func (v *VirtualDiskHandler) Override(rules []v1alpha2.NameReplacement) { + v.vd.Name = common.OverrideName(v.vd.Kind, v.vd.Name, rules) +} + +func (v *VirtualDiskHandler) ValidateRestore(ctx context.Context) error { + vdKey := types.NamespacedName{Namespace: v.vd.Namespace, Name: v.vd.Name} + existed, err := object.FetchObject(ctx, vdKey, v.client, &v1alpha2.VirtualDisk{}) + if err != nil { + return err + } + + vmName := v.getVirtualMachineName() + + if existed != nil { + if value, ok := existed.Annotations[annotations.AnnVMOPRestore]; ok && value == v.restoreUID { + return nil + } + + for _, a := range existed.Status.AttachedToVirtualMachines { + if a.Mounted && a.Name != vmName { + return fmt.Errorf("the virtual disk %q %w", existed.Name, common.ErrAlreadyInUse) + } + } + } + + return nil +} + +func (v *VirtualDiskHandler) ValidateClone(ctx context.Context) error { + return nil +} + +func (v *VirtualDiskHandler) ProcessRestore(ctx context.Context) error { + err := v.ValidateRestore(ctx) + if err != nil { + return err + } + + vdKey := types.NamespacedName{Namespace: v.vd.Namespace, Name: v.vd.Name} + vdObj, err := object.FetchObject(ctx, vdKey, v.client, &v1alpha2.VirtualDisk{}) + if err != nil { + return fmt.Errorf("failed to fetch the `VirtualDisk`: %w", err) + } + + if object.IsTerminating(vdObj) { + return fmt.Errorf("waiting for the `VirtualDisk` %s %w", vdObj.Name, common.ErrRestoring) + } + + if vdObj != nil { + if value, ok := vdObj.Annotations[annotations.AnnVMOPRestore]; ok && value == v.restoreUID { + return nil + } + + if vdObj.Annotations == nil { + vdObj.Annotations = make(map[string]string) + } + vdObj.Annotations[annotations.AnnVMOPRestoreDeleted] = v.restoreUID + + // Phase 1: Set annotation to trigger find right VMOP for reconciliation + err := v.client.Update(ctx, vdObj) + if err != nil { + if apierrors.IsConflict(err) { + return fmt.Errorf("waiting for the `VirtualDisk` %w", common.ErrUpdating) + } + return fmt.Errorf("failed to update the `VirtualDisk`: %w", err) + } + + // Phase 2: Initiate deletion and wait for completion + if !object.IsTerminating(vdObj) { + err := v.client.Delete(ctx, vdObj) + if err != nil { + return fmt.Errorf("failed to delete the `VirtualDisk`: %w", err) + } + } + + // Phase 3: Wait for deletion to complete before creating new disk + return fmt.Errorf("waiting for deletion of VirtualDisk %s %w", vdObj.Name, common.ErrWaitingForDeletion) + } else { + err = v.client.Create(ctx, v.vd) + if err != nil { + return fmt.Errorf("failed to create the `VirtualDisk`: %w", err) + } + } + + return nil +} + +func (v *VirtualDiskHandler) ProcessClone(ctx context.Context) error { + return nil +} + +func (v *VirtualDiskHandler) Object() client.Object { + return &v1alpha2.VirtualDisk{ + TypeMeta: metav1.TypeMeta{ + Kind: v.vd.Kind, + APIVersion: v.vd.APIVersion, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: v.vd.Name, + Namespace: v.vd.Namespace, + Annotations: v.vd.Annotations, + Labels: v.vd.Labels, + }, + Spec: v.vd.Spec, + } +} + +func (v *VirtualDiskHandler) getVirtualMachineName() string { + for _, a := range v.vd.Status.AttachedToVirtualMachines { + if a.Mounted { + return a.Name + } + } + return "" +} diff --git a/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vd_restorer_test.go b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vd_restorer_test.go new file mode 100644 index 0000000000..6c808cb980 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vd_restorer_test.go @@ -0,0 +1,260 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package restorer + +import ( + "context" + "errors" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + "github.com/deckhouse/virtualization-controller/pkg/common/testutil" + "github.com/deckhouse/virtualization-controller/pkg/controller/service/restorer/common" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type VirtualDiskTestArgs struct { + diskExists bool + diskUsedByDiffVM bool + + failValidation bool + failProcess bool + + shouldBeDeleted bool + shouldBeCreated bool +} + +var _ = Describe("VirtualDiskRestorer", func() { + var ( + ctx context.Context + err error + + uid string + vm string + name string + namespace string + + intercept interceptor.Funcs + diskDeleted bool + diskCreated bool + + objects []client.Object + disk v1alpha2.VirtualDisk + handler *VirtualDiskHandler + fakeClient client.WithWatch + ) + + BeforeEach(func() { + ctx = context.Background() + uid = "0000-1111-2222-4444" + name = "test-disk" + namespace = "default" + vm = "test-vm" + diskDeleted = false + diskCreated = false + + objects = []client.Object{} + + disk = v1alpha2.VirtualDisk{ + TypeMeta: metav1.TypeMeta{ + Kind: "VirtualDisk", + APIVersion: v1alpha2.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: v1alpha2.VirtualDiskSpec{ + DataSource: &v1alpha2.VirtualDiskDataSource{ + Type: v1alpha2.DataSourceTypeObjectRef, + ObjectRef: &v1alpha2.VirtualDiskObjectRef{ + Kind: v1alpha2.VirtualDiskObjectRefKindVirtualDiskSnapshot, + Name: "test-vdsnapshot", + }, + }, + }, + Status: v1alpha2.VirtualDiskStatus{ + AttachedToVirtualMachines: []v1alpha2.AttachedVirtualMachine{ + {Name: vm, Mounted: true}, + }, + }, + } + + intercept = interceptor.Funcs{ + Delete: func(_ context.Context, _ client.WithWatch, obj client.Object, _ ...client.DeleteOption) error { + if obj.GetName() == disk.Name { + _, ok := obj.(*v1alpha2.VirtualDisk) + Expect(ok).To(BeTrue()) + diskDeleted = true + } + return nil + }, + Create: func(_ context.Context, _ client.WithWatch, obj client.Object, _ ...client.CreateOption) error { + if obj.GetName() == disk.Name { + _, ok := obj.(*v1alpha2.VirtualDisk) + Expect(ok).To(BeTrue()) + diskCreated = true + } + + return nil + }, + } + }) + + DescribeTable("restore", + func(args VirtualDiskTestArgs) { + if args.diskUsedByDiffVM { + disk.Status.AttachedToVirtualMachines = []v1alpha2.AttachedVirtualMachine{ + {Name: vm, Mounted: true}, + {Name: vm + "-2", Mounted: true}, + } + } + + if args.diskExists { + objects = append(objects, &disk) + } + + fakeClient, err = testutil.NewFakeClientWithInterceptorWithObjects(intercept, objects...) + Expect(err).ToNot(HaveOccurred()) + Expect(fakeClient).ToNot(BeNil()) + + disk.Status.AttachedToVirtualMachines = []v1alpha2.AttachedVirtualMachine{{Name: vm, Mounted: true}} + handler = NewVirtualDiskHandler(fakeClient, disk, uid) + Expect(handler).ToNot(BeNil()) + + err = handler.ValidateRestore(ctx) + if args.failValidation { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).ToNot(HaveOccurred()) + } + + err = handler.ProcessRestore(ctx) + if args.failProcess { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).ToNot(HaveOccurred()) + } + + Expect(diskDeleted).To(Equal(args.shouldBeDeleted)) + Expect(diskCreated).To(Equal(args.shouldBeCreated)) + }, + Entry("disk exists; used by different VM", VirtualDiskTestArgs{ + diskExists: true, + diskUsedByDiffVM: true, + + failValidation: true, + failProcess: true, + + shouldBeDeleted: false, + shouldBeCreated: false, + }), + Entry("disk exists; not used by different VM", VirtualDiskTestArgs{ + diskExists: true, + diskUsedByDiffVM: false, + + failValidation: false, + failProcess: true, + + shouldBeDeleted: true, + shouldBeCreated: false, + }), + Entry("disk doesn't exist", VirtualDiskTestArgs{ + diskExists: false, + diskUsedByDiffVM: false, + + failValidation: false, + failProcess: false, + + shouldBeDeleted: false, + shouldBeCreated: true, + }), + Entry("disk deletion completed; ready to create", VirtualDiskTestArgs{ + diskExists: false, + diskUsedByDiffVM: false, + + failValidation: false, + failProcess: false, + + shouldBeDeleted: false, + shouldBeCreated: true, + }), + ) + + Describe("Two-phase deletion behavior", func() { + It("should return ErrWaitingForDeletion on first call when disk needs replacement", func() { + objects = append(objects, &disk) + + fakeClient, err = testutil.NewFakeClientWithInterceptorWithObjects(intercept, objects...) + Expect(err).ToNot(HaveOccurred()) + + handler = NewVirtualDiskHandler(fakeClient, disk, uid) + + err = handler.ProcessRestore(ctx) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, common.ErrWaitingForDeletion)).To(BeTrue()) + Expect(diskDeleted).To(BeTrue()) + Expect(diskCreated).To(BeFalse()) + }) + }) + + Describe("Override", func() { + var rules []v1alpha2.NameReplacement + + BeforeEach(func() { + rules = []v1alpha2.NameReplacement{ + { + From: v1alpha2.NameReplacementFrom{ + Kind: "VirtualDisk", + Name: name, + }, + To: "new-disk-name", + }, + } + + fakeClient, err = testutil.NewFakeClientWithInterceptorWithObjects(intercept) + Expect(err).ToNot(HaveOccurred()) + + handler = NewVirtualDiskHandler(fakeClient, disk, uid) + }) + + It("should override disk name", func() { + handler.Override(rules) + Expect(handler.vd.Name).To(Equal("new-disk-name")) + }) + + It("should not override non-matching names", func() { + nonMatchingRules := []v1alpha2.NameReplacement{ + { + From: v1alpha2.NameReplacementFrom{ + Kind: "VirtualDisk", + Name: "different-disk", + }, + To: "should-not-apply", + }, + } + + originalName := handler.vd.Name + handler.Override(nonMatchingRules) + Expect(handler.vd.Name).To(Equal(originalName)) + }) + }) +}) 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 new file mode 100644 index 0000000000..b3f102b08d --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vm_restorer.go @@ -0,0 +1,282 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package restorer + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "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/controller/service/restorer/common" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vmcondition" +) + +const ReasonPVCNotFound = "PVC not found" + +type VirtualMachineHandler struct { + vm *v1alpha2.VirtualMachine + client client.Client + restoreUID string + mode v1alpha2.VMOPRestoreMode +} + +func NewVirtualMachineHandler(client client.Client, vmTmpl v1alpha2.VirtualMachine, vmopRestoreUID string, mode v1alpha2.VMOPRestoreMode) *VirtualMachineHandler { + if vmTmpl.Annotations != nil { + vmTmpl.Annotations[annotations.AnnVMOPRestore] = vmopRestoreUID + } else { + vmTmpl.Annotations = make(map[string]string) + vmTmpl.Annotations[annotations.AnnVMOPRestore] = vmopRestoreUID + } + + return &VirtualMachineHandler{ + vm: &v1alpha2.VirtualMachine{ + TypeMeta: metav1.TypeMeta{ + Kind: vmTmpl.Kind, + APIVersion: vmTmpl.APIVersion, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: vmTmpl.Name, + Namespace: vmTmpl.Namespace, + Annotations: vmTmpl.Annotations, + Labels: vmTmpl.Labels, + }, + Spec: vmTmpl.Spec, + }, + client: client, + restoreUID: vmopRestoreUID, + mode: mode, + } +} + +func (v *VirtualMachineHandler) Override(rules []v1alpha2.NameReplacement) { + v.vm.Name = common.OverrideName(v.vm.Kind, v.vm.Name, rules) + v.vm.Spec.VirtualMachineIPAddress = common.OverrideName(v1alpha2.VirtualMachineIPAddressKind, v.vm.Spec.VirtualMachineIPAddress, rules) + + if v.vm.Spec.Provisioning != nil { + if v.vm.Spec.Provisioning.UserDataRef != nil { + if v.vm.Spec.Provisioning.UserDataRef.Kind == v1alpha2.UserDataRefKindSecret { + v.vm.Spec.Provisioning.UserDataRef.Name = common.OverrideName( + string(v1alpha2.UserDataRefKindSecret), + v.vm.Spec.Provisioning.UserDataRef.Name, + rules, + ) + } + } + } + + for i := range v.vm.Spec.BlockDeviceRefs { + if v.vm.Spec.BlockDeviceRefs[i].Kind != v1alpha2.DiskDevice { + continue + } + + v.vm.Spec.BlockDeviceRefs[i].Name = common.OverrideName(v1alpha2.VirtualDiskKind, v.vm.Spec.BlockDeviceRefs[i].Name, rules) + } +} + +func (v *VirtualMachineHandler) ValidateRestore(ctx context.Context) error { + vmKey := types.NamespacedName{Namespace: v.vm.Namespace, Name: v.vm.Name} + existed, err := object.FetchObject(ctx, vmKey, v.client, &v1alpha2.VirtualMachine{}) + if err != nil { + return err + } + + if existed != nil { + if value, ok := existed.Annotations[annotations.AnnVMOPRestore]; ok && value == v.restoreUID { + return nil + } + } + + if err := v.validateImageDependencies(ctx); err != nil { + return err + } + + return nil +} + +func (v *VirtualMachineHandler) ValidateClone(ctx context.Context) error { + return nil +} + +func (v *VirtualMachineHandler) ProcessRestore(ctx context.Context) error { + err := v.ValidateRestore(ctx) + if err != nil { + return err + } + + if err := v.validateImageDependencies(ctx); err != nil { + return err + } + + vmKey := types.NamespacedName{Namespace: v.vm.Namespace, Name: v.vm.Name} + vm, err := object.FetchObject(ctx, vmKey, v.client, &v1alpha2.VirtualMachine{}) + if err != nil { + return fmt.Errorf("failed to fetch the `VirtualMachine`: %w", err) + } + + if vm != nil { + cond, found := conditions.GetCondition(vmcondition.TypeMaintenance, vm.Status.Conditions) + if !found { + return common.ErrVMMaintenanceCondNotFound + } + + if cond.Status != metav1.ConditionTrue { + return common.ErrVMNotInMaintenance + } + + // Early return if VM is already fully processed by this restore operation + if value, ok := vm.Annotations[annotations.AnnVMOPRestore]; ok && value == v.restoreUID { + if equality.Semantic.DeepEqual(vm.Spec, v.vm.Spec) { + return nil + } + } + + if vm.Annotations == nil { + vm.Annotations = make(map[string]string) + } + + vm.Spec = v.vm.Spec + vm.Labels = v.vm.Labels + vm.Annotations = v.vm.Annotations + vm.Annotations[annotations.AnnVMOPRestore] = v.restoreUID + + updErr := v.client.Update(ctx, vm) + if updErr != nil { + if apierrors.IsConflict(updErr) { + return fmt.Errorf("waiting for the `VirtualMachine` %w", common.ErrUpdating) + } + return fmt.Errorf("failed to update the `VirtualMachine`: %w", updErr) + } + + // Always clean up VMBDAs first, regardless of VM state + err = v.deleteCurrentVirtualMachineBlockDeviceAttachments(ctx) + if err != nil { + return err + } + } else { + err := v.client.Create(ctx, v.vm) + if err != nil { + return fmt.Errorf("failed to create the `VirtualMachine`: %w", err) + } + } + + return nil +} + +func (v *VirtualMachineHandler) ProcessClone(ctx context.Context) error { + return nil +} + +func (v *VirtualMachineHandler) validateImageDependencies(ctx context.Context) error { + filteredRefs := make([]v1alpha2.BlockDeviceSpecRef, 0, len(v.vm.Spec.BlockDeviceRefs)) + + for _, ref := range v.vm.Spec.BlockDeviceRefs { + if ref.Kind != v1alpha2.ImageDevice && ref.Kind != v1alpha2.ClusterImageDevice { + filteredRefs = append(filteredRefs, ref) + continue + } + + exists, err := v.imageExists(ctx, ref) + if err != nil { + return err + } + + if exists { + filteredRefs = append(filteredRefs, ref) + } + } + + v.vm.Spec.BlockDeviceRefs = filteredRefs + return nil +} + +func (v *VirtualMachineHandler) imageExists(ctx context.Context, ref v1alpha2.BlockDeviceSpecRef) (bool, error) { + var obj client.Object + var key types.NamespacedName + + switch ref.Kind { + case v1alpha2.ImageDevice: + obj = &v1alpha2.VirtualImage{} + key = types.NamespacedName{Namespace: v.vm.Namespace, Name: ref.Name} + case v1alpha2.ClusterImageDevice: + obj = &v1alpha2.ClusterVirtualImage{} + key = types.NamespacedName{Name: ref.Name} + default: + return true, nil + } + + fetchedObj, err := object.FetchObject(ctx, key, v.client, obj) + if err != nil { + return false, err + } + + if fetchedObj == nil { + if v.mode == v1alpha2.VMOPRestoreModeBestEffort { + return false, nil + } + return false, fmt.Errorf("%s %q not found", ref.Kind, ref.Name) + } + + return true, nil +} + +func (v *VirtualMachineHandler) Object() client.Object { + return v.vm +} + +func (v *VirtualMachineHandler) deleteCurrentVirtualMachineBlockDeviceAttachments(ctx context.Context) error { + vmbdas := &v1alpha2.VirtualMachineBlockDeviceAttachmentList{} + err := v.client.List(ctx, vmbdas, &client.ListOptions{Namespace: v.vm.Namespace}) + if err != nil { + return fmt.Errorf("failed to list the `VirtualMachineBlockDeviceAttachment`: %w", err) + } + + // Create a set of block device names that should exist based on the VM from snapshot + expectedBlockDevices := make(map[string]struct{}) + for _, ref := range v.vm.Spec.BlockDeviceRefs { + expectedBlockDevices[ref.Name] = struct{}{} + } + + vmbdasToDelete := make([]*v1alpha2.VirtualMachineBlockDeviceAttachment, 0, len(vmbdas.Items)) + for _, vmbda := range vmbdas.Items { + if vmbda.Spec.VirtualMachineName != v.vm.Name { + continue + } + + // Delete VMBDA if it's not in the expected block devices from the snapshot + if _, ok := expectedBlockDevices[vmbda.Spec.BlockDeviceRef.Name]; !ok { + vmbdasToDelete = append(vmbdasToDelete, &vmbda) + } + } + + for _, vmbda := range vmbdasToDelete { + err := object.DeleteObject(ctx, v.client, client.Object(vmbda)) + if err != nil { + return fmt.Errorf("failed to delete the `VirtualMachineBlockDeviceAttachment` %s: %w", vmbda.Name, err) + } + } + + return 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 new file mode 100644 index 0000000000..146192076b --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vm_restorer_test.go @@ -0,0 +1,424 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package restorer + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/testutil" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type VirtualMachineTestArgs struct { + vmExists bool + vmHasCorrectRestoreUID bool + vmHasCorrectSpec bool + hasVMBDAs bool + vmbdasHaveCorrectUID bool + + failValidation bool + failProcess bool + + shouldCreateVM bool + shouldUpdateVM bool + shouldDeleteVMBDAs bool +} + +var _ = Describe("VirtualMachineRestorer", func() { + var ( + ctx context.Context + err error + + restoreUID string + vmName string + namespace string + + intercept interceptor.Funcs + vmCreated bool + vmUpdated bool + vmbdasDeleted int + + objects []client.Object + vm v1alpha2.VirtualMachine + vmbda1 v1alpha2.VirtualMachineBlockDeviceAttachment + vmbda2 v1alpha2.VirtualMachineBlockDeviceAttachment + handler *VirtualMachineHandler + fakeClient client.WithWatch + ) + + BeforeEach(func() { + ctx = context.Background() + restoreUID = "restore-uid-1234" + vmName = "test-vm" + namespace = "default" + vmCreated = false + vmUpdated = false + vmbdasDeleted = 0 + + objects = []client.Object{} + + vm = v1alpha2.VirtualMachine{ + TypeMeta: metav1.TypeMeta{ + Kind: "VirtualMachine", + APIVersion: "virtualization.deckhouse.io/v1alpha2", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: vmName, + Namespace: namespace, + Annotations: map[string]string{ + "test-annotation": "test-value", + }, + }, + Spec: v1alpha2.VirtualMachineSpec{ + RunPolicy: v1alpha2.AlwaysOnPolicy, + VirtualMachineIPAddress: "test-ip", + BlockDeviceRefs: []v1alpha2.BlockDeviceSpecRef{ + { + Kind: v1alpha2.DiskDevice, + Name: "test-disk-1", + }, + { + Kind: v1alpha2.DiskDevice, + Name: "test-disk-2", + }, + }, + }, + Status: v1alpha2.VirtualMachineStatus{ + Conditions: []metav1.Condition{ + { + Type: "Maintenance", + Status: metav1.ConditionTrue, + Reason: "InMaintenance", + }, + }, + }, + } + + vmbda1 = v1alpha2.VirtualMachineBlockDeviceAttachment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vmbda-1", + Namespace: namespace, + Annotations: map[string]string{ + "other-annotation": "other-value", + }, + }, + Spec: v1alpha2.VirtualMachineBlockDeviceAttachmentSpec{ + VirtualMachineName: vmName, + BlockDeviceRef: v1alpha2.VMBDAObjectRef{ + Kind: v1alpha2.VMBDAObjectRefKindVirtualDisk, + Name: "old-disk-1", + }, + }, + } + + vmbda2 = v1alpha2.VirtualMachineBlockDeviceAttachment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "vmbda-2", + Namespace: namespace, + Annotations: map[string]string{ + annotations.AnnVMOPRestore: "different-restore-uid", + }, + }, + Spec: v1alpha2.VirtualMachineBlockDeviceAttachmentSpec{ + VirtualMachineName: vmName, + BlockDeviceRef: v1alpha2.VMBDAObjectRef{ + Kind: v1alpha2.VMBDAObjectRefKindVirtualDisk, + Name: "old-disk-2", + }, + }, + } + + intercept = interceptor.Funcs{ + Create: func(_ context.Context, _ client.WithWatch, obj client.Object, _ ...client.CreateOption) error { + if obj.GetName() == vm.Name && obj.GetNamespace() == vm.Namespace { + _, ok := obj.(*v1alpha2.VirtualMachine) + Expect(ok).To(BeTrue()) + vmCreated = true + } + return nil + }, + Update: func(_ context.Context, _ client.WithWatch, obj client.Object, _ ...client.UpdateOption) error { + if obj.GetName() == vm.Name && obj.GetNamespace() == vm.Namespace { + _, ok := obj.(*v1alpha2.VirtualMachine) + Expect(ok).To(BeTrue()) + vmUpdated = true + } + return nil + }, + Delete: func(_ context.Context, _ client.WithWatch, obj client.Object, _ ...client.DeleteOption) error { + if _, ok := obj.(*v1alpha2.VirtualMachineBlockDeviceAttachment); ok { + vmbdasDeleted++ + } + return nil + }, + } + }) + + DescribeTable("restore", + func(args VirtualMachineTestArgs) { + if args.vmExists { + vmToAdd := vm.DeepCopy() + if args.vmHasCorrectRestoreUID { + if vmToAdd.Annotations == nil { + vmToAdd.Annotations = make(map[string]string) + } + vmToAdd.Annotations[annotations.AnnVMOPRestore] = restoreUID + } + if !args.vmHasCorrectSpec { + vmToAdd.Spec.VirtualMachineIPAddress = "different-ip" + } + objects = append(objects, vmToAdd) + } + + if args.hasVMBDAs { + vmbda1Copy := vmbda1.DeepCopy() + vmbda2Copy := vmbda2.DeepCopy() + + if args.vmbdasHaveCorrectUID { + if vmbda1Copy.Annotations == nil { + vmbda1Copy.Annotations = make(map[string]string) + } + vmbda1Copy.Annotations[annotations.AnnVMOPRestore] = restoreUID + + if vmbda2Copy.Annotations == nil { + vmbda2Copy.Annotations = make(map[string]string) + } + vmbda2Copy.Annotations[annotations.AnnVMOPRestore] = restoreUID + } + + objects = append(objects, vmbda1Copy, vmbda2Copy) + } + + fakeClient, err = testutil.NewFakeClientWithInterceptorWithObjects(intercept, objects...) + Expect(err).ToNot(HaveOccurred()) + Expect(fakeClient).ToNot(BeNil()) + + handler = NewVirtualMachineHandler(fakeClient, vm, restoreUID, v1alpha2.VMOPRestoreModeStrict) + Expect(handler).ToNot(BeNil()) + + // Verify that restore annotation was added + Expect(handler.vm.Annotations[annotations.AnnVMOPRestore]).To(Equal(restoreUID)) + + err = handler.ValidateRestore(ctx) + if args.failValidation { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).ToNot(HaveOccurred()) + } + + err = handler.ProcessRestore(ctx) + if args.failProcess { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).ToNot(HaveOccurred()) + } + + Expect(vmCreated).To(Equal(args.shouldCreateVM)) + Expect(vmUpdated).To(Equal(args.shouldUpdateVM)) + + if args.shouldDeleteVMBDAs { + expectedDeletes := 0 + if args.hasVMBDAs { + if !args.vmbdasHaveCorrectUID { + expectedDeletes = 2 + } else { + expectedDeletes = 0 + } + } + Expect(vmbdasDeleted).To(Equal(expectedDeletes)) + } + }, + Entry("VM doesn't exist", VirtualMachineTestArgs{ + vmExists: false, + + failValidation: false, + failProcess: false, + + shouldCreateVM: true, + shouldUpdateVM: false, + shouldDeleteVMBDAs: true, + }), + Entry("VM exists with correct restore UID and correct spec", VirtualMachineTestArgs{ + vmExists: true, + vmHasCorrectRestoreUID: true, + vmHasCorrectSpec: true, + + failValidation: false, + failProcess: false, + + shouldCreateVM: false, + shouldUpdateVM: false, + shouldDeleteVMBDAs: true, + }), + Entry("VM exists with incorrect restore UID", VirtualMachineTestArgs{ + vmExists: true, + vmHasCorrectRestoreUID: false, + vmHasCorrectSpec: true, + + failValidation: false, + failProcess: false, + + shouldCreateVM: false, + shouldUpdateVM: true, + shouldDeleteVMBDAs: true, + }), + Entry("VM exists with incorrect spec", VirtualMachineTestArgs{ + vmExists: true, + vmHasCorrectRestoreUID: true, + vmHasCorrectSpec: false, + + failValidation: false, + failProcess: false, + + shouldCreateVM: false, + shouldUpdateVM: true, + shouldDeleteVMBDAs: true, + }), + Entry("VM exists with incorrect UID and spec", VirtualMachineTestArgs{ + vmExists: true, + vmHasCorrectRestoreUID: false, + vmHasCorrectSpec: false, + + failValidation: false, + failProcess: false, + + shouldCreateVM: false, + shouldUpdateVM: true, + shouldDeleteVMBDAs: true, + }), + Entry("VM exists with VMBDAs that should be deleted", VirtualMachineTestArgs{ + vmExists: true, + vmHasCorrectRestoreUID: true, + vmHasCorrectSpec: true, + hasVMBDAs: true, + vmbdasHaveCorrectUID: false, + + failValidation: false, + failProcess: false, + + shouldCreateVM: false, + shouldUpdateVM: false, + shouldDeleteVMBDAs: false, + }), + Entry("VM exists with VMBDAs that should not be deleted", VirtualMachineTestArgs{ + vmExists: true, + vmHasCorrectRestoreUID: true, + vmHasCorrectSpec: true, + hasVMBDAs: true, + vmbdasHaveCorrectUID: true, + + failValidation: false, + failProcess: false, + + shouldCreateVM: false, + shouldUpdateVM: false, + shouldDeleteVMBDAs: true, + }), + ) + + Describe("Override", func() { + var rules []v1alpha2.NameReplacement + + BeforeEach(func() { + rules = []v1alpha2.NameReplacement{ + { + From: v1alpha2.NameReplacementFrom{ + Kind: "VirtualMachine", + Name: vmName, + }, + To: "new-vm-name", + }, + { + From: v1alpha2.NameReplacementFrom{ + Kind: "VirtualMachineIPAddress", + Name: "test-ip", + }, + To: "new-test-ip", + }, + { + From: v1alpha2.NameReplacementFrom{ + Kind: "VirtualDisk", + Name: "test-disk-1", + }, + To: "new-test-disk-1", + }, + { + From: v1alpha2.NameReplacementFrom{ + Kind: "Secret", + Name: "test-secret", + }, + To: "new-test-secret", + }, + } + + fakeClient, err = testutil.NewFakeClientWithInterceptorWithObjects(intercept) + Expect(err).ToNot(HaveOccurred()) + + handler = NewVirtualMachineHandler(fakeClient, vm, restoreUID, v1alpha2.VMOPRestoreModeStrict) + }) + + It("should override VM name", func() { + handler.Override(rules) + Expect(handler.vm.Name).To(Equal("new-vm-name")) + }) + + It("should override VirtualMachineIPAddress", func() { + handler.Override(rules) + Expect(handler.vm.Spec.VirtualMachineIPAddress).To(Equal("new-test-ip")) + }) + + It("should override disk names in BlockDeviceRefs", func() { + handler.Override(rules) + Expect(handler.vm.Spec.BlockDeviceRefs[0].Name).To(Equal("new-test-disk-1")) + Expect(handler.vm.Spec.BlockDeviceRefs[1].Name).To(Equal("test-disk-2")) // unchanged + }) + + It("should override Secret name in UserDataRef", func() { + handler.vm.Spec.Provisioning = &v1alpha2.Provisioning{ + UserDataRef: &v1alpha2.UserDataRef{ + Kind: v1alpha2.UserDataRefKindSecret, + Name: "test-secret", + }, + } + handler.Override(rules) + Expect(handler.vm.Spec.Provisioning.UserDataRef.Name).To(Equal("new-test-secret")) + }) + + It("should not override non-matching names", func() { + nonMatchingRules := []v1alpha2.NameReplacement{ + { + From: v1alpha2.NameReplacementFrom{ + Kind: "VirtualMachine", + Name: "different-vm", + }, + To: "should-not-apply", + }, + } + + originalName := handler.vm.Name + handler.Override(nonMatchingRules) + Expect(handler.vm.Name).To(Equal(originalName)) + }) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmbda_restorer.go b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmbda_restorer.go new file mode 100644 index 0000000000..602f44f2aa --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmbda_restorer.go @@ -0,0 +1,165 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package restorer + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/controller/service/restorer/common" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type VMBlockDeviceAttachmentHandler struct { + vmbda *v1alpha2.VirtualMachineBlockDeviceAttachment + client client.Client + restoreUID string +} + +func NewVMBlockDeviceAttachmentHandler(client client.Client, vmbdaTmpl v1alpha2.VirtualMachineBlockDeviceAttachment, vmRestoreUID string) *VMBlockDeviceAttachmentHandler { + if vmbdaTmpl.Annotations != nil { + vmbdaTmpl.Annotations[annotations.AnnVMOPRestore] = vmRestoreUID + } else { + vmbdaTmpl.Annotations = make(map[string]string) + vmbdaTmpl.Annotations[annotations.AnnVMOPRestore] = vmRestoreUID + } + return &VMBlockDeviceAttachmentHandler{ + vmbda: &v1alpha2.VirtualMachineBlockDeviceAttachment{ + TypeMeta: metav1.TypeMeta{ + Kind: vmbdaTmpl.Kind, + APIVersion: vmbdaTmpl.APIVersion, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: vmbdaTmpl.Name, + Namespace: vmbdaTmpl.Namespace, + Annotations: vmbdaTmpl.Annotations, + Labels: vmbdaTmpl.Labels, + }, + Spec: vmbdaTmpl.Spec, + }, + client: client, + restoreUID: vmRestoreUID, + } +} + +func (v *VMBlockDeviceAttachmentHandler) Override(rules []v1alpha2.NameReplacement) { + v.vmbda.Name = common.OverrideName(v.vmbda.Kind, v.vmbda.Name, rules) + v.vmbda.Spec.VirtualMachineName = common.OverrideName(v1alpha2.VirtualMachineKind, v.vmbda.Spec.VirtualMachineName, rules) + + switch v.vmbda.Spec.BlockDeviceRef.Kind { + case v1alpha2.VMBDAObjectRefKindVirtualDisk: + v.vmbda.Spec.BlockDeviceRef.Name = common.OverrideName(v1alpha2.VirtualDiskKind, v.vmbda.Spec.BlockDeviceRef.Name, rules) + case v1alpha2.VMBDAObjectRefKindClusterVirtualImage: + v.vmbda.Spec.BlockDeviceRef.Name = common.OverrideName(v1alpha2.ClusterVirtualImageKind, v.vmbda.Spec.BlockDeviceRef.Name, rules) + case v1alpha2.VMBDAObjectRefKindVirtualImage: + v.vmbda.Spec.BlockDeviceRef.Name = common.OverrideName(v1alpha2.VirtualImageKind, v.vmbda.Spec.BlockDeviceRef.Name, rules) + } +} + +func (v *VMBlockDeviceAttachmentHandler) ValidateRestore(ctx context.Context) error { + vmbdaKey := types.NamespacedName{Namespace: v.vmbda.Namespace, Name: v.vmbda.Name} + existed, err := object.FetchObject(ctx, vmbdaKey, v.client, &v1alpha2.VirtualMachineBlockDeviceAttachment{}) + if err != nil { + return err + } + + if existed != nil { + if value, ok := existed.Annotations[annotations.AnnVMOPRestore]; ok && value == v.restoreUID { + return nil + } + + if v.vmbda.Spec.VirtualMachineName != existed.Spec.VirtualMachineName { + return fmt.Errorf("the virtual machine block device attachment %q %w", vmbdaKey.Name, common.ErrAlreadyInUse) + } + } + + return nil +} + +func (v *VMBlockDeviceAttachmentHandler) ValidateClone(ctx context.Context) error { + return nil +} + +func (v *VMBlockDeviceAttachmentHandler) ProcessRestore(ctx context.Context) error { + err := v.ValidateRestore(ctx) + if err != nil { + return err + } + + vmbdaKey := types.NamespacedName{Namespace: v.vmbda.Namespace, Name: v.vmbda.Name} + vmbdaObj, err := object.FetchObject(ctx, vmbdaKey, v.client, &v1alpha2.VirtualMachineBlockDeviceAttachment{}) + if err != nil { + return fmt.Errorf("failed to fetch the `VirtualMachineBlockDeviceAttachment`: %w", err) + } + + if object.IsTerminating(vmbdaObj) { + return fmt.Errorf("waiting for the `VirtualMachineBlockDeviceAttachment` %s %w", vmbdaObj.Name, common.ErrRestoring) + } + + if vmbdaObj != nil { + if value, ok := vmbdaObj.Annotations[annotations.AnnVMOPRestore]; ok && value == v.restoreUID { + return nil + } + + if vmbdaObj.Annotations == nil { + vmbdaObj.Annotations = make(map[string]string) + } + vmbdaObj.Annotations[annotations.AnnVMOPRestore] = v.restoreUID + + // Phase 1: Set annotation to trigger find right VMOP for reconciliation + err := v.client.Update(ctx, vmbdaObj) + if err != nil { + if apierrors.IsConflict(err) { + return fmt.Errorf("waiting for the `VirtualMachineBlockDeviceAttachment` %w", common.ErrUpdating) + } + return fmt.Errorf("failed to update the `VirtualMachineBlockDeviceAttachment`: %w", err) + } + + // Phase 2: Initiate deletion and wait for completion + if !object.IsTerminating(vmbdaObj) { + err = v.client.Delete(ctx, vmbdaObj) + if err != nil { + return fmt.Errorf("failed to delete the `VirtualMachineBlockDeviceAttachment`: %w", err) + } + } + + // Phase 3: Wait for deletion to complete before creating new VMBDA + return fmt.Errorf("waiting for deletion of VirtualMachineBlockDeviceAttachment %s %w", vmbdaObj.Name, common.ErrWaitingForDeletion) + } else { + err = v.client.Create(ctx, v.vmbda) + if err != nil { + return fmt.Errorf("failed to create the `VirtualMachineBlockDeviceAttachment`: %w", err) + } + } + + return nil +} + +func (v *VMBlockDeviceAttachmentHandler) ProcessClone(ctx context.Context) error { + return nil +} + +func (v *VMBlockDeviceAttachmentHandler) Object() client.Object { + return v.vmbda +} diff --git a/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmbda_restorer_test.go b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmbda_restorer_test.go new file mode 100644 index 0000000000..496ac430bf --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmbda_restorer_test.go @@ -0,0 +1,315 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package restorer + +import ( + "context" + "errors" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + "github.com/deckhouse/virtualization-controller/pkg/common/testutil" + "github.com/deckhouse/virtualization-controller/pkg/controller/service/restorer/common" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type VMBlockDeviceAttachmentTestArgs struct { + mode v1alpha2.VMOPRestoreMode + + vmbdaExists bool + vmbdaUsedByDiffVM bool + + failValidation bool + failProcess bool + + shouldBeDeleted bool + shouldBeCreated bool +} + +var _ = Describe("VMBlockDeviceAttachmentRestorer", func() { + var ( + ctx context.Context + err error + + uid string + vm string + name string + namespace string + + intercept interceptor.Funcs + vmbdaDeleted bool + vmbdaCreated bool + + objects []client.Object + vmbda v1alpha2.VirtualMachineBlockDeviceAttachment + handler *VMBlockDeviceAttachmentHandler + fakeClient client.WithWatch + ) + + BeforeEach(func() { + ctx = context.Background() + uid = "0000-1111-2222-4444" + name = "test-vmbda" + namespace = "default" + vm = "test-vm" + vmbdaDeleted = false + vmbdaCreated = false + + objects = []client.Object{} + vmbda = v1alpha2.VirtualMachineBlockDeviceAttachment{ + TypeMeta: metav1.TypeMeta{ + Kind: "VirtualMachineBlockDeviceAttachment", + APIVersion: v1alpha2.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Spec: v1alpha2.VirtualMachineBlockDeviceAttachmentSpec{VirtualMachineName: vm}, + } + + intercept = interceptor.Funcs{ + Delete: func(_ context.Context, _ client.WithWatch, obj client.Object, _ ...client.DeleteOption) error { + if obj.GetName() == vmbda.Name { + _, ok := obj.(*v1alpha2.VirtualMachineBlockDeviceAttachment) + Expect(ok).To(BeTrue()) + vmbdaDeleted = true + } + return nil + }, + Create: func(_ context.Context, _ client.WithWatch, obj client.Object, _ ...client.CreateOption) error { + if obj.GetName() == vmbda.Name { + _, ok := obj.(*v1alpha2.VirtualMachineBlockDeviceAttachment) + Expect(ok).To(BeTrue()) + vmbdaCreated = true + } + + return nil + }, + } + }) + + DescribeTable("restore", + func(args VMBlockDeviceAttachmentTestArgs) { + if args.vmbdaUsedByDiffVM { + vmbda.Spec.VirtualMachineName = vm + "-2" + } + + if args.vmbdaExists { + objects = append(objects, &vmbda) + } + + fakeClient, err = testutil.NewFakeClientWithInterceptorWithObjects(intercept, objects...) + Expect(err).ToNot(HaveOccurred()) + Expect(fakeClient).ToNot(BeNil()) + + vmbda.Spec.VirtualMachineName = vm + handler = NewVMBlockDeviceAttachmentHandler(fakeClient, vmbda, uid) + Expect(handler).ToNot(BeNil()) + + err = handler.ValidateRestore(ctx) + if args.failValidation { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).ToNot(HaveOccurred()) + } + + err = handler.ProcessRestore(ctx) + if args.failProcess { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).ToNot(HaveOccurred()) + } + + Expect(vmbdaDeleted).To(Equal(args.shouldBeDeleted)) + Expect(vmbdaCreated).To(Equal(args.shouldBeCreated)) + }, + Entry("vmbda exists; used by different VM", VMBlockDeviceAttachmentTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + + vmbdaExists: true, + vmbdaUsedByDiffVM: true, + + failValidation: true, + failProcess: true, + + shouldBeDeleted: false, + shouldBeCreated: false, + }), + Entry("vmbda exists; not used by different VM", VMBlockDeviceAttachmentTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + + vmbdaExists: true, + vmbdaUsedByDiffVM: false, + + failValidation: false, + failProcess: true, + + shouldBeDeleted: true, + shouldBeCreated: false, + }), + Entry("vmbda doesn't exist", VMBlockDeviceAttachmentTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + + vmbdaExists: false, + vmbdaUsedByDiffVM: false, + + failValidation: false, + failProcess: false, + + shouldBeDeleted: false, + shouldBeCreated: true, + }), + Entry("vmbda deletion completed; ready to create", VMBlockDeviceAttachmentTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + + vmbdaExists: false, + vmbdaUsedByDiffVM: false, + + failValidation: false, + failProcess: false, + + shouldBeDeleted: false, + shouldBeCreated: true, + }), + ) + + Describe("Two-phase deletion behavior", func() { + It("should return ErrWaitingForDeletion on first call when VMBDA needs replacement", func() { + objects = append(objects, &vmbda) + + fakeClient, err = testutil.NewFakeClientWithInterceptorWithObjects(intercept, objects...) + Expect(err).ToNot(HaveOccurred()) + + handler = NewVMBlockDeviceAttachmentHandler(fakeClient, vmbda, uid) + + err = handler.ProcessRestore(ctx) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, common.ErrWaitingForDeletion)).To(BeTrue()) + Expect(vmbdaDeleted).To(BeTrue()) + Expect(vmbdaCreated).To(BeFalse()) + }) + }) + + Describe("Override", func() { + var rules []v1alpha2.NameReplacement + + BeforeEach(func() { + vmbda.Spec.BlockDeviceRef = v1alpha2.VMBDAObjectRef{ + Kind: v1alpha2.VMBDAObjectRefKindVirtualDisk, + Name: "test-disk", + } + + rules = []v1alpha2.NameReplacement{ + { + From: v1alpha2.NameReplacementFrom{ + Kind: "VirtualMachineBlockDeviceAttachment", + Name: name, + }, + To: "new-vmbda-name", + }, + { + From: v1alpha2.NameReplacementFrom{ + Kind: "VirtualMachine", + Name: vm, + }, + To: "new-vm-name", + }, + { + From: v1alpha2.NameReplacementFrom{ + Kind: "VirtualDisk", + Name: "test-disk", + }, + To: "new-disk-name", + }, + } + + fakeClient, err = testutil.NewFakeClientWithInterceptorWithObjects(intercept) + Expect(err).ToNot(HaveOccurred()) + + handler = NewVMBlockDeviceAttachmentHandler(fakeClient, vmbda, uid) + }) + + It("should override VMBDA name", func() { + handler.Override(rules) + Expect(handler.vmbda.Name).To(Equal("new-vmbda-name")) + }) + + It("should override VirtualMachine name", func() { + handler.Override(rules) + Expect(handler.vmbda.Spec.VirtualMachineName).To(Equal("new-vm-name")) + }) + + It("should override VirtualDisk name in BlockDeviceRef", func() { + handler.Override(rules) + Expect(handler.vmbda.Spec.BlockDeviceRef.Name).To(Equal("new-disk-name")) + }) + + It("should override ClusterVirtualImage name in BlockDeviceRef", func() { + handler.vmbda.Spec.BlockDeviceRef.Kind = v1alpha2.VMBDAObjectRefKindClusterVirtualImage + handler.vmbda.Spec.BlockDeviceRef.Name = "test-cvi" + + cviRules := []v1alpha2.NameReplacement{ + { + From: v1alpha2.NameReplacementFrom{ + Kind: "ClusterVirtualImage", + Name: "test-cvi", + }, + To: "new-cvi-name", + }, + } + + handler.Override(cviRules) + Expect(handler.vmbda.Spec.BlockDeviceRef.Name).To(Equal("new-cvi-name")) + }) + + It("should override VirtualImage name in BlockDeviceRef", func() { + handler.vmbda.Spec.BlockDeviceRef.Kind = v1alpha2.VMBDAObjectRefKindVirtualImage + handler.vmbda.Spec.BlockDeviceRef.Name = "test-vi" + + viRules := []v1alpha2.NameReplacement{ + { + From: v1alpha2.NameReplacementFrom{ + Kind: "VirtualImage", + Name: "test-vi", + }, + To: "new-vi-name", + }, + } + + handler.Override(viRules) + Expect(handler.vmbda.Spec.BlockDeviceRef.Name).To(Equal("new-vi-name")) + }) + + It("should not override non-matching names", func() { + nonMatchingRules := []v1alpha2.NameReplacement{ + { + From: v1alpha2.NameReplacementFrom{ + Kind: "VirtualMachineBlockDeviceAttachment", + Name: "different-vmbda", + }, + To: "should-not-apply", + }, + } + + originalName := handler.vmbda.Name + handler.Override(nonMatchingRules) + Expect(handler.vmbda.Name).To(Equal(originalName)) + }) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmip_restorer.go b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmip_restorer.go new file mode 100644 index 0000000000..c27921816d --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmip_restorer.go @@ -0,0 +1,164 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package restorer + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" + "github.com/deckhouse/virtualization-controller/pkg/controller/service/restorer/common" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type VirtualMachineIPHandler struct { + vmip *v1alpha2.VirtualMachineIPAddress + client client.Client + restoreUID string +} + +func NewVirtualMachineIPAddressHandler(client client.Client, vmipTmpl *v1alpha2.VirtualMachineIPAddress, vmRestoreUID string) *VirtualMachineIPHandler { + if vmipTmpl.Annotations != nil { + vmipTmpl.Annotations[annotations.AnnVMOPRestore] = vmRestoreUID + } else { + vmipTmpl.Annotations = make(map[string]string) + vmipTmpl.Annotations[annotations.AnnVMOPRestore] = vmRestoreUID + } + return &VirtualMachineIPHandler{ + vmip: &v1alpha2.VirtualMachineIPAddress{ + TypeMeta: metav1.TypeMeta{ + Kind: vmipTmpl.Kind, + APIVersion: vmipTmpl.APIVersion, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: vmipTmpl.Name, + Namespace: vmipTmpl.Namespace, + Annotations: vmipTmpl.Annotations, + Labels: vmipTmpl.Labels, + }, + Spec: vmipTmpl.Spec, + Status: vmipTmpl.Status, + }, + client: client, + restoreUID: vmRestoreUID, + } +} + +func (v *VirtualMachineIPHandler) Override(rules []v1alpha2.NameReplacement) { + v.vmip.Name = common.OverrideName(v.vmip.Kind, v.vmip.Name, rules) +} + +func (v *VirtualMachineIPHandler) ValidateRestore(ctx context.Context) error { + vmipKey := types.NamespacedName{Namespace: v.vmip.Namespace, Name: v.vmip.Name} + existed, err := object.FetchObject(ctx, vmipKey, v.client, &v1alpha2.VirtualMachineIPAddress{}) + if err != nil { + return err + } + + if existed != nil { + if value, ok := existed.Annotations[annotations.AnnVMOPRestore]; ok && value == v.restoreUID { + return nil + } + } + + if v.vmip.Spec.StaticIP != "" { + var vmips v1alpha2.VirtualMachineIPAddressList + err = v.client.List(ctx, &vmips, &client.ListOptions{ + Namespace: v.vmip.Namespace, + FieldSelector: fields.OneTermEqualSelector(indexer.IndexFieldVMIPByAddress, v.vmip.Spec.StaticIP), + }) + if err != nil { + return err + } + + for _, vmip := range vmips.Items { + if vmip.Status.VirtualMachine == v.vmip.Status.VirtualMachine || vmip.Name == v.vmip.Name { + continue + } + + return fmt.Errorf( + "the IP Address %q cannot be used for restore: it is taken by VirtualMachineIPAddress/%s and %w by the different virtual machine", + v.vmip.Spec.StaticIP, vmip.Name, common.ErrAlreadyInUse, + ) + } + } + + if existed != nil { + if existed.Status.Phase == v1alpha2.VirtualMachineIPAddressPhaseAttached && existed.Status.VirtualMachine != v.vmip.Status.VirtualMachine { + return fmt.Errorf("the virtual machine ip address %q is %w and cannot be used for the restored virtual machine", vmipKey.Name, common.ErrAlreadyInUse) + } + } + + return nil +} + +func (v *VirtualMachineIPHandler) ProcessRestore(ctx context.Context) error { + err := v.ValidateRestore(ctx) + if err != nil { + return err + } + + vmipKey := types.NamespacedName{Namespace: v.vmip.Namespace, Name: v.vmip.Name} + existed, err := object.FetchObject(ctx, vmipKey, v.client, &v1alpha2.VirtualMachineIPAddress{}) + if err != nil { + return err + } + + if existed != nil { + if value, ok := existed.Annotations[annotations.AnnVMOPRestore]; ok && value == v.restoreUID { + return nil + } + } else { + err = v.client.Create(ctx, v.vmip) + if err != nil { + return fmt.Errorf("failed to create the `VirtualMachineIPAddress`: %w", err) + } + } + + return nil +} + +func (v *VirtualMachineIPHandler) ValidateClone(ctx context.Context) error { + return nil +} + +func (v *VirtualMachineIPHandler) ProcessClone(ctx context.Context) error { + return nil +} + +func (v *VirtualMachineIPHandler) Object() client.Object { + return &v1alpha2.VirtualMachineIPAddress{ + TypeMeta: metav1.TypeMeta{ + Kind: v.vmip.Kind, + APIVersion: v.vmip.APIVersion, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: v.vmip.Name, + Namespace: v.vmip.Namespace, + Annotations: v.vmip.Annotations, + Labels: v.vmip.Labels, + }, + Spec: v.vmip.Spec, + } +} diff --git a/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmip_restorer_test.go b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmip_restorer_test.go new file mode 100644 index 0000000000..e3d1675501 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmip_restorer_test.go @@ -0,0 +1,321 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package restorer + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + "github.com/deckhouse/virtualization-controller/pkg/common/testutil" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type VMIPTestArgs struct { + mode v1alpha2.VMOPRestoreMode + vmipType v1alpha2.VirtualMachineIPAddressType + + vmipExists bool + vmipUsedByDiffVM bool + staticIPUsedByDiffVM bool + + failValidation bool + failProcess bool + + shouldBeDeleted bool + shouldBeCreated bool +} + +var _ = Describe("VirtualMachineIPAddressRestorer", func() { + var ( + err error + ctx context.Context + + uid string + vm string + name string + namespace string + staticIP string + + intercept interceptor.Funcs + vmipDeleted bool + vmipCreated bool + + objects []client.Object + vmip v1alpha2.VirtualMachineIPAddress + handler *VirtualMachineIPHandler + fakeClient client.WithWatch + ) + + BeforeEach(func() { + ctx = context.Background() + name = "test-vmip" + namespace = "default" + staticIP = "10.0.0.1" + vm = "test-vm" + uid = "0000-1111-2222-4444" + + vmipDeleted = false + vmipCreated = false + + objects = []client.Object{} + + vmip = v1alpha2.VirtualMachineIPAddress{ + TypeMeta: metav1.TypeMeta{ + Kind: "VirtualMachineIPAddress", + APIVersion: v1alpha2.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Spec: v1alpha2.VirtualMachineIPAddressSpec{}, + Status: v1alpha2.VirtualMachineIPAddressStatus{ + VirtualMachine: vm, + Phase: v1alpha2.VirtualMachineIPAddressPhaseAttached, + }, + } + + intercept = interceptor.Funcs{ + Delete: func(_ context.Context, _ client.WithWatch, obj client.Object, _ ...client.DeleteOption) error { + if obj.GetName() == vmip.Name { + _, ok := obj.(*v1alpha2.VirtualMachineIPAddress) + Expect(ok).To(BeTrue()) + vmipDeleted = true + } + return nil + }, + Create: func(_ context.Context, _ client.WithWatch, obj client.Object, _ ...client.CreateOption) error { + if obj.GetName() == vmip.Name { + _, ok := obj.(*v1alpha2.VirtualMachineIPAddress) + Expect(ok).To(BeTrue()) + vmipCreated = true + } + + return nil + }, + } + }) + + DescribeTable("Checking VMOP events", + func(args VMIPTestArgs) { + switch args.vmipType { + case v1alpha2.VirtualMachineIPAddressTypeAuto: + vmip.Spec.Type = v1alpha2.VirtualMachineIPAddressTypeAuto + case v1alpha2.VirtualMachineIPAddressTypeStatic: + vmip.Spec.Type = v1alpha2.VirtualMachineIPAddressTypeStatic + vmip.Spec.StaticIP = staticIP + vmip.Status.Address = staticIP + } + + if args.vmipExists { + objects = append(objects, &vmip) + } + + if args.staticIPUsedByDiffVM { + objects = append(objects, &v1alpha2.VirtualMachineIPAddress{ + ObjectMeta: metav1.ObjectMeta{Name: name + "-2", Namespace: namespace}, + Spec: v1alpha2.VirtualMachineIPAddressSpec{ + StaticIP: staticIP, + }, + Status: v1alpha2.VirtualMachineIPAddressStatus{ + VirtualMachine: vm + "-2", + Phase: v1alpha2.VirtualMachineIPAddressPhaseAttached, + Address: staticIP, + }, + }) + } + + if args.vmipUsedByDiffVM { + vmip.Status.VirtualMachine = vm + "-2" + } + + fakeClient, err = testutil.NewFakeClientWithInterceptorWithObjects(intercept, objects...) + Expect(err).ToNot(HaveOccurred()) + Expect(fakeClient).ToNot(BeNil()) + + vmip.Status.VirtualMachine = vm + handler = NewVirtualMachineIPAddressHandler(fakeClient, &vmip, uid) + Expect(handler).ToNot(BeNil()) + + err = handler.ValidateRestore(ctx) + if args.failValidation { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).ToNot(HaveOccurred()) + } + + err = handler.ProcessRestore(ctx) + if args.failProcess { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).ToNot(HaveOccurred()) + } + + Expect(vmipDeleted).To(Equal(args.shouldBeDeleted)) + Expect(vmipCreated).To(Equal(args.shouldBeCreated)) + }, + Entry("vmip exists; vmip has Auto type; vmip used by different VM", VMIPTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + vmipExists: true, + vmipType: v1alpha2.VirtualMachineIPAddressTypeAuto, + vmipUsedByDiffVM: true, + staticIPUsedByDiffVM: false, + + failValidation: true, + failProcess: true, + + shouldBeDeleted: false, + shouldBeCreated: false, + }), + Entry("vmip exists; vmip has Auto type; vmip doesn't used by different VM", VMIPTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + vmipExists: true, + vmipType: v1alpha2.VirtualMachineIPAddressTypeAuto, + vmipUsedByDiffVM: false, + staticIPUsedByDiffVM: false, + + failValidation: false, + failProcess: false, + + shouldBeDeleted: false, + shouldBeCreated: false, + }), + Entry("vmip exists; vmip has StaticIP type; vmip used by different VM", VMIPTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + vmipExists: true, + vmipType: v1alpha2.VirtualMachineIPAddressTypeStatic, + vmipUsedByDiffVM: true, + staticIPUsedByDiffVM: false, + + failValidation: true, + failProcess: true, + + shouldBeDeleted: false, + shouldBeCreated: false, + }), + Entry("vmip exists; vmip has StaticIP type; staticIP used by different VM", VMIPTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + vmipExists: true, + vmipType: v1alpha2.VirtualMachineIPAddressTypeStatic, + vmipUsedByDiffVM: false, + staticIPUsedByDiffVM: true, + + failValidation: true, + failProcess: true, + + shouldBeDeleted: false, + shouldBeCreated: false, + }), + Entry("vmip exists; vmip has StaticIP type; vmip doesn't used by different VM", VMIPTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + vmipExists: true, + vmipType: v1alpha2.VirtualMachineIPAddressTypeStatic, + vmipUsedByDiffVM: false, + staticIPUsedByDiffVM: false, + + failValidation: false, + failProcess: false, + + shouldBeDeleted: false, + shouldBeCreated: false, + }), + + Entry("vmip doesn't exist; vmip has Auto type", VMIPTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + vmipExists: false, + vmipType: v1alpha2.VirtualMachineIPAddressTypeAuto, + vmipUsedByDiffVM: false, + staticIPUsedByDiffVM: false, + + failValidation: false, + failProcess: false, + + shouldBeDeleted: false, + shouldBeCreated: true, + }), + Entry("vmip doesn't exist; vmip has StaticIP type; staticIP used by different VM", VMIPTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + vmipExists: false, + vmipType: v1alpha2.VirtualMachineIPAddressTypeStatic, + vmipUsedByDiffVM: false, + staticIPUsedByDiffVM: true, + + failValidation: true, + failProcess: true, + + shouldBeDeleted: false, + shouldBeCreated: false, + }), + Entry("vmip doesn't exist; vmip has StaticIP type; staticIP doesn't used by different VM", VMIPTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + vmipExists: false, + vmipType: v1alpha2.VirtualMachineIPAddressTypeStatic, + vmipUsedByDiffVM: false, + staticIPUsedByDiffVM: false, + + failValidation: false, + failProcess: false, + + shouldBeDeleted: false, + shouldBeCreated: true, + }), + ) + + Describe("Override", func() { + var rules []v1alpha2.NameReplacement + + BeforeEach(func() { + rules = []v1alpha2.NameReplacement{ + { + From: v1alpha2.NameReplacementFrom{ + Kind: "VirtualMachineIPAddress", + Name: name, + }, + To: "new-vmip-name", + }, + } + + fakeClient, err = testutil.NewFakeClientWithInterceptorWithObjects(intercept) + Expect(err).ToNot(HaveOccurred()) + + handler = NewVirtualMachineIPAddressHandler(fakeClient, &vmip, uid) + }) + + It("should override VMIP name", func() { + handler.Override(rules) + Expect(handler.vmip.Name).To(Equal("new-vmip-name")) + }) + + It("should not override non-matching names", func() { + nonMatchingRules := []v1alpha2.NameReplacement{ + { + From: v1alpha2.NameReplacementFrom{ + Kind: "VirtualMachineIPAddress", + Name: "different-vmip", + }, + To: "should-not-apply", + }, + } + + originalName := handler.vmip.Name + handler.Override(nonMatchingRules) + Expect(handler.vmip.Name).To(Equal(originalName)) + }) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmmac_restorer.go b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmmac_restorer.go new file mode 100644 index 0000000000..4f01e994df --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmmac_restorer.go @@ -0,0 +1,164 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package restorer + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/controller/indexer" + "github.com/deckhouse/virtualization-controller/pkg/controller/service/restorer/common" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type VirtualMachineMACHandler struct { + vmmac *v1alpha2.VirtualMachineMACAddress + client client.Client + restoreUID string +} + +func NewVirtualMachineMACAddressHandler(client client.Client, vmmacTmpl *v1alpha2.VirtualMachineMACAddress, vmRestoreUID string) *VirtualMachineMACHandler { + if vmmacTmpl.Annotations != nil { + vmmacTmpl.Annotations[annotations.AnnVMOPRestore] = vmRestoreUID + } else { + vmmacTmpl.Annotations = make(map[string]string) + vmmacTmpl.Annotations[annotations.AnnVMOPRestore] = vmRestoreUID + } + return &VirtualMachineMACHandler{ + vmmac: &v1alpha2.VirtualMachineMACAddress{ + TypeMeta: metav1.TypeMeta{ + Kind: vmmacTmpl.Kind, + APIVersion: vmmacTmpl.APIVersion, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: vmmacTmpl.Name, + Namespace: vmmacTmpl.Namespace, + Annotations: vmmacTmpl.Annotations, + Labels: vmmacTmpl.Labels, + }, + Spec: vmmacTmpl.Spec, + Status: vmmacTmpl.Status, + }, + client: client, + restoreUID: vmRestoreUID, + } +} + +func (v *VirtualMachineMACHandler) Override(rules []v1alpha2.NameReplacement) { + v.vmmac.Name = common.OverrideName(v.vmmac.Kind, v.vmmac.Name, rules) +} + +func (v *VirtualMachineMACHandler) ValidateRestore(ctx context.Context) error { + vmMacKey := types.NamespacedName{Namespace: v.vmmac.Namespace, Name: v.vmmac.Name} + existed, err := object.FetchObject(ctx, vmMacKey, v.client, &v1alpha2.VirtualMachineMACAddress{}) + if err != nil { + return err + } + + if existed != nil { + if value, ok := existed.Annotations[annotations.AnnVMOPRestore]; ok && value == v.restoreUID { + return nil + } + } + + if v.vmmac.Spec.Address != "" { + var vmmacs v1alpha2.VirtualMachineMACAddressList + err = v.client.List(ctx, &vmmacs, &client.ListOptions{ + Namespace: v.vmmac.Namespace, + FieldSelector: fields.OneTermEqualSelector(indexer.IndexFieldVMMACByAddress, v.vmmac.Spec.Address), + }) + if err != nil { + return err + } + + for _, vmMac := range vmmacs.Items { + if vmMac.Status.VirtualMachine == v.vmmac.Status.VirtualMachine || vmMac.Name == v.vmmac.Name { + continue + } + + return fmt.Errorf( + "the MAC address %q cannot be used for the restore: it is taken by VirtualMachineMACAddress/%q and %w by the different virtual machine", + v.vmmac.Spec.Address, vmMac.Name, common.ErrAlreadyInUse, + ) + } + } + + if existed != nil { + if existed.Status.Phase == v1alpha2.VirtualMachineMACAddressPhaseAttached && existed.Status.VirtualMachine != v.vmmac.Status.VirtualMachine { + return fmt.Errorf("the virtual machine MAC address %q is %w and cannot be used for the restored virtual machine", vmMacKey.Name, common.ErrAlreadyInUse) + } + } + + return nil +} + +func (v *VirtualMachineMACHandler) ProcessRestore(ctx context.Context) error { + err := v.ValidateRestore(ctx) + if err != nil { + return err + } + + vmMacKey := types.NamespacedName{Namespace: v.vmmac.Namespace, Name: v.vmmac.Name} + existed, err := object.FetchObject(ctx, vmMacKey, v.client, &v1alpha2.VirtualMachineMACAddress{}) + if err != nil { + return err + } + + if existed != nil { + if value, ok := existed.Annotations[annotations.AnnVMOPRestore]; ok && value == v.restoreUID { + return nil + } + } else { + err = v.client.Create(ctx, v.vmmac) + if err != nil { + return fmt.Errorf("failed to create the `VirtualMachineMacAddress`: %w", err) + } + } + + return nil +} + +func (v *VirtualMachineMACHandler) Object() client.Object { + return &v1alpha2.VirtualMachineMACAddress{ + TypeMeta: metav1.TypeMeta{ + Kind: v.vmmac.Kind, + APIVersion: v.vmmac.APIVersion, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: v.vmmac.Name, + Namespace: v.vmmac.Namespace, + Annotations: v.vmmac.Annotations, + Labels: v.vmmac.Labels, + }, + Spec: v.vmmac.Spec, + } +} + +func (v *VirtualMachineMACHandler) ValidateClone(ctx context.Context) error { + return nil +} + +func (v *VirtualMachineMACHandler) ProcessClone(ctx context.Context) error { + return nil +} diff --git a/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmmac_restorer_test.go b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmmac_restorer_test.go new file mode 100644 index 0000000000..ef141c4c9f --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmmac_restorer_test.go @@ -0,0 +1,317 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package restorer + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + "github.com/deckhouse/virtualization-controller/pkg/common/testutil" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type VMMACTestArgs struct { + mode v1alpha2.VMOPRestoreMode + + vmmacExists bool + vmmacUsedByDiffVM bool + addressUsedByDiffVM bool + hasAddress bool + + failValidation bool + failProcess bool + + shouldBeDeleted bool + shouldBeCreated bool +} + +var _ = Describe("VirtualMachineMACAddressRestorer", func() { + var ( + err error + ctx context.Context + + uid string + vm string + name string + namespace string + macAddress string + + intercept interceptor.Funcs + vmmacDeleted bool + vmmacCreated bool + + objects []client.Object + vmmac v1alpha2.VirtualMachineMACAddress + handler *VirtualMachineMACHandler + fakeClient client.WithWatch + ) + + BeforeEach(func() { + ctx = context.Background() + name = "test-vmmac" + namespace = "default" + macAddress = "02:00:00:00:00:01" + vm = "test-vm" + uid = "0000-1111-2222-4444" + + vmmacDeleted = false + vmmacCreated = false + + objects = []client.Object{} + + vmmac = v1alpha2.VirtualMachineMACAddress{ + TypeMeta: metav1.TypeMeta{ + Kind: "VirtualMachineMACAddress", + APIVersion: v1alpha2.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Spec: v1alpha2.VirtualMachineMACAddressSpec{}, + Status: v1alpha2.VirtualMachineMACAddressStatus{ + VirtualMachine: vm, + Phase: v1alpha2.VirtualMachineMACAddressPhaseAttached, + }, + } + + intercept = interceptor.Funcs{ + Delete: func(_ context.Context, _ client.WithWatch, obj client.Object, _ ...client.DeleteOption) error { + if obj.GetName() == vmmac.Name { + _, ok := obj.(*v1alpha2.VirtualMachineMACAddress) + Expect(ok).To(BeTrue()) + vmmacDeleted = true + } + return nil + }, + Create: func(_ context.Context, _ client.WithWatch, obj client.Object, _ ...client.CreateOption) error { + if obj.GetName() == vmmac.Name { + _, ok := obj.(*v1alpha2.VirtualMachineMACAddress) + Expect(ok).To(BeTrue()) + vmmacCreated = true + } + + return nil + }, + } + }) + + DescribeTable("Checking VMOP events", + func(args VMMACTestArgs) { + if args.hasAddress { + vmmac.Spec.Address = macAddress + vmmac.Status.Address = macAddress + } + + if args.vmmacExists { + objects = append(objects, &vmmac) + } + + if args.addressUsedByDiffVM { + objects = append(objects, &v1alpha2.VirtualMachineMACAddress{ + ObjectMeta: metav1.ObjectMeta{Name: name + "-2", Namespace: namespace}, + Spec: v1alpha2.VirtualMachineMACAddressSpec{ + Address: macAddress, + }, + Status: v1alpha2.VirtualMachineMACAddressStatus{ + VirtualMachine: vm + "-2", + Phase: v1alpha2.VirtualMachineMACAddressPhaseAttached, + Address: macAddress, + }, + }) + } + + if args.vmmacUsedByDiffVM { + vmmac.Status.VirtualMachine = vm + "-2" + } + + fakeClient, err = testutil.NewFakeClientWithInterceptorWithObjects(intercept, objects...) + Expect(err).ToNot(HaveOccurred()) + Expect(fakeClient).ToNot(BeNil()) + + vmmac.Status.VirtualMachine = vm + handler = NewVirtualMachineMACAddressHandler(fakeClient, &vmmac, uid) + Expect(handler).ToNot(BeNil()) + + err = handler.ValidateRestore(ctx) + if args.failValidation { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).ToNot(HaveOccurred()) + } + + err = handler.ProcessRestore(ctx) + if args.failProcess { + Expect(err).To(HaveOccurred()) + } else { + Expect(err).ToNot(HaveOccurred()) + } + + Expect(vmmacDeleted).To(Equal(args.shouldBeDeleted)) + Expect(vmmacCreated).To(Equal(args.shouldBeCreated)) + }, + Entry("vmmac exists; vmmac has auto address; vmmac used by different VM", VMMACTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + vmmacExists: true, + hasAddress: false, + vmmacUsedByDiffVM: true, + addressUsedByDiffVM: false, + + failValidation: true, + failProcess: true, + + shouldBeDeleted: false, + shouldBeCreated: false, + }), + Entry("vmmac exists; vmmac has auto address; vmmac doesn't used by different VM", VMMACTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + vmmacExists: true, + hasAddress: false, + vmmacUsedByDiffVM: false, + addressUsedByDiffVM: false, + + failValidation: false, + failProcess: false, + + shouldBeDeleted: false, + shouldBeCreated: false, + }), + Entry("vmmac exists; vmmac has address; vmmac used by different VM", VMMACTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + vmmacExists: true, + hasAddress: true, + vmmacUsedByDiffVM: true, + addressUsedByDiffVM: false, + + failValidation: true, + failProcess: true, + + shouldBeDeleted: false, + shouldBeCreated: false, + }), + Entry("vmmac exists; vmmac has address; address used by different VM", VMMACTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + vmmacExists: true, + hasAddress: true, + vmmacUsedByDiffVM: false, + addressUsedByDiffVM: true, + + failValidation: true, + failProcess: true, + + shouldBeDeleted: false, + shouldBeCreated: false, + }), + Entry("vmmac exists; vmmac has address; vmmac doesn't used by different VM", VMMACTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + vmmacExists: true, + hasAddress: true, + vmmacUsedByDiffVM: false, + addressUsedByDiffVM: false, + + failValidation: false, + failProcess: false, + + shouldBeDeleted: false, + shouldBeCreated: false, + }), + + Entry("vmmac doesn't exist; vmmac has auto address", VMMACTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + vmmacExists: false, + hasAddress: false, + vmmacUsedByDiffVM: false, + addressUsedByDiffVM: false, + + failValidation: false, + failProcess: false, + + shouldBeDeleted: false, + shouldBeCreated: true, + }), + Entry("vmmac doesn't exist; vmmac has address; address used by different VM", VMMACTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + vmmacExists: false, + hasAddress: true, + vmmacUsedByDiffVM: false, + addressUsedByDiffVM: true, + + failValidation: true, + failProcess: true, + + shouldBeDeleted: false, + shouldBeCreated: false, + }), + Entry("vmmac doesn't exist; vmmac has address; address doesn't used by different VM", VMMACTestArgs{ + mode: v1alpha2.VMOPRestoreModeStrict, + vmmacExists: false, + hasAddress: true, + vmmacUsedByDiffVM: false, + addressUsedByDiffVM: false, + + failValidation: false, + failProcess: false, + + shouldBeDeleted: false, + shouldBeCreated: true, + }), + ) + + Describe("Override", func() { + var rules []v1alpha2.NameReplacement + + BeforeEach(func() { + rules = []v1alpha2.NameReplacement{ + { + From: v1alpha2.NameReplacementFrom{ + Kind: "VirtualMachineMACAddress", + Name: name, + }, + To: "new-vmip-name", + }, + } + + fakeClient, err = testutil.NewFakeClientWithInterceptorWithObjects(intercept) + Expect(err).ToNot(HaveOccurred()) + + handler = NewVirtualMachineMACAddressHandler(fakeClient, &vmmac, uid) + }) + + It("should override VMMAC name", func() { + handler.Override(rules) + Expect(handler.vmmac.Name).To(Equal("new-vmip-name")) + }) + + It("should not override non-matching names", func() { + nonMatchingRules := []v1alpha2.NameReplacement{ + { + From: v1alpha2.NameReplacementFrom{ + Kind: "VirtualMachineMACAddress", + Name: "different-vmmac", + }, + To: "should-not-apply", + }, + } + + originalName := handler.vmmac.Name + handler.Override(nonMatchingRules) + Expect(handler.vmmac.Name).To(Equal(originalName)) + }) + }) +}) diff --git a/images/virtualization-artifact/pkg/controller/service/restorer/restorer.go b/images/virtualization-artifact/pkg/controller/service/restorer/secret_restorer.go similarity index 79% rename from images/virtualization-artifact/pkg/controller/service/restorer/restorer.go rename to images/virtualization-artifact/pkg/controller/service/restorer/secret_restorer.go index 95b1a29e21..38d41ae158 100644 --- a/images/virtualization-artifact/pkg/controller/service/restorer/restorer.go +++ b/images/virtualization-artifact/pkg/controller/service/restorer/secret_restorer.go @@ -30,7 +30,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/common/object" "github.com/deckhouse/virtualization-controller/pkg/controller/service" - virtv2 "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2" ) type SecretRestorer struct { @@ -43,7 +43,7 @@ func NewSecretRestorer(client client.Client) *SecretRestorer { } } -func (r SecretRestorer) Store(ctx context.Context, vm *virtv2.VirtualMachine, vmSnapshot *virtv2.VirtualMachineSnapshot) (*corev1.Secret, error) { +func (r SecretRestorer) Store(ctx context.Context, vm *v1alpha2.VirtualMachine, vmSnapshot *v1alpha2.VirtualMachineSnapshot) (*corev1.Secret, error) { secret := corev1.Secret{ TypeMeta: metav1.TypeMeta{ Kind: "Secret", @@ -93,31 +93,31 @@ func (r SecretRestorer) Store(ctx context.Context, vm *virtv2.VirtualMachine, vm return &secret, nil } -func (r SecretRestorer) RestoreVirtualMachine(_ context.Context, secret *corev1.Secret) (*virtv2.VirtualMachine, error) { - return get[*virtv2.VirtualMachine](secret, virtualMachineKey) +func (r SecretRestorer) RestoreVirtualMachine(_ context.Context, secret *corev1.Secret) (*v1alpha2.VirtualMachine, error) { + return get[*v1alpha2.VirtualMachine](secret, virtualMachineKey) } func (r SecretRestorer) RestoreProvisioner(_ context.Context, secret *corev1.Secret) (*corev1.Secret, error) { return get[*corev1.Secret](secret, provisionerKey) } -func (r SecretRestorer) RestoreVirtualMachineIPAddress(_ context.Context, secret *corev1.Secret) (*virtv2.VirtualMachineIPAddress, error) { - return get[*virtv2.VirtualMachineIPAddress](secret, virtualMachineIPAddressKey) +func (r SecretRestorer) RestoreVirtualMachineIPAddress(_ context.Context, secret *corev1.Secret) (*v1alpha2.VirtualMachineIPAddress, error) { + return get[*v1alpha2.VirtualMachineIPAddress](secret, virtualMachineIPAddressKey) } -func (r SecretRestorer) RestoreVirtualMachineMACAddresses(_ context.Context, secret *corev1.Secret) ([]*virtv2.VirtualMachineMACAddress, error) { - return get[[]*virtv2.VirtualMachineMACAddress](secret, virtualMachineMACAddressesKey) +func (r SecretRestorer) RestoreVirtualMachineMACAddresses(_ context.Context, secret *corev1.Secret) ([]*v1alpha2.VirtualMachineMACAddress, error) { + return get[[]*v1alpha2.VirtualMachineMACAddress](secret, virtualMachineMACAddressesKey) } func (r SecretRestorer) RestoreMACAddressOrder(_ context.Context, secret *corev1.Secret) ([]string, error) { - vm, err := get[*virtv2.VirtualMachine](secret, virtualMachineKey) + vm, err := get[*v1alpha2.VirtualMachine](secret, virtualMachineKey) if err != nil { return nil, err } var macAddressOrder []string for _, ns := range vm.Status.Networks { - if ns.Type == virtv2.NetworksTypeMain { + if ns.Type == v1alpha2.NetworksTypeMain { continue } macAddressOrder = append(macAddressOrder, ns.MAC) @@ -125,11 +125,11 @@ func (r SecretRestorer) RestoreMACAddressOrder(_ context.Context, secret *corev1 return macAddressOrder, nil } -func (r SecretRestorer) RestoreVirtualMachineBlockDeviceAttachments(_ context.Context, secret *corev1.Secret) ([]*virtv2.VirtualMachineBlockDeviceAttachment, error) { - return get[[]*virtv2.VirtualMachineBlockDeviceAttachment](secret, virtualMachineBlockDeviceAttachmentKey) +func (r SecretRestorer) RestoreVirtualMachineBlockDeviceAttachments(_ context.Context, secret *corev1.Secret) ([]*v1alpha2.VirtualMachineBlockDeviceAttachment, error) { + return get[[]*v1alpha2.VirtualMachineBlockDeviceAttachment](secret, virtualMachineBlockDeviceAttachmentKey) } -func (r SecretRestorer) setVirtualMachine(secret *corev1.Secret, vm *virtv2.VirtualMachine) error { +func (r SecretRestorer) setVirtualMachine(secret *corev1.Secret, vm *v1alpha2.VirtualMachine) error { JSON, err := json.Marshal(vm) if err != nil { return err @@ -139,8 +139,8 @@ func (r SecretRestorer) setVirtualMachine(secret *corev1.Secret, vm *virtv2.Virt return nil } -func (r SecretRestorer) setVirtualMachineBlockDeviceAttachments(ctx context.Context, secret *corev1.Secret, vm *virtv2.VirtualMachine) error { - var vmbdas []*virtv2.VirtualMachineBlockDeviceAttachment +func (r SecretRestorer) setVirtualMachineBlockDeviceAttachments(ctx context.Context, secret *corev1.Secret, vm *v1alpha2.VirtualMachine) error { + var vmbdas []*v1alpha2.VirtualMachineBlockDeviceAttachment for _, bdr := range vm.Status.BlockDeviceRefs { if !bdr.Hotplugged { @@ -150,7 +150,7 @@ func (r SecretRestorer) setVirtualMachineBlockDeviceAttachments(ctx context.Cont vmbda, err := object.FetchObject(ctx, types.NamespacedName{ Name: bdr.VirtualMachineBlockDeviceAttachmentName, Namespace: vm.Namespace, - }, r.client, &virtv2.VirtualMachineBlockDeviceAttachment{}) + }, r.client, &v1alpha2.VirtualMachineBlockDeviceAttachment{}) if err != nil { return err } @@ -175,11 +175,11 @@ func (r SecretRestorer) setVirtualMachineBlockDeviceAttachments(ctx context.Cont return nil } -func (r SecretRestorer) setVirtualMachineIPAddress(ctx context.Context, secret *corev1.Secret, vm *virtv2.VirtualMachine, keepIPAddress virtv2.KeepIPAddress) error { +func (r SecretRestorer) setVirtualMachineIPAddress(ctx context.Context, secret *corev1.Secret, vm *v1alpha2.VirtualMachine, keepIPAddress v1alpha2.KeepIPAddress) error { vmip, err := object.FetchObject(ctx, types.NamespacedName{ Namespace: vm.Namespace, Name: vm.Status.VirtualMachineIPAddress, - }, r.client, &virtv2.VirtualMachineIPAddress{}) + }, r.client, &v1alpha2.VirtualMachineIPAddress{}) if err != nil { return err } @@ -210,29 +210,29 @@ func (r SecretRestorer) setVirtualMachineIPAddress(ctx context.Context, secret * */ switch keepIPAddress { - case virtv2.KeepIPAddressNever: + case v1alpha2.KeepIPAddressNever: switch vmip.Spec.Type { - case virtv2.VirtualMachineIPAddressTypeStatic: + case v1alpha2.VirtualMachineIPAddressTypeStatic: if vm.Spec.VirtualMachineIPAddress == "" { return errors.New("not possible to use static ip address with omitted .spec.VirtualMachineIPAddress, please report a bug") } - case virtv2.VirtualMachineIPAddressTypeAuto: + case v1alpha2.VirtualMachineIPAddressTypeAuto: if vm.Spec.VirtualMachineIPAddress == "" { return nil } } // Put to secret. - case virtv2.KeepIPAddressAlways: + case v1alpha2.KeepIPAddressAlways: switch vmip.Spec.Type { - case virtv2.VirtualMachineIPAddressTypeStatic: + case v1alpha2.VirtualMachineIPAddressTypeStatic: if vm.Spec.VirtualMachineIPAddress == "" { return errors.New("not possible to use static ip address with omitted .spec.VirtualMachineIPAddress, please report a bug") } // Put to secret. - case virtv2.VirtualMachineIPAddressTypeAuto: - vmip.Spec.Type = virtv2.VirtualMachineIPAddressTypeStatic + case v1alpha2.VirtualMachineIPAddressTypeAuto: + vmip.Spec.Type = v1alpha2.VirtualMachineIPAddressTypeStatic vmip.Spec.StaticIP = vmip.Status.Address // Put to secret. } @@ -247,17 +247,17 @@ func (r SecretRestorer) setVirtualMachineIPAddress(ctx context.Context, secret * return nil } -func (r SecretRestorer) setVirtualMachineMACAddresses(ctx context.Context, secret *corev1.Secret, vm *virtv2.VirtualMachine) error { - var vmmacs []virtv2.VirtualMachineMACAddress +func (r SecretRestorer) setVirtualMachineMACAddresses(ctx context.Context, secret *corev1.Secret, vm *v1alpha2.VirtualMachine) error { + var vmmacs []v1alpha2.VirtualMachineMACAddress for _, ns := range vm.Status.Networks { - if ns.Type == virtv2.NetworksTypeMain { + if ns.Type == v1alpha2.NetworksTypeMain { continue } vmmac, err := object.FetchObject(ctx, types.NamespacedName{ Namespace: vm.Namespace, Name: ns.VirtualMachineMACAddressName, - }, r.client, &virtv2.VirtualMachineMACAddress{}) + }, r.client, &v1alpha2.VirtualMachineMACAddress{}) if err != nil { return err } @@ -283,7 +283,7 @@ func (r SecretRestorer) setVirtualMachineMACAddresses(ctx context.Context, secre return nil } -func (r SecretRestorer) setProvisioning(ctx context.Context, secret *corev1.Secret, vm *virtv2.VirtualMachine) error { +func (r SecretRestorer) setProvisioning(ctx context.Context, secret *corev1.Secret, vm *v1alpha2.VirtualMachine) error { var secretName string if vm.Spec.Provisioning == nil { @@ -291,24 +291,24 @@ func (r SecretRestorer) setProvisioning(ctx context.Context, secret *corev1.Secr } switch vm.Spec.Provisioning.Type { - case virtv2.ProvisioningTypeSysprepRef: + case v1alpha2.ProvisioningTypeSysprepRef: if vm.Spec.Provisioning.SysprepRef == nil { return errors.New("the virtual machine sysprep ref provisioning is nil") } switch vm.Spec.Provisioning.SysprepRef.Kind { - case virtv2.SysprepRefKindSecret: + case v1alpha2.SysprepRefKindSecret: secretName = vm.Spec.Provisioning.SysprepRef.Name default: return fmt.Errorf("unknown sysprep ref kind %s", vm.Spec.Provisioning.SysprepRef.Kind) } - case virtv2.ProvisioningTypeUserDataRef: + case v1alpha2.ProvisioningTypeUserDataRef: if vm.Spec.Provisioning.UserDataRef == nil { return errors.New("the virtual machine user data ref provisioning is nil") } switch vm.Spec.Provisioning.UserDataRef.Kind { - case virtv2.UserDataRefKindSecret: + case v1alpha2.UserDataRefKindSecret: secretName = vm.Spec.Provisioning.UserDataRef.Name default: return fmt.Errorf("unknown user data ref kind %s", vm.Spec.Provisioning.UserDataRef.Kind) diff --git a/images/virtualization-artifact/pkg/controller/service/restorer/snapshot_resources.go b/images/virtualization-artifact/pkg/controller/service/restorer/snapshot_resources.go new file mode 100644 index 0000000000..79e411c345 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/service/restorer/snapshot_resources.go @@ -0,0 +1,317 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package restorer + +import ( + "context" + "errors" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/controller/service/restorer/common" + restorer "github.com/deckhouse/virtualization-controller/pkg/controller/service/restorer/restorers" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type SnapshotResources struct { + uuid string + client client.Client + restorer *SecretRestorer + restorerSecret *corev1.Secret + vmSnapshot *v1alpha2.VirtualMachineSnapshot + objectHandlers []ObjectHandler + statuses []v1alpha2.VirtualMachineOperationResource + mode v1alpha2.VMOPRestoreMode + kind v1alpha2.VMOPType +} + +func NewSnapshotResources(client client.Client, kind v1alpha2.VMOPType, mode v1alpha2.VMOPRestoreMode, restorerSecret *corev1.Secret, vmSnapshot *v1alpha2.VirtualMachineSnapshot, uuid string) SnapshotResources { + return SnapshotResources{ + mode: mode, + kind: kind, + uuid: uuid, + client: client, + restorer: NewSecretRestorer(client), + vmSnapshot: vmSnapshot, + restorerSecret: restorerSecret, + } +} + +func (r *SnapshotResources) Prepare(ctx context.Context) error { + provisioner, err := r.restorer.RestoreProvisioner(ctx, r.restorerSecret) + if err != nil { + return err + } + + vm, err := r.restorer.RestoreVirtualMachine(ctx, r.restorerSecret) + if err != nil { + return err + } + + vmip, err := r.restorer.RestoreVirtualMachineIPAddress(ctx, r.restorerSecret) + if err != nil { + return err + } + + if vmip != nil && r.kind == v1alpha2.VMOPTypeRestore { + vm.Spec.VirtualMachineIPAddress = vmip.Name + } else { + vm.Spec.VirtualMachineIPAddress = "" + } + + vmmacs, err := r.restorer.RestoreVirtualMachineMACAddresses(ctx, r.restorerSecret) + if err != nil { + return err + } + + macAddressOrder, err := r.restorer.RestoreMACAddressOrder(ctx, r.restorerSecret) + if err != nil { + return err + } + + vds, err := getVirtualDisks(ctx, r.client, r.vmSnapshot) + if err != nil { + return err + } + + vmbdas, err := r.restorer.RestoreVirtualMachineBlockDeviceAttachments(ctx, r.restorerSecret) + if err != nil { + return err + } + + if len(vmmacs) > 0 && r.kind == v1alpha2.VMOPTypeRestore { + macAddressNamesByAddress := make(map[string]string) + for _, vmmac := range vmmacs { + r.objectHandlers = append(r.objectHandlers, restorer.NewVirtualMachineMACAddressHandler(r.client, vmmac, r.uuid)) + macAddressNamesByAddress[vmmac.Status.Address] = vmmac.Name + } + + for i := range vm.Spec.Networks { + ns := &vm.Spec.Networks[i] + if ns.Type == v1alpha2.NetworksTypeMain { + continue + } + + ns.VirtualMachineMACAddressName = macAddressNamesByAddress[macAddressOrder[i-1]] + } + } else { + for i := range vm.Spec.Networks { + vm.Spec.Networks[i].VirtualMachineMACAddressName = "" + } + } + + if vmip != nil { + r.objectHandlers = append(r.objectHandlers, restorer.NewVirtualMachineIPAddressHandler(r.client, vmip, r.uuid)) + } + + for _, vd := range vds { + r.objectHandlers = append(r.objectHandlers, restorer.NewVirtualDiskHandler(r.client, *vd, r.uuid)) + } + + for _, vmbda := range vmbdas { + r.objectHandlers = append(r.objectHandlers, restorer.NewVMBlockDeviceAttachmentHandler(r.client, *vmbda, r.uuid)) + } + + if provisioner != nil { + r.objectHandlers = append(r.objectHandlers, restorer.NewProvisionerHandler(r.client, *provisioner, r.uuid)) + } + + r.objectHandlers = append(r.objectHandlers, restorer.NewVirtualMachineHandler(r.client, *vm, string(r.vmSnapshot.UID), r.mode)) + + return nil +} + +func (r *SnapshotResources) Validate(ctx context.Context) ([]v1alpha2.VirtualMachineOperationResource, error) { + var hasErrors bool + + r.statuses = make([]v1alpha2.VirtualMachineOperationResource, 0, len(r.objectHandlers)) + + for _, ov := range r.objectHandlers { + obj := ov.Object() + + status := v1alpha2.VirtualMachineOperationResource{ + APIVersion: obj.GetObjectKind().GroupVersionKind().Version, + Kind: obj.GetObjectKind().GroupVersionKind().Kind, + Name: obj.GetName(), + Status: v1alpha2.VMOPResourceStatusCompleted, + Message: obj.GetName() + " is valid for restore", + } + + if r.kind == v1alpha2.VMOPTypeRestore { + err := ov.ValidateRestore(ctx) + switch { + case err == nil: + case shouldIgnoreError(r.mode, err): + default: + hasErrors = true + status.Status = v1alpha2.VMOPResourceStatusFailed + status.Message = err.Error() + } + } + r.statuses = append(r.statuses, status) + } + + if hasErrors { + return r.statuses, errors.New("fail to validate the resources: check the status") + } + + return r.statuses, nil +} + +func (r *SnapshotResources) Process(ctx context.Context) ([]v1alpha2.VirtualMachineOperationResource, error) { + var hasErrors bool + + r.statuses = make([]v1alpha2.VirtualMachineOperationResource, 0, len(r.objectHandlers)) + + if r.mode == v1alpha2.VMOPRestoreModeDryRun { + return r.statuses, errors.New("cannot Process with DryRun operation") + } + + for _, ov := range r.objectHandlers { + obj := ov.Object() + + status := v1alpha2.VirtualMachineOperationResource{ + APIVersion: obj.GetObjectKind().GroupVersionKind().Version, + Kind: obj.GetObjectKind().GroupVersionKind().Kind, + Name: obj.GetName(), + Status: v1alpha2.VMOPResourceStatusCompleted, + Message: "Successfully processed", + } + + if r.kind == v1alpha2.VMOPTypeRestore { + err := ov.ProcessRestore(ctx) + switch { + case err == nil: + case shouldIgnoreError(r.mode, err): + case isRetryError(err): + status.Status = v1alpha2.VMOPResourceStatusInProgress + status.Message = err.Error() + default: + hasErrors = true + status.Status = v1alpha2.VMOPResourceStatusFailed + status.Message = err.Error() + } + } + r.statuses = append(r.statuses, status) + } + + if hasErrors { + return r.statuses, errors.New("fail to process the resources: check the status") + } + + return r.statuses, nil +} + +var DryRunIgnoredErrors = []error{ + common.ErrVMMaintenanceCondNotFound, + common.ErrVMNotInMaintenance, +} + +var BestEffortIgnoredErrors = []error{ + common.ErrVirtualImageNotFound, + common.ErrClusterVirtualImageNotFound, + common.ErrSecretHasDifferentData, +} + +var RetryErrors = []error{ + common.ErrRestoring, + common.ErrUpdating, + common.ErrWaitingForDeletion, +} + +func shouldIgnoreError(mode v1alpha2.VMOPRestoreMode, err error) bool { + switch mode { + case v1alpha2.VMOPRestoreModeDryRun: + for _, e := range DryRunIgnoredErrors { + if errors.Is(err, e) { + return true + } + } + case v1alpha2.VMOPRestoreModeBestEffort: + for _, e := range BestEffortIgnoredErrors { + if errors.Is(err, e) { + return true + } + } + } + + return false +} + +func isRetryError(err error) bool { + if apierrors.IsConflict(err) { + return true + } + + for _, e := range RetryErrors { + if errors.Is(err, e) { + return true + } + } + return false +} + +func getVirtualDisks(ctx context.Context, client client.Client, vmSnapshot *v1alpha2.VirtualMachineSnapshot) ([]*v1alpha2.VirtualDisk, error) { + vds := make([]*v1alpha2.VirtualDisk, 0, len(vmSnapshot.Status.VirtualDiskSnapshotNames)) + + for _, vdSnapshotName := range vmSnapshot.Status.VirtualDiskSnapshotNames { + vdSnapshotKey := types.NamespacedName{Namespace: vmSnapshot.Namespace, Name: vdSnapshotName} + vdSnapshot, err := object.FetchObject(ctx, vdSnapshotKey, client, &v1alpha2.VirtualDiskSnapshot{}) + if err != nil { + return nil, fmt.Errorf("failed to fetch the virtual disk snapshot %q: %w", vdSnapshotKey.Name, err) + } + + if vdSnapshot == nil { + return nil, fmt.Errorf("the virtual disk snapshot %q %w", vdSnapshotName, common.ErrVirtualDiskSnapshotNotFound) + } + + vd := v1alpha2.VirtualDisk{ + TypeMeta: metav1.TypeMeta{ + Kind: v1alpha2.VirtualDiskKind, + APIVersion: v1alpha2.Version, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: vdSnapshot.Spec.VirtualDiskName, + Namespace: vdSnapshot.Namespace, + }, + Spec: v1alpha2.VirtualDiskSpec{ + DataSource: &v1alpha2.VirtualDiskDataSource{ + Type: v1alpha2.DataSourceTypeObjectRef, + ObjectRef: &v1alpha2.VirtualDiskObjectRef{ + Kind: v1alpha2.VirtualDiskObjectRefKindVirtualDiskSnapshot, + Name: vdSnapshot.Name, + }, + }, + }, + Status: v1alpha2.VirtualDiskStatus{ + AttachedToVirtualMachines: []v1alpha2.AttachedVirtualMachine{ + {Name: vmSnapshot.Spec.VirtualMachineName, Mounted: true}, + }, + }, + } + + vds = append(vds, &vd) + } + + return vds, nil +} diff --git a/images/virtualization-artifact/pkg/controller/service/stat_service.go b/images/virtualization-artifact/pkg/controller/service/stat_service.go index ec01a841e8..2f5be8267a 100644 --- a/images/virtualization-artifact/pkg/controller/service/stat_service.go +++ b/images/virtualization-artifact/pkg/controller/service/stat_service.go @@ -248,7 +248,9 @@ func (s StatService) IsUploaderReady(pod *corev1.Pod, svc *corev1.Service, ing * return false } - return podutil.IsPodRunning(pod) && podutil.IsPodStarted(pod) && ing.Annotations[annotations.AnnUploadURL] != "" + ingressIsOK := ing.Annotations[annotations.AnnUploadPath] != "" || ing.Annotations[annotations.AnnUploadURLDeprecated] != "" + + return podutil.IsPodRunning(pod) && podutil.IsPodStarted(pod) && ingressIsOK } func (s StatService) IsUploadStarted(ownerUID types.UID, pod *corev1.Pod) bool { diff --git a/images/virtualization-artifact/pkg/controller/service/uploader_service.go b/images/virtualization-artifact/pkg/controller/service/uploader_service.go index 69ded95ba4..9e20763fbc 100644 --- a/images/virtualization-artifact/pkg/controller/service/uploader_service.go +++ b/images/virtualization-artifact/pkg/controller/service/uploader_service.go @@ -245,6 +245,10 @@ func (s UploaderService) GetIngress(ctx context.Context, sup *supplements.Genera func (s UploaderService) GetExternalURL(ctx context.Context, ing *netv1.Ingress) string { url := ing.Annotations[annotations.AnnUploadURL] + if url == "" { + // Fallback to deprecated annotation. + url = ing.Annotations[annotations.AnnUploadURLDeprecated] + } if url == "" { logger.FromContext(ctx).Error("unexpected empty upload url, please report a bug") return "" diff --git a/images/virtualization-artifact/pkg/controller/uploader/settings.go b/images/virtualization-artifact/pkg/controller/uploader/settings.go index 33f86407f5..f32e1ac425 100644 --- a/images/virtualization-artifact/pkg/controller/uploader/settings.go +++ b/images/virtualization-artifact/pkg/controller/uploader/settings.go @@ -25,7 +25,6 @@ import ( // Fields from this struct are passed via environment variables. type Settings struct { Verbose string - SecretExtraHeaders []string DestinationEndpoint string DestinationInsecureTLS string DestinationAuthSecret string diff --git a/images/virtualization-artifact/pkg/controller/uploader/uploader_ingress.go b/images/virtualization-artifact/pkg/controller/uploader/uploader_ingress.go index 67cd42e54a..686ba23244 100644 --- a/images/virtualization-artifact/pkg/controller/uploader/uploader_ingress.go +++ b/images/virtualization-artifact/pkg/controller/uploader/uploader_ingress.go @@ -64,15 +64,24 @@ func (i *Ingress) Create(ctx context.Context, client client.Client) (*netv1.Ingr return ing, nil } +// makeSpec fills Ingress structure with uploader settings. +// +// Notes: +// - AnnUploadURL annotation is used by VI/CVI handlers to show URL for external upload. +// - AnnUploadPath annotation is a workaround to support clusters without publicDomainTemplate. func (i *Ingress) makeSpec() *netv1.Ingress { pathTypeExact := netv1.PathTypeExact path := i.generatePath() tlsEnabled := i.Settings.TLSSecretName != "" - var uploadURL string - if tlsEnabled { - uploadURL = fmt.Sprintf("https://%s%s", i.Settings.Host, path) - } else { - uploadURL = fmt.Sprintf("http://%s%s", i.Settings.Host, path) + uploadHost := "dvcr-upload" + uploadURL := "" + if i.Settings.Host != "" { + uploadHost = i.Settings.Host + if tlsEnabled { + uploadURL = fmt.Sprintf("https://%s%s", i.Settings.Host, path) + } else { + uploadURL = fmt.Sprintf("http://%s%s", i.Settings.Host, path) + } } ingress := &netv1.Ingress{ TypeMeta: metav1.TypeMeta{ @@ -84,6 +93,7 @@ func (i *Ingress) makeSpec() *netv1.Ingress { Namespace: i.Settings.Namespace, Annotations: map[string]string{ annotations.AnnUploadURL: uploadURL, + annotations.AnnUploadPath: path, "nginx.ingress.kubernetes.io/proxy-body-size": "0", "nginx.ingress.kubernetes.io/proxy-request-buffering": "off", "nginx.ingress.kubernetes.io/proxy-buffering": "off", @@ -97,7 +107,7 @@ func (i *Ingress) makeSpec() *netv1.Ingress { IngressClassName: i.Settings.ClassName, Rules: []netv1.IngressRule{ { - Host: i.Settings.Host, + Host: uploadHost, IngressRuleValue: netv1.IngressRuleValue{ HTTP: &netv1.HTTPIngressRuleValue{ Paths: []netv1.HTTPIngressPath{ @@ -124,7 +134,7 @@ func (i *Ingress) makeSpec() *netv1.Ingress { if tlsEnabled { ingress.Spec.TLS = []netv1.IngressTLS{ { - Hosts: []string{i.Settings.Host}, + Hosts: []string{uploadHost}, SecretName: i.Settings.TLSSecretName, }, } diff --git a/images/virtualization-artifact/pkg/controller/uploader/uploader_pod.go b/images/virtualization-artifact/pkg/controller/uploader/uploader_pod.go index 9bc98f11ab..0472ee3839 100644 --- a/images/virtualization-artifact/pkg/controller/uploader/uploader_pod.go +++ b/images/virtualization-artifact/pkg/controller/uploader/uploader_pod.go @@ -18,8 +18,6 @@ package uploader import ( "context" - "fmt" - "path" corev1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -36,9 +34,6 @@ import ( ) const ( - // secretExtraHeadersVolumeName is the format string that specifies where extra HTTP header secrets will be mounted - secretExtraHeadersVolumeName = "import-extra-headers-vol-%d" - // destinationAuthVol is the name of the volume containing DVCR docker auth config. destinationAuthVol = "dvcr-secret-vol" ) @@ -197,21 +192,6 @@ func (p *Pod) addVolumes(pod *corev1.Pod, container *corev1.Container) { }, ) } - - // Mount extra headers Secrets. - for index, header := range p.Settings.SecretExtraHeaders { - volName := fmt.Sprintf(secretExtraHeadersVolumeName, index) - mountPath := path.Join(common.UploaderSecretExtraHeadersDir, fmt.Sprint(index)) - envName := fmt.Sprintf("%s%d", common.UploaderExtraHeader, index) - podutil.AddVolume(pod, container, - podutil.CreateSecretVolume(volName, header), - podutil.CreateVolumeMount(volName, mountPath), - corev1.EnvVar{ - Name: envName, - Value: header, - }, - ) - } } type PodNamer interface { diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/maintenance.go b/images/virtualization-artifact/pkg/controller/vm/internal/maintenance.go index ab1a7d4d85..1ace9780f5 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/maintenance.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/maintenance.go @@ -29,6 +29,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" "github.com/deckhouse/virtualization-controller/pkg/controller/vm/internal/state" "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" "github.com/deckhouse/virtualization/api/core/v1alpha2/vmcondition" ) @@ -107,6 +108,10 @@ func (h *MaintenanceHandler) Handle(ctx context.Context, s state.VirtualMachineS } } + if changed.Status.Phase == v1alpha2.MachinePending { + changed.Status.Phase = v1alpha2.MachineStopped + } + return reconcile.Result{}, reconciler.ErrStopHandlerChain } diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/statistic.go b/images/virtualization-artifact/pkg/controller/vm/internal/statistic.go index 4c4a4e672b..15d0e293ef 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/statistic.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/statistic.go @@ -125,7 +125,7 @@ func (h *StatisticHandler) syncResources(changed *virtv2.VirtualMachine, } var ctr corev1.Container for _, container := range pod.Spec.Containers { - if container.Name == "compute" { + if vm.IsComputeContainer(container.Name) { ctr = container } } diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/statistic_test.go b/images/virtualization-artifact/pkg/controller/vm/internal/statistic_test.go index 05a0340518..a68885634a 100644 --- a/images/virtualization-artifact/pkg/controller/vm/internal/statistic_test.go +++ b/images/virtualization-artifact/pkg/controller/vm/internal/statistic_test.go @@ -89,7 +89,7 @@ var _ = Describe("TestStatisticHandler", func() { NodeName: nodeName, Containers: []corev1.Container{ { - Name: "compute", + Name: "d8v-compute", Resources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse(requestCPU), diff --git a/images/virtualization-artifact/pkg/controller/vm/internal/validators/maintenance_validator.go b/images/virtualization-artifact/pkg/controller/vm/internal/validators/maintenance_validator.go deleted file mode 100644 index b5b9f0a067..0000000000 --- a/images/virtualization-artifact/pkg/controller/vm/internal/validators/maintenance_validator.go +++ /dev/null @@ -1,54 +0,0 @@ -/* -Copyright 2025 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package validators - -import ( - "context" - "fmt" - "reflect" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/webhook/admission" - - "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" - virtv2 "github.com/deckhouse/virtualization/api/core/v1alpha2" - "github.com/deckhouse/virtualization/api/core/v1alpha2/vmcondition" -) - -type MaintenanceValidator struct{} - -func NewMaintenanceValidator() *MaintenanceValidator { - return &MaintenanceValidator{} -} - -func (v *MaintenanceValidator) ValidateCreate(_ context.Context, _ *virtv2.VirtualMachine) (admission.Warnings, error) { - return nil, nil -} - -func (v *MaintenanceValidator) ValidateUpdate(_ context.Context, oldVM, newVM *virtv2.VirtualMachine) (admission.Warnings, error) { - maintenance, _ := conditions.GetCondition(vmcondition.TypeMaintenance, oldVM.Status.Conditions) - - if maintenance.Status != metav1.ConditionTrue { - return nil, nil - } - - if !reflect.DeepEqual(oldVM.Spec, newVM.Spec) { - return nil, fmt.Errorf("spec changes are not allowed while VirtualMachine is in maintenance mode") - } - - return nil, nil -} diff --git a/images/virtualization-artifact/pkg/controller/vm/vm_webhook.go b/images/virtualization-artifact/pkg/controller/vm/vm_webhook.go index 9c8da5a892..49f4ecd78b 100644 --- a/images/virtualization-artifact/pkg/controller/vm/vm_webhook.go +++ b/images/virtualization-artifact/pkg/controller/vm/vm_webhook.go @@ -45,7 +45,6 @@ type Validator struct { func NewValidator(client client.Client, service *service.BlockDeviceService, featureGate featuregate.FeatureGate, log *log.Logger) *Validator { return &Validator{ validators: []VirtualMachineValidator{ - validators.NewMaintenanceValidator(), validators.NewMetaValidator(client), validators.NewIPAMValidator(client), validators.NewBlockDeviceSpecRefsValidator(), diff --git a/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle.go b/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle.go index fd95955086..636a016ec6 100644 --- a/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle.go +++ b/images/virtualization-artifact/pkg/controller/vmop/migration/internal/handler/lifecycle.go @@ -78,6 +78,17 @@ func (h LifecycleHandler) Handle(ctx context.Context, vmop *v1alpha2.VirtualMach // 1.Initialize new VMOP resource: set phase to Pending and all conditions to Unknown. h.base.Init(vmop) + if vmop.Status.Phase == "" { + conditions.SetCondition( + conditions.NewConditionBuilder(vmopcondition.TypeSignalSent). + Generation(vmop.GetGeneration()). + Reason(conditions.ReasonUnknown). + Status(metav1.ConditionUnknown). + Message(""), + &vmop.Status.Conditions, + ) + } + completedCond := conditions.NewConditionBuilder(vmopcondition.TypeCompleted).Generation(vmop.GetGeneration()) // Pending if quota exceeded. diff --git a/images/virtualization-artifact/pkg/controller/vmop/powerstate/internal/handler/lifecycle.go b/images/virtualization-artifact/pkg/controller/vmop/powerstate/internal/handler/lifecycle.go index 8f54299287..43bd2c31e5 100644 --- a/images/virtualization-artifact/pkg/controller/vmop/powerstate/internal/handler/lifecycle.go +++ b/images/virtualization-artifact/pkg/controller/vmop/powerstate/internal/handler/lifecycle.go @@ -79,6 +79,17 @@ func (h LifecycleHandler) Handle(ctx context.Context, vmop *v1alpha2.VirtualMach // 1.Initialize new VMOP resource: set phase to Pending and all conditions to Unknown. h.base.Init(vmop) + if vmop.Status.Phase == "" { + conditions.SetCondition( + conditions.NewConditionBuilder(vmopcondition.TypeSignalSent). + Generation(vmop.GetGeneration()). + Reason(conditions.ReasonUnknown). + Status(metav1.ConditionUnknown). + Message(""), + &vmop.Status.Conditions, + ) + } + // 2. Get VirtualMachine for validation vmop. vm, err := h.base.FetchVirtualMachineOrSetFailedPhase(ctx, vmop) if vm == nil || err != nil { diff --git a/images/virtualization-artifact/pkg/controller/vmop/service/service.go b/images/virtualization-artifact/pkg/controller/vmop/service/service.go index b7376b7ebf..66a3d9d714 100644 --- a/images/virtualization-artifact/pkg/controller/vmop/service/service.go +++ b/images/virtualization-artifact/pkg/controller/vmop/service/service.go @@ -106,14 +106,6 @@ func (s *BaseVMOPService) Init(vmop *v1alpha2.VirtualMachineOperation) { Message(""), &vmop.Status.Conditions, ) - conditions.SetCondition( - conditions.NewConditionBuilder(vmopcondition.TypeSignalSent). - Generation(vmop.GetGeneration()). - Reason(conditions.ReasonUnknown). - Status(metav1.ConditionUnknown). - Message(""), - &vmop.Status.Conditions, - ) } } diff --git a/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/common/common.go b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/common/common.go new file mode 100644 index 0000000000..e24a4923b0 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/common/common.go @@ -0,0 +1,56 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vmopcondition" +) + +func SetPhaseConditionToFailed(cb *conditions.ConditionBuilder, phase *v1alpha2.VMOPPhase, err error) { + *phase = v1alpha2.VMOPPhaseFailed + cb. + Status(metav1.ConditionFalse). + Reason(vmopcondition.ReasonRestoreOperationFailed). + Message(service.CapitalizeFirstLetter(err.Error()) + ".") +} + +func SetPhaseConditionCompleted(cb *conditions.ConditionBuilder, phase *v1alpha2.VMOPPhase, reason vmopcondition.ReasonRestoreCompleted, msg string) { + *phase = v1alpha2.VMOPPhaseCompleted + cb. + Status(metav1.ConditionTrue). + Reason(reason). + Message(service.CapitalizeFirstLetter(msg) + ".") +} + +func FillResourcesStatuses(vmop *v1alpha2.VirtualMachineOperation, statuses []v1alpha2.VirtualMachineOperationResource) { + vmop.Status.Resources = nil + + for _, status := range statuses { + vmop.Status.Resources = append(vmop.Status.Resources, v1alpha2.VirtualMachineOperationResource{ + APIVersion: status.APIVersion, + Kind: status.Kind, + Name: status.Name, + Message: status.Message, + Status: status.Status, + }) + } +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/deletion.go b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/deletion.go new file mode 100644 index 0000000000..c5a54b6c58 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/deletion.go @@ -0,0 +1,110 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handler + +import ( + "context" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vmcondition" +) + +const deletionHandlerName = "DeletionHandler" + +// DeletionHandler manages finalizers on VirtualMachineOperation resource. +type DeletionHandler struct { + client client.Client +} + +func NewDeletionHandler(client client.Client) *DeletionHandler { + return &DeletionHandler{client: client} +} + +func (h DeletionHandler) Handle(ctx context.Context, vmop *v1alpha2.VirtualMachineOperation) (reconcile.Result, error) { + log := logger.FromContext(ctx) + + // Add finalizer for operations in progress + if vmop.DeletionTimestamp.IsZero() { + if vmop.Status.Phase == v1alpha2.VMOPPhaseInProgress { + log.Debug("Add cleanup finalizer while in the InProgress phase") + controllerutil.AddFinalizer(vmop, v1alpha2.FinalizerVMOPCleanup) + } + + return reconcile.Result{}, nil + } + + vmKey := types.NamespacedName{Namespace: vmop.Namespace, Name: vmop.Spec.VirtualMachine} + vm, err := object.FetchObject(ctx, vmKey, h.client, &v1alpha2.VirtualMachine{}) + if err != nil { + log.Debug("Failed to fetch VirtualMachine", logger.SlogErr(err)) + return reconcile.Result{}, err + } + + if vm == nil { + controllerutil.RemoveFinalizer(vmop, v1alpha2.FinalizerVMOPCleanup) + return reconcile.Result{}, nil + } + + // Clean up maintenance mode if VM is in maintenance for restore operation + maintenanceCondition, found := conditions.GetCondition(vmcondition.TypeMaintenance, vm.Status.Conditions) + if found && maintenanceCondition.Status == metav1.ConditionTrue && maintenanceCondition.Reason == vmcondition.ReasonMaintenanceRestore.String() { + conditions.SetCondition( + conditions.NewConditionBuilder(vmcondition.TypeMaintenance). + Generation(vm.GetGeneration()). + Reason(vmcondition.ReasonMaintenanceRestore). + Status(metav1.ConditionFalse). + Message("VM exited maintenance mode due to vmop deletion"), + &vm.Status.Conditions, + ) + + err = h.client.Status().Update(ctx, vm) + if err != nil { + if apierrors.IsConflict(err) { + return reconcile.Result{}, nil + } + + log.Error("Failed to exit maintenance mode during deletion", logger.SlogErr(err)) + return reconcile.Result{}, err + } + + log.Info("VM exited maintenance mode due to vmop deletion") + } + + // Remove finalizer when VirtualMachineOperation is in deletion state or not in progress. + if vmop.DeletionTimestamp.IsZero() { + log.Debug("Remove cleanup finalizer from VirtualMachineOperation: not InProgress state", "phase", vmop.Status.Phase) + } else { + log.Info("Deletion observed: remove cleanup finalizer from VirtualMachineOperation", "phase", vmop.Status.Phase) + } + controllerutil.RemoveFinalizer(vmop, v1alpha2.FinalizerVMOPCleanup) + + return reconcile.Result{}, nil +} + +func (h DeletionHandler) Name() string { + return deletionHandlerName +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/deletion_test.go b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/deletion_test.go new file mode 100644 index 0000000000..837d3b114b --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/deletion_test.go @@ -0,0 +1,89 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handler + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + vmopbuilder "github.com/deckhouse/virtualization-controller/pkg/builder/vmop" + "github.com/deckhouse/virtualization-controller/pkg/common/testutil" + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +var _ = Describe("DeletionHandler", func() { + const ( + name = "test" + namespace = "default" + ) + + var ( + ctx = testutil.ContextBackgroundWithNoOpLogger() + fakeClient client.WithWatch + srv *reconciler.Resource[*v1alpha2.VirtualMachineOperation, v1alpha2.VirtualMachineOperationStatus] + ) + + AfterEach(func() { + fakeClient = nil + srv = nil + }) + + reconcile := func() { + h := NewDeletionHandler(fakeClient) + _, err := h.Handle(ctx, srv.Changed()) + Expect(err).NotTo(HaveOccurred()) + err = srv.Update(ctx) + Expect(err).NotTo(HaveOccurred()) + } + + newVmop := func(phase v1alpha2.VMOPPhase, opts ...vmopbuilder.Option) *v1alpha2.VirtualMachineOperation { + vmop := vmopbuilder.NewEmpty(name, namespace) + vmop.Status.Phase = phase + vmop.Status = v1alpha2.VirtualMachineOperationStatus{Conditions: []metav1.Condition{}} + vmop.Spec.VirtualMachine = "test-vm" + vmopbuilder.ApplyOptions(vmop, opts...) + return vmop + } + + DescribeTable("Should be protected", func(phase v1alpha2.VMOPPhase, protect bool) { + vmop := newVmop(phase, vmopbuilder.WithType(v1alpha2.VMOPTypeRestore)) + + fakeClient, srv = setupEnvironment(vmop) + reconcile() + + newVMOP := &v1alpha2.VirtualMachineOperation{} + err := fakeClient.Get(ctx, client.ObjectKeyFromObject(vmop), newVMOP) + Expect(err).NotTo(HaveOccurred()) + + updated := controllerutil.AddFinalizer(newVMOP, v1alpha2.FinalizerVMOPCleanup) + + if protect { + Expect(updated).To(BeFalse()) + } else { + Expect(updated).To(BeTrue()) + } + }, + Entry("VMOP Restore 1", v1alpha2.VMOPPhasePending, false), + Entry("VMOP Restore 2", v1alpha2.VMOPPhaseInProgress, false), + Entry("VMOP Restore 3", v1alpha2.VMOPPhaseCompleted, false), + Entry("VMOP Restore 4", v1alpha2.VMOPPhaseFailed, false), + ) +}) diff --git a/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/lifecycle.go b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/lifecycle.go new file mode 100644 index 0000000000..9c1648f6ef --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/lifecycle.go @@ -0,0 +1,157 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handler + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + commonvmop "github.com/deckhouse/virtualization-controller/pkg/common/vmop" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + genericservice "github.com/deckhouse/virtualization-controller/pkg/controller/vmop/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/vmop/snapshot/internal/service" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization-controller/pkg/logger" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vmopcondition" +) + +const lifecycleHandlerName = "LifecycleHandler" + +type Base interface { + Init(vmop *v1alpha2.VirtualMachineOperation) + ShouldExecuteOrSetFailedPhase(ctx context.Context, vmop *v1alpha2.VirtualMachineOperation) (bool, error) + FetchVirtualMachineOrSetFailedPhase(ctx context.Context, vmop *v1alpha2.VirtualMachineOperation) (*v1alpha2.VirtualMachine, error) + IsApplicableOrSetFailedPhase(checker genericservice.ApplicableChecker, vmop *v1alpha2.VirtualMachineOperation, vm *v1alpha2.VirtualMachine) bool +} + +type LifecycleHandler struct { + svcOpCreator SvcOpCreator + base Base + recorder eventrecord.EventRecorderLogger +} + +func NewLifecycleHandler(svcOpCreator SvcOpCreator, base Base, recorder eventrecord.EventRecorderLogger) *LifecycleHandler { + return &LifecycleHandler{ + svcOpCreator: svcOpCreator, + base: base, + recorder: recorder, + } +} + +// Handle sets conditions depending on cluster state. +// It should set Running condition to start operation on VM. +func (h LifecycleHandler) Handle(ctx context.Context, vmop *v1alpha2.VirtualMachineOperation) (reconcile.Result, error) { + // Do not update conditions for object in the deletion state. + if commonvmop.IsTerminating(vmop) { + vmop.Status.Phase = v1alpha2.VMOPPhaseTerminating + return reconcile.Result{}, nil + } + + // Ignore if VMOP is in final state. + if commonvmop.IsFinished(vmop) { + return reconcile.Result{}, nil + } + + // 1.Initialize new VMOP resource: set phase to Pending and all conditions to Unknown. + h.base.Init(vmop) + + if vmop.Status.Phase == "" { + conditions.SetCondition( + conditions.NewConditionBuilder(vmopcondition.TypeRestoreCompleted). + Generation(vmop.GetGeneration()). + Reason(conditions.ReasonUnknown). + Status(metav1.ConditionUnknown). + Message(""), + &vmop.Status.Conditions, + ) + } + + // 2. Get VirtualMachine for validation vmop. + vm, err := h.base.FetchVirtualMachineOrSetFailedPhase(ctx, vmop) + if vm == nil || err != nil { + return reconcile.Result{}, err + } + + svcOp, err := h.svcOpCreator(vmop) + if err != nil { + return reconcile.Result{}, err + } + + // 3. Operation already in progress. Check if the operation is completed. + // Run execute until the operation is completed. + if svcOp.IsInProgress() { + return h.execute(ctx, vmop, svcOp) + } + + // 4. VMOP is not in progress. + // All operations must be performed in course, check it and set phase if operation cannot be executed now. + should, err := h.base.ShouldExecuteOrSetFailedPhase(ctx, vmop) + if err != nil { + return reconcile.Result{}, err + } + if !should { + return reconcile.Result{}, nil + } + + // 5. Check if the operation is applicable for executed. + isApplicable := h.base.IsApplicableOrSetFailedPhase(svcOp, vmop, vm) + if !isApplicable { + return reconcile.Result{}, nil + } + + // 6. The Operation is valid, and can be executed. + return h.execute(ctx, vmop, svcOp) +} + +func (h LifecycleHandler) Name() string { + return lifecycleHandlerName +} + +func (h LifecycleHandler) execute(ctx context.Context, vmop *v1alpha2.VirtualMachineOperation, svcOp service.Operation) (rec reconcile.Result, err error) { + log := logger.FromContext(ctx) + + completedCond := conditions.NewConditionBuilder(vmopcondition.TypeCompleted).Generation(vmop.GetGeneration()) + rec, err = svcOp.Execute(ctx) + if err != nil { + failMsg := fmt.Sprintf("%s is failed", vmop.Spec.Type) + log.Debug(failMsg, logger.SlogErr(err)) + failMsg = fmt.Errorf("%s: %w", failMsg, err).Error() + h.recorder.Event(vmop, corev1.EventTypeWarning, v1alpha2.ReasonErrVMOPFailed, failMsg) + + vmop.Status.Phase = v1alpha2.VMOPPhaseFailed + conditions.SetCondition(completedCond.Reason(vmopcondition.ReasonOperationFailed).Message(failMsg).Status(metav1.ConditionFalse), &vmop.Status.Conditions) + } else { + vmop.Status.Phase = v1alpha2.VMOPPhaseInProgress + reason := svcOp.GetInProgressReason() + conditions.SetCondition(completedCond.Reason(reason).Message("Wait for operation to complete").Status(metav1.ConditionFalse), &vmop.Status.Conditions) + } + + isComplete := svcOp.IsComplete() + if isComplete { + vmop.Status.Phase = v1alpha2.VMOPPhaseCompleted + h.recorder.Event(vmop, corev1.EventTypeNormal, v1alpha2.ReasonVMOPSucceeded, "VirtualMachineOperation succeeded") + + conditions.SetCondition(completedCond.Reason(vmopcondition.ReasonOperationCompleted).Message("VirtualMachineOperation succeeded").Status(metav1.ConditionTrue), &vmop.Status.Conditions) + } + + return rec, err +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/service.go b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/service.go new file mode 100644 index 0000000000..754e04fd86 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/service.go @@ -0,0 +1,33 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handler + +import ( + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/controller/vmop/snapshot/internal/service" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +type SvcOpCreator func(vmop *v1alpha2.VirtualMachineOperation) (service.Operation, error) + +func NewSvcOpCreator(client client.Client, recorder eventrecord.EventRecorderLogger) SvcOpCreator { + return func(vmop *v1alpha2.VirtualMachineOperation) (service.Operation, error) { + return service.NewOperationService(client, recorder, vmop) + } +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/suite_test.go b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/suite_test.go new file mode 100644 index 0000000000..00c6503ed8 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/suite_test.go @@ -0,0 +1,59 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handler + +import ( + "context" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/deckhouse/virtualization-controller/pkg/common/testutil" + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func TestVmopHandlers(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "VMOP Restore handlers Suite") +} + +func setupEnvironment(vmop *v1alpha2.VirtualMachineOperation, objs ...client.Object) (client.WithWatch, *reconciler.Resource[*v1alpha2.VirtualMachineOperation, v1alpha2.VirtualMachineOperationStatus]) { + GinkgoHelper() + Expect(vmop).ToNot(BeNil()) + + allObjects := make([]client.Object, len(objs)+1) + allObjects[0] = vmop + allObjects = append(allObjects, objs...) + + fakeClient, err := testutil.NewFakeClientWithObjects(allObjects...) + Expect(err).NotTo(HaveOccurred()) + + srv := reconciler.NewResource(client.ObjectKeyFromObject(vmop), fakeClient, + func() *v1alpha2.VirtualMachineOperation { + return &v1alpha2.VirtualMachineOperation{} + }, + func(obj *v1alpha2.VirtualMachineOperation) v1alpha2.VirtualMachineOperationStatus { + return obj.Status + }) + err = srv.Fetch(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + return fakeClient, srv +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/service/operation.go b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/service/operation.go new file mode 100644 index 0000000000..3852d7f958 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/service/operation.go @@ -0,0 +1,47 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package service + +import ( + "context" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vmopcondition" +) + +type Operation interface { + Execute(ctx context.Context) (reconcile.Result, error) + IsApplicableForVMPhase(phase v1alpha2.MachinePhase) bool + IsApplicableForRunPolicy(runPolicy v1alpha2.RunPolicy) bool + GetInProgressReason() vmopcondition.ReasonCompleted + IsInProgress() bool + IsComplete() bool +} + +func NewOperationService(client client.Client, recorder eventrecord.EventRecorderLogger, vmop *v1alpha2.VirtualMachineOperation) (Operation, error) { + switch vmop.Spec.Type { + case v1alpha2.VMOPTypeRestore: + return NewRestoreOperation(client, recorder, vmop), nil + default: + return nil, fmt.Errorf("unknown virtual machine operation type: %v", vmop.Spec.Type) + } +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/service/restore.go b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/service/restore.go new file mode 100644 index 0000000000..885a7bc1de --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/service/restore.go @@ -0,0 +1,141 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package service + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/common/steptaker" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/vmop/snapshot/internal/step" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vmopcondition" +) + +func NewRestoreOperation(client client.Client, eventRecorder eventrecord.EventRecorderLogger, vmop *v1alpha2.VirtualMachineOperation) *RestoreOperation { + return &RestoreOperation{ + vmop: vmop, + client: client, + recorder: eventRecorder, + } +} + +type RestoreOperation struct { + vmop *v1alpha2.VirtualMachineOperation + client client.Client + recorder eventrecord.EventRecorderLogger +} + +func (o RestoreOperation) Execute(ctx context.Context) (reconcile.Result, error) { + cb := conditions.NewConditionBuilder(vmopcondition.TypeRestoreCompleted) + defer func() { conditions.SetCondition(cb.Generation(o.vmop.Generation), &o.vmop.Status.Conditions) }() + + cond, exist := conditions.GetCondition(vmopcondition.TypeRestoreCompleted, o.vmop.Status.Conditions) + if exist { + if cond.Status == metav1.ConditionUnknown { + cb.Status(metav1.ConditionFalse).Reason(vmopcondition.ReasonRestoreOperationInProgress) + } else { + cb.Status(cond.Status).Reason(vmopcondition.ReasonRestoreCompleted(cond.Reason)).Message(cond.Message) + } + } + + if o.vmop.Spec.Restore == nil { + err := fmt.Errorf("restore specification is nil") + cb.Status(metav1.ConditionFalse).Reason(vmopcondition.ReasonRestoreOperationFailed).Message(service.CapitalizeFirstLetter(err.Error())) + return reconcile.Result{}, err + } + + if o.vmop.Spec.Restore.VirtualMachineSnapshotName == "" { + err := fmt.Errorf("virtual machine snapshot name is required") + cb.Status(metav1.ConditionFalse).Reason(vmopcondition.ReasonRestoreOperationFailed).Message(service.CapitalizeFirstLetter(err.Error())) + return reconcile.Result{}, err + } + + vmKey := types.NamespacedName{Namespace: o.vmop.Namespace, Name: o.vmop.Spec.VirtualMachine} + vm, err := object.FetchObject(ctx, vmKey, o.client, &v1alpha2.VirtualMachine{}) + if err != nil { + err := fmt.Errorf("failed to fetch the virtual machine %q: %w", vmKey.Name, err) + cb.Status(metav1.ConditionFalse).Reason(vmopcondition.ReasonRestoreOperationFailed).Message(service.CapitalizeFirstLetter(err.Error())) + return reconcile.Result{}, err + } + + if vm == nil { + err := fmt.Errorf("virtual machine is nil") + cb.Status(metav1.ConditionFalse).Reason(vmopcondition.ReasonRestoreOperationFailed).Message(service.CapitalizeFirstLetter(err.Error())) + return reconcile.Result{}, err + } + + return steptaker.NewStepTakers( + step.NewVMSnapshotReadyStep(o.client, cb), + step.NewEnterMaintenanceStep(o.client, o.recorder, cb), + step.NewProcessRestoreStep(o.client, o.recorder, cb), + step.NewExitMaintenanceStep(o.client, o.recorder, cb), + ).Run(ctx, o.vmop) +} + +func (o RestoreOperation) IsApplicableForVMPhase(phase v1alpha2.MachinePhase) bool { + return phase == v1alpha2.MachineStopped || phase == v1alpha2.MachineRunning || phase == v1alpha2.MachinePending +} + +func (o RestoreOperation) IsApplicableForRunPolicy(runPolicy v1alpha2.RunPolicy) bool { + return true +} + +func (o RestoreOperation) GetInProgressReason() vmopcondition.ReasonCompleted { + return vmopcondition.ReasonRestoreInProgress +} + +func (o RestoreOperation) IsInProgress() bool { + maintenanceModeCondition, found := conditions.GetCondition(vmopcondition.TypeMaintenanceMode, o.vmop.Status.Conditions) + if found && maintenanceModeCondition.Status != metav1.ConditionUnknown { + return true + } + + completedCondition, found := conditions.GetCondition(vmopcondition.TypeRestoreCompleted, o.vmop.Status.Conditions) + if found && completedCondition.Status != metav1.ConditionUnknown { + return true + } + + return false +} + +func (o RestoreOperation) IsComplete() bool { + rc, ok := conditions.GetCondition(vmopcondition.TypeRestoreCompleted, o.vmop.Status.Conditions) + if !ok { + return false + } + + if o.vmop.Spec.Restore.Mode == v1alpha2.VMOPRestoreModeDryRun { + return rc.Status == metav1.ConditionTrue + } + + mc, ok := conditions.GetCondition(vmopcondition.TypeMaintenanceMode, o.vmop.Status.Conditions) + if !ok { + return false + } + + return rc.Status == metav1.ConditionTrue && mc.Status == metav1.ConditionFalse +} 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 new file mode 100644 index 0000000000..792dd48c41 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/step/enter_maintenance_step.go @@ -0,0 +1,126 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package step + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/vmop/snapshot/internal/common" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vmcondition" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vmopcondition" +) + +type EnterMaintenanceStep struct { + client client.Client + recorder eventrecord.EventRecorderLogger + cb *conditions.ConditionBuilder +} + +func NewEnterMaintenanceStep( + client client.Client, + recorder eventrecord.EventRecorderLogger, + cb *conditions.ConditionBuilder, +) *EnterMaintenanceStep { + return &EnterMaintenanceStep{ + client: client, + recorder: recorder, + cb: cb, + } +} + +func (s EnterMaintenanceStep) Take(ctx context.Context, vmop *v1alpha2.VirtualMachineOperation) (*reconcile.Result, error) { + if vmop.Spec.Restore.Mode == v1alpha2.VMOPRestoreModeDryRun { + return nil, nil + } + + vmKey := types.NamespacedName{Namespace: vmop.Namespace, Name: vmop.Spec.VirtualMachine} + vm, err := object.FetchObject(ctx, vmKey, s.client, &v1alpha2.VirtualMachine{}) + if err != nil { + return nil, fmt.Errorf("failed to fetch the virtual machine %q: %w", vmKey.Name, err) + } + + if s.cb.Condition().Status == metav1.ConditionTrue { + return nil, nil + } + + maintenanceCondition, found := conditions.GetCondition(vmcondition.TypeMaintenance, vm.Status.Conditions) + if found && maintenanceCondition.Status == metav1.ConditionTrue && maintenanceCondition.Reason == vmcondition.ReasonMaintenanceRestore.String() { + if vm.Status.Phase != v1alpha2.MachineStopped && vm.Status.Phase != v1alpha2.MachinePending { + return &reconcile.Result{}, nil + } + + conditions.SetCondition( + conditions.NewConditionBuilder(vmopcondition.TypeMaintenanceMode). + Generation(vmop.GetGeneration()). + Reason(vmopcondition.ReasonMaintenanceModeEnabled). + Status(metav1.ConditionTrue). + Message("VMOP has enabled maintenance mode on VM for restore operation"), + &vmop.Status.Conditions, + ) + + return nil, nil + } + + conditions.SetCondition( + conditions.NewConditionBuilder(vmcondition.TypeMaintenance). + Generation(vm.GetGeneration()). + Reason(vmcondition.ReasonMaintenanceRestore). + Status(metav1.ConditionTrue). + Message("VM is in maintenance mode for restore operation"), + &vm.Status.Conditions, + ) + + err = s.client.Status().Update(ctx, vm) + if err != nil { + if apierrors.IsConflict(err) { + return &reconcile.Result{}, nil + } + + s.recorder.Event(vmop, corev1.EventTypeWarning, v1alpha2.ReasonErrVMOPFailed, "Failed to enter maintenance mode: "+err.Error()) + common.SetPhaseConditionToFailed(s.cb, &vmop.Status.Phase, err) + return &reconcile.Result{}, err + } + + conditions.SetCondition( + conditions.NewConditionBuilder(vmopcondition.TypeMaintenanceMode). + Generation(vmop.GetGeneration()). + Reason(vmopcondition.ReasonMaintenanceModeEnabled). + Status(metav1.ConditionTrue). + Message("VMOP has enabled maintenance mode on VM for restore operation"), + &vmop.Status.Conditions, + ) + + s.recorder.Event(vmop, corev1.EventTypeNormal, "MaintenanceMode", "VM entered maintenance mode for restore operation") + + if vm.Status.Phase != v1alpha2.MachineStopped { + return &reconcile.Result{}, nil + } + + return nil, nil +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/step/exit_maintenance_step.go b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/step/exit_maintenance_step.go new file mode 100644 index 0000000000..d6c6b25597 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/step/exit_maintenance_step.go @@ -0,0 +1,124 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package step + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/vmop/snapshot/internal/common" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vmcondition" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vmopcondition" +) + +type ExitMaintenanceStep struct { + client client.Client + recorder eventrecord.EventRecorderLogger + cb *conditions.ConditionBuilder +} + +func NewExitMaintenanceStep( + client client.Client, + recorder eventrecord.EventRecorderLogger, + cb *conditions.ConditionBuilder, +) *ExitMaintenanceStep { + return &ExitMaintenanceStep{ + client: client, + recorder: recorder, + cb: cb, + } +} + +func (s ExitMaintenanceStep) Take(ctx context.Context, vmop *v1alpha2.VirtualMachineOperation) (*reconcile.Result, error) { + if vmop.Spec.Restore.Mode == v1alpha2.VMOPRestoreModeDryRun { + return &reconcile.Result{}, nil + } + + vmKey := types.NamespacedName{Namespace: vmop.Namespace, Name: vmop.Spec.VirtualMachine} + vm, err := object.FetchObject(ctx, vmKey, s.client, &v1alpha2.VirtualMachine{}) + if err != nil { + return nil, fmt.Errorf("failed to fetch the virtual machine %q: %w", vmKey.Name, err) + } + + maintenanceVMOPCondition, found := conditions.GetCondition(vmopcondition.TypeMaintenanceMode, vmop.Status.Conditions) + if !found || maintenanceVMOPCondition.Status == metav1.ConditionFalse { + return &reconcile.Result{}, nil + } + + restoreCondition, found := conditions.GetCondition(vmopcondition.TypeRestoreCompleted, vmop.Status.Conditions) + if !found || restoreCondition.Status == metav1.ConditionFalse { + return &reconcile.Result{}, nil + } + + maintenanceVMCondition, found := conditions.GetCondition(vmcondition.TypeMaintenance, vm.Status.Conditions) + if !found || maintenanceVMCondition.Status != metav1.ConditionTrue { + return &reconcile.Result{}, nil + } + + for _, status := range vmop.Status.Resources { + if status.Status == v1alpha2.VMOPResourceStatusInProgress { + return &reconcile.Result{}, nil + } + } + + conditions.SetCondition( + conditions.NewConditionBuilder(vmcondition.TypeMaintenance). + Generation(vm.GetGeneration()). + Reason(vmcondition.ReasonMaintenanceRestore). + Status(metav1.ConditionFalse). + Message("VM exited maintenance mode after restore completion"), + &vm.Status.Conditions, + ) + + err = s.client.Status().Update(ctx, vm) + if err != nil { + if apierrors.IsConflict(err) { + return &reconcile.Result{}, nil + } + + s.recorder.Event( + vmop, + corev1.EventTypeWarning, + v1alpha2.ReasonErrVMOPFailed, + "Failed to exit maintenance mode: "+err.Error(), + ) + common.SetPhaseConditionToFailed(s.cb, &vmop.Status.Phase, err) + return &reconcile.Result{}, err + } + + conditions.SetCondition( + conditions.NewConditionBuilder(vmopcondition.TypeMaintenanceMode). + Generation(vmop.GetGeneration()). + Reason(vmopcondition.ReasonMaintenanceModeDisabled). + Status(metav1.ConditionFalse). + Message("VMOP has disabled maintenance mode on VM"), + &vmop.Status.Conditions, + ) + + return &reconcile.Result{}, nil +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/step/process_step.go b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/step/process_step.go new file mode 100644 index 0000000000..1986b551d3 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/step/process_step.go @@ -0,0 +1,118 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package step + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/service/restorer" + "github.com/deckhouse/virtualization-controller/pkg/controller/vmop/snapshot/internal/common" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vmopcondition" +) + +type ProcessRestoreStep struct { + client client.Client + recorder eventrecord.EventRecorderLogger + cb *conditions.ConditionBuilder +} + +func NewProcessRestoreStep( + client client.Client, + recorder eventrecord.EventRecorderLogger, + cb *conditions.ConditionBuilder, +) *ProcessRestoreStep { + return &ProcessRestoreStep{ + client: client, + recorder: recorder, + cb: cb, + } +} + +func (s ProcessRestoreStep) Take(ctx context.Context, vmop *v1alpha2.VirtualMachineOperation) (*reconcile.Result, error) { + c, exist := conditions.GetCondition(s.cb.GetType(), vmop.Status.Conditions) + if exist { + if c.Status == metav1.ConditionTrue { + return nil, nil + } + + maintenanceModeCondition, found := conditions.GetCondition(vmopcondition.TypeMaintenanceMode, vmop.Status.Conditions) + if found && maintenanceModeCondition.Status == metav1.ConditionFalse { + return &reconcile.Result{}, nil + } + } + + vmSnapshotKey := types.NamespacedName{Namespace: vmop.Namespace, Name: vmop.Spec.Restore.VirtualMachineSnapshotName} + vmSnapshot, err := object.FetchObject(ctx, vmSnapshotKey, s.client, &v1alpha2.VirtualMachineSnapshot{}) + if err != nil { + common.SetPhaseConditionToFailed(s.cb, &vmop.Status.Phase, err) + return &reconcile.Result{}, err + } + + restorerSecretKey := types.NamespacedName{Namespace: vmSnapshot.Namespace, Name: vmSnapshot.Status.VirtualMachineSnapshotSecretName} + restorerSecret, err := object.FetchObject(ctx, restorerSecretKey, s.client, &corev1.Secret{}) + if err != nil { + common.SetPhaseConditionToFailed(s.cb, &vmop.Status.Phase, err) + return &reconcile.Result{}, err + } + + snapshotResources := restorer.NewSnapshotResources(s.client, v1alpha2.VMOPTypeRestore, vmop.Spec.Restore.Mode, restorerSecret, vmSnapshot, string(vmop.UID)) + + err = snapshotResources.Prepare(ctx) + if err != nil { + common.SetPhaseConditionToFailed(s.cb, &vmop.Status.Phase, err) + return &reconcile.Result{}, err + } + + statuses, err := snapshotResources.Validate(ctx) + if err != nil { + common.SetPhaseConditionToFailed(s.cb, &vmop.Status.Phase, err) + common.FillResourcesStatuses(vmop, statuses) + return &reconcile.Result{}, err + } + + if vmop.Spec.Restore.Mode == v1alpha2.VMOPRestoreModeDryRun { + common.SetPhaseConditionCompleted(s.cb, &vmop.Status.Phase, vmopcondition.ReasonDryRunOperationCompleted, "The virtual machine can be restored from the snapshot") + return &reconcile.Result{}, nil + } + + statuses, err = snapshotResources.Process(ctx) + common.FillResourcesStatuses(vmop, statuses) + if err != nil { + common.SetPhaseConditionToFailed(s.cb, &vmop.Status.Phase, err) + return &reconcile.Result{}, err + } + + for _, status := range statuses { + if status.Status == v1alpha2.VMOPResourceStatusInProgress { + return &reconcile.Result{}, nil + } + } + + s.cb.Status(metav1.ConditionTrue).Reason(vmopcondition.ReasonRestoreOperationCompleted).Message("The virtual machine has been restored from the snapshot successfully") + + return &reconcile.Result{}, nil +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/step/vmsnapshot_ready_step.go b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/step/vmsnapshot_ready_step.go new file mode 100644 index 0000000000..8a50cd1f66 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/step/vmsnapshot_ready_step.go @@ -0,0 +1,95 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package step + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/deckhouse/virtualization-controller/pkg/common/object" + "github.com/deckhouse/virtualization-controller/pkg/controller/conditions" + "github.com/deckhouse/virtualization-controller/pkg/controller/vmop/snapshot/internal/common" + "github.com/deckhouse/virtualization/api/core/v1alpha2" + "github.com/deckhouse/virtualization/api/core/v1alpha2/vmscondition" +) + +type VMSnapshotReadyStep struct { + client client.Client + cb *conditions.ConditionBuilder +} + +func NewVMSnapshotReadyStep( + client client.Client, + cb *conditions.ConditionBuilder, +) *VMSnapshotReadyStep { + return &VMSnapshotReadyStep{ + client: client, + cb: cb, + } +} + +func (s VMSnapshotReadyStep) Take(ctx context.Context, vmop *v1alpha2.VirtualMachineOperation) (*reconcile.Result, error) { + if vmop.Spec.Restore.VirtualMachineSnapshotName == "" { + err := fmt.Errorf("the virtual machine snapshot name is empty") + common.SetPhaseConditionToFailed(s.cb, &vmop.Status.Phase, err) + return &reconcile.Result{}, err + } + + vmSnapshotKey := types.NamespacedName{Namespace: vmop.Namespace, Name: vmop.Spec.Restore.VirtualMachineSnapshotName} + vmSnapshot, err := object.FetchObject(ctx, vmSnapshotKey, s.client, &v1alpha2.VirtualMachineSnapshot{}) + if err != nil { + vmop.Status.Phase = v1alpha2.VMOPPhaseFailed + err := fmt.Errorf("failed to fetch the virtual machine snapshot %q: %w", vmSnapshotKey.Name, err) + common.SetPhaseConditionToFailed(s.cb, &vmop.Status.Phase, err) + return &reconcile.Result{}, err + } + + if vmSnapshot == nil { + vmop.Status.Phase = v1alpha2.VMOPPhaseFailed + err := fmt.Errorf("virtual machine snapshot %q is not found", vmSnapshotKey.Name) + common.SetPhaseConditionToFailed(s.cb, &vmop.Status.Phase, err) + return &reconcile.Result{}, err + } + + vmSnapshotReadyToUseCondition, exist := conditions.GetCondition(vmscondition.VirtualMachineSnapshotReadyType, vmSnapshot.Status.Conditions) + if !exist { + vmop.Status.Phase = v1alpha2.VMOPPhaseFailed + err := fmt.Errorf("virtual machine snapshot %q is not ready to use", vmop.Spec.Restore.VirtualMachineSnapshotName) + common.SetPhaseConditionToFailed(s.cb, &vmop.Status.Phase, err) + return &reconcile.Result{}, err + } + + if vmSnapshotReadyToUseCondition.Status != metav1.ConditionTrue { + vmop.Status.Phase = v1alpha2.VMOPPhaseFailed + err := fmt.Errorf("virtual machine snapshot %q is not ready to use", vmop.Spec.Restore.VirtualMachineSnapshotName) + common.SetPhaseConditionToFailed(s.cb, &vmop.Status.Phase, err) + return &reconcile.Result{}, err + } + + if vmSnapshot.Status.VirtualMachineSnapshotSecretName == "" { + err := fmt.Errorf("snapshot secret name is empty") + common.SetPhaseConditionToFailed(s.cb, &vmop.Status.Phase, err) + return &reconcile.Result{}, err + } + + return nil, nil +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/predicates.go b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/predicates.go new file mode 100644 index 0000000000..327cb3f1eb --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/predicates.go @@ -0,0 +1,31 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package watcher + +import ( + "sigs.k8s.io/controller-runtime/pkg/predicate" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func NewRestorePredicate() predicate.TypedPredicate[*v1alpha2.VirtualMachineOperation] { + return predicate.NewTypedPredicateFuncs(Match) +} + +func Match(vmop *v1alpha2.VirtualMachineOperation) bool { + return vmop.Spec.Type == v1alpha2.VMOPTypeRestore +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/vd.go b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/vd.go new file mode 100644 index 0000000000..ef3d265f8b --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/vd.go @@ -0,0 +1,98 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package watcher + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func NewVDWatcher() *VirtualDiskWatcher { + return &VirtualDiskWatcher{} +} + +type VirtualDiskWatcher struct{} + +func (w VirtualDiskWatcher) Watch(mgr manager.Manager, ctr controller.Controller) error { + mgrClient := mgr.GetClient() + if err := ctr.Watch( + source.Kind(mgr.GetCache(), &v1alpha2.VirtualDisk{}, + handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, vd *v1alpha2.VirtualDisk) []reconcile.Request { + restoreUID, hasRestoreAnnotation := vd.Annotations[annotations.AnnVMOPRestore] + if !hasRestoreAnnotation { + restoreUID, hasRestoreAnnotation = vd.Annotations[annotations.AnnVMOPRestoreDeleted] + } + + if !hasRestoreAnnotation { + return nil + } + + // Find VMOPs that match this restore operation + vmops := &v1alpha2.VirtualMachineOperationList{} + if err := mgrClient.List(ctx, vmops, client.InNamespace(vd.GetNamespace())); err != nil { + return nil + } + + var requests []reconcile.Request + for _, vmop := range vmops.Items { + if !Match(&vmop) { + continue + } + + // Check if this VMOP matches the restore UID and is in progress + if string(vmop.UID) == restoreUID && vmop.Status.Phase == v1alpha2.VMOPPhaseInProgress { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: vmop.GetNamespace(), + Name: vmop.GetName(), + }, + }) + } + } + return requests + }), + predicate.TypedFuncs[*v1alpha2.VirtualDisk]{ + DeleteFunc: func(e event.TypedDeleteEvent[*v1alpha2.VirtualDisk]) bool { + // Always trigger on delete events - the handler will filter for relevant VMOPs + // since deleted VDs might not have restore annotations but could still be blocking restores + return true + }, + UpdateFunc: func(e event.TypedUpdateEvent[*v1alpha2.VirtualDisk]) bool { + // Trigger reconciliation when VirtualDisk phase changes during restore + _, hasRestoreAnnotation := e.ObjectNew.Annotations[annotations.AnnVMOPRestore] + return hasRestoreAnnotation && e.ObjectOld.Status.Phase != e.ObjectNew.Status.Phase + }, + }, + ), + ); err != nil { + return fmt.Errorf("error setting watch on VirtualDisk: %w", err) + } + return nil +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/vm.go b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/vm.go new file mode 100644 index 0000000000..965689b424 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/vm.go @@ -0,0 +1,80 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package watcher + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func NewVMWatcher() *VMWatcher { + return &VMWatcher{} +} + +type VMWatcher struct{} + +func (w VMWatcher) Watch(mgr manager.Manager, ctr controller.Controller) error { + mgrClient := mgr.GetClient() + if err := ctr.Watch( + source.Kind(mgr.GetCache(), &v1alpha2.VirtualMachine{}, + handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, vm *v1alpha2.VirtualMachine) []reconcile.Request { + vmops := &v1alpha2.VirtualMachineOperationList{} + if err := mgrClient.List(ctx, vmops, client.InNamespace(vm.GetNamespace())); err != nil { + return nil + } + var requests []reconcile.Request + for _, vmop := range vmops.Items { + if !Match(&vmop) { + continue + } + + if vmop.Spec.VirtualMachine == vm.GetName() && vmop.Status.Phase == v1alpha2.VMOPPhaseInProgress { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: vmop.GetNamespace(), + Name: vmop.GetName(), + }, + }) + break + } + } + return requests + }), + predicate.TypedFuncs[*v1alpha2.VirtualMachine]{ + UpdateFunc: func(e event.TypedUpdateEvent[*v1alpha2.VirtualMachine]) bool { + return e.ObjectOld.Status.Phase != e.ObjectNew.Status.Phase || !equality.Semantic.DeepEqual(e.ObjectOld.Status.Conditions, e.ObjectNew.Status.Conditions) + }, + }, + ), + ); err != nil { + return fmt.Errorf("error setting watch on VirtualMachine: %w", err) + } + return nil +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/vmbda.go b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/vmbda.go new file mode 100644 index 0000000000..5510975512 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/vmbda.go @@ -0,0 +1,98 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package watcher + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/virtualization-controller/pkg/common/annotations" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func NewVMBDAWatcher() *VMBlockDeviceAttachmentWatcher { + return &VMBlockDeviceAttachmentWatcher{} +} + +type VMBlockDeviceAttachmentWatcher struct{} + +func (w VMBlockDeviceAttachmentWatcher) Watch(mgr manager.Manager, ctr controller.Controller) error { + mgrClient := mgr.GetClient() + if err := ctr.Watch( + source.Kind(mgr.GetCache(), &v1alpha2.VirtualMachineBlockDeviceAttachment{}, + handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, vmbda *v1alpha2.VirtualMachineBlockDeviceAttachment) []reconcile.Request { + restoreUID, hasRestoreAnnotation := vmbda.Annotations[annotations.AnnVMOPRestore] + if !hasRestoreAnnotation { + restoreUID, hasRestoreAnnotation = vmbda.Annotations[annotations.AnnVMOPRestoreDeleted] + } + + if !hasRestoreAnnotation { + return nil + } + + // Find VMOPs that match this restore operation + vmops := &v1alpha2.VirtualMachineOperationList{} + if err := mgrClient.List(ctx, vmops, client.InNamespace(vmbda.GetNamespace())); err != nil { + return nil + } + + var requests []reconcile.Request + for _, vmop := range vmops.Items { + if !Match(&vmop) { + continue + } + + // Check if this VMOP matches the restore UID and is in progress + if string(vmop.UID) == restoreUID && vmop.Status.Phase == v1alpha2.VMOPPhaseInProgress { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: vmop.GetNamespace(), + Name: vmop.GetName(), + }, + }) + } + } + return requests + }), + predicate.TypedFuncs[*v1alpha2.VirtualMachineBlockDeviceAttachment]{ + DeleteFunc: func(e event.TypedDeleteEvent[*v1alpha2.VirtualMachineBlockDeviceAttachment]) bool { + // Always trigger on delete events - the handler will filter for relevant VMOPs + // since deleted VMBDAs might not have restore annotations but could still be blocking restores + return true + }, + UpdateFunc: func(e event.TypedUpdateEvent[*v1alpha2.VirtualMachineBlockDeviceAttachment]) bool { + // Trigger reconciliation when VMBDA phase changes during restore + _, hasRestoreAnnotation := e.ObjectNew.Annotations[annotations.AnnVMOPRestore] + return hasRestoreAnnotation && e.ObjectOld.Status.Phase != e.ObjectNew.Status.Phase + }, + }, + ), + ); err != nil { + return fmt.Errorf("error setting watch on VirtualMachineBlockDeviceAttachment: %w", err) + } + return nil +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/vmop.go b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/vmop.go new file mode 100644 index 0000000000..18308ee805 --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/vmop.go @@ -0,0 +1,49 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package watcher + +import ( + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +func NewVMOPWatcher() *VMOPWatcher { + return &VMOPWatcher{} +} + +type VMOPWatcher struct{} + +func (w VMOPWatcher) Watch(mgr manager.Manager, ctr controller.Controller) error { + err := ctr.Watch( + source.Kind( + mgr.GetCache(), + &v1alpha2.VirtualMachineOperation{}, + &handler.TypedEnqueueRequestForObject[*v1alpha2.VirtualMachineOperation]{}, + NewRestorePredicate(), + ), + ) + if err != nil { + return fmt.Errorf("error setting watch on VMOP: %w", err) + } + return nil +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/snapshot/snapshot_controller.go b/images/virtualization-artifact/pkg/controller/vmop/snapshot/snapshot_controller.go new file mode 100644 index 0000000000..e7e1bc179a --- /dev/null +++ b/images/virtualization-artifact/pkg/controller/vmop/snapshot/snapshot_controller.go @@ -0,0 +1,73 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package snapshot + +import ( + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + + "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" + genericservice "github.com/deckhouse/virtualization-controller/pkg/controller/vmop/service" + "github.com/deckhouse/virtualization-controller/pkg/controller/vmop/snapshot/internal/handler" + "github.com/deckhouse/virtualization-controller/pkg/controller/vmop/snapshot/internal/watcher" + "github.com/deckhouse/virtualization-controller/pkg/eventrecord" + "github.com/deckhouse/virtualization/api/core/v1alpha2" +) + +const ( + controllerName = "vmop-snapshot-controller" +) + +func NewController(client client.Client, mgr manager.Manager) *Controller { + recorder := eventrecord.NewEventRecorderLogger(mgr, controllerName) + baseSvc := genericservice.NewBaseVMOPService(client, recorder) + svcOpCreator := handler.NewSvcOpCreator(client, recorder) + + return &Controller{ + watchers: []reconciler.Watcher{ + watcher.NewVMWatcher(), + watcher.NewVMOPWatcher(), + watcher.NewVDWatcher(), + watcher.NewVMBDAWatcher(), + }, + handlers: []reconciler.Handler[*v1alpha2.VirtualMachineOperation]{ + handler.NewLifecycleHandler(svcOpCreator, baseSvc, recorder), + handler.NewDeletionHandler(client), + }, + } +} + +type Controller struct { + watchers []reconciler.Watcher + handlers []reconciler.Handler[*v1alpha2.VirtualMachineOperation] +} + +func (c *Controller) Name() string { + return controllerName +} + +func (c *Controller) Watchers() []reconciler.Watcher { + return c.watchers +} + +func (c *Controller) Handlers() []reconciler.Handler[*v1alpha2.VirtualMachineOperation] { + return c.handlers +} + +func (c *Controller) ShouldReconcile(vmop *v1alpha2.VirtualMachineOperation) bool { + return watcher.Match(vmop) +} diff --git a/images/virtualization-artifact/pkg/controller/vmop/vmop_controller.go b/images/virtualization-artifact/pkg/controller/vmop/vmop_controller.go index ffa3cea94e..1e4f2ec2c4 100644 --- a/images/virtualization-artifact/pkg/controller/vmop/vmop_controller.go +++ b/images/virtualization-artifact/pkg/controller/vmop/vmop_controller.go @@ -32,6 +32,7 @@ import ( "github.com/deckhouse/virtualization-controller/pkg/controller/reconciler" "github.com/deckhouse/virtualization-controller/pkg/controller/vmop/migration" "github.com/deckhouse/virtualization-controller/pkg/controller/vmop/powerstate" + "github.com/deckhouse/virtualization-controller/pkg/controller/vmop/snapshot" "github.com/deckhouse/virtualization-controller/pkg/logger" vmopcollector "github.com/deckhouse/virtualization-controller/pkg/monitoring/metrics/vmop" "github.com/deckhouse/virtualization/api/core/v1alpha2" @@ -56,15 +57,18 @@ func SetupController( controllers := []SubController{ powerstate.NewController(client, mgr), migration.NewController(client, mgr), + snapshot.NewController(client, mgr), } for _, ctr := range controllers { + l := log.With("controller", ctr.Name()) r := NewReconciler(client, ctr) + c, err := controller.New(ctr.Name(), mgr, controller.Options{ Reconciler: r, RateLimiter: workqueue.NewTypedItemExponentialFailureRateLimiter[reconcile.Request](time.Second, 32*time.Second), RecoverPanic: ptr.To(true), - LogConstructor: logger.NewConstructor(log), + LogConstructor: logger.NewConstructor(l), CacheSyncTimeout: 10 * time.Minute, }) if err != nil { diff --git a/images/virtualization-artifact/pkg/controller/vmrestore/internal/life_cycle.go b/images/virtualization-artifact/pkg/controller/vmrestore/internal/life_cycle.go index 37c66ef637..d4de034a75 100644 --- a/images/virtualization-artifact/pkg/controller/vmrestore/internal/life_cycle.go +++ b/images/virtualization-artifact/pkg/controller/vmrestore/internal/life_cycle.go @@ -512,7 +512,7 @@ func newVMRestoreVMOP(vmName, namespace, vmRestoreUID string, vmopType virtv2.VM return vmopbuilder.New( vmopbuilder.WithGenerateName("vmrestore-"), vmopbuilder.WithNamespace(namespace), - vmopbuilder.WithAnnotation(annotations.AnnVMRestore, vmRestoreUID), + vmopbuilder.WithAnnotation(annotations.AnnVMOPRestore, vmRestoreUID), vmopbuilder.WithType(vmopType), vmopbuilder.WithVirtualMachine(vmName), ) @@ -526,7 +526,7 @@ func (h LifeCycleHandler) getVMRestoreVMOP(ctx context.Context, vmNamespace, vmR } for _, vmop := range vmops.Items { - if v, ok := vmop.Annotations[annotations.AnnVMRestore]; ok { + if v, ok := vmop.Annotations[annotations.AnnVMOPRestore]; ok { if v == vmRestoreUID && vmop.Spec.Type == vmopType { return &vmop, nil } diff --git a/images/virtualization-artifact/pkg/controller/vmrestore/internal/restorer/vm_restorer.go b/images/virtualization-artifact/pkg/controller/vmrestore/internal/restorer/vm_restorer.go index 9bb6358162..ec632c2d5b 100644 --- a/images/virtualization-artifact/pkg/controller/vmrestore/internal/restorer/vm_restorer.go +++ b/images/virtualization-artifact/pkg/controller/vmrestore/internal/restorer/vm_restorer.go @@ -139,9 +139,8 @@ func (v *VirtualMachineOverrideValidator) ProcessWithForce(ctx context.Context) if updErr != nil { if apierrors.IsConflict(updErr) { return fmt.Errorf("waiting for the `VirtualMachine` %w", ErrUpdating) - } else { - return fmt.Errorf("failed to update the `VirtualMachine`: %w", updErr) } + return fmt.Errorf("failed to update the `VirtualMachine`: %w", updErr) } } } diff --git a/images/virtualization-audit/mount-points.yaml b/images/virtualization-audit/mount-points.yaml new file mode 100644 index 0000000000..393d1fda58 --- /dev/null +++ b/images/virtualization-audit/mount-points.yaml @@ -0,0 +1,4 @@ +# A list of pre-created mount points for containerd strict mode. + +dirs: + - /etc/virtualization-audit/certificates diff --git a/images/virtualization-audit/werf.inc.yaml b/images/virtualization-audit/werf.inc.yaml index 80491c16b2..1d3d3d9974 100644 --- a/images/virtualization-audit/werf.inc.yaml +++ b/images/virtualization-audit/werf.inc.yaml @@ -2,6 +2,8 @@ --- image: {{ .ModuleNamePrefix }}{{ .ImageName }} fromImage: {{ .ModuleNamePrefix }}distroless +git: + {{- include "image mount points" . }} import: - image: {{ .ModuleNamePrefix }}virtualization-artifact add: /out/virtualization-audit diff --git a/images/virtualization-controller/mount-points.yaml b/images/virtualization-controller/mount-points.yaml new file mode 100644 index 0000000000..80ba2a6cc0 --- /dev/null +++ b/images/virtualization-controller/mount-points.yaml @@ -0,0 +1,5 @@ +# A list of pre-created mount points for containerd strict mode. + +dirs: + - /tmp/k8s-webhook-server/serving-certs + - /kubeconfig.local diff --git a/images/virtualization-controller/werf.inc.yaml b/images/virtualization-controller/werf.inc.yaml index f23d868b55..73e6f0de5e 100644 --- a/images/virtualization-controller/werf.inc.yaml +++ b/images/virtualization-controller/werf.inc.yaml @@ -1,6 +1,8 @@ --- image: {{ .ModuleNamePrefix }}{{ .ImageName }} fromImage: {{ .ModuleNamePrefix }}distroless +git: + {{- include "image mount points" . }} import: - image: {{ .ModuleNamePrefix }}virtualization-artifact add: /out/virtualization-controller diff --git a/monitoring/grafana-dashboards/main/virtual-machine.json b/monitoring/grafana-dashboards/main/virtual-machine.json index b46bed5699..45f84bb56d 100644 --- a/monitoring/grafana-dashboards/main/virtual-machine.json +++ b/monitoring/grafana-dashboards/main/virtual-machine.json @@ -1720,7 +1720,7 @@ } ] }, - "unit": "bytes" + "unit": "Bps" }, "overrides": [] }, @@ -1750,7 +1750,7 @@ "uid": "P0D6E4079E36703EB" }, "editorMode": "code", - "expr": "rate(d8_virtualization_virtualmachine_migration_data_processed_bytes{namespace=\"$namespace\", name=\"$name\"}[$__rate_interval])", + "expr": "sum(rate(d8_virtualization_virtualmachine_migration_data_processed_bytes{namespace=\"$namespace\", name=\"$name\"}[$__rate_interval])) without (instance,job,node)", "instant": false, "legendFormat": "Processed memory rate", "range": true, @@ -1762,7 +1762,7 @@ "uid": "${ds_prometheus}" }, "editorMode": "code", - "expr": "rate(d8_virtualization_virtualmachine_migration_data_remaining_bytes{namespace=\"$namespace\", name=\"$name\"}[$__rate_interval])", + "expr": "sum(rate(d8_virtualization_virtualmachine_migration_data_remaining_bytes{namespace=\"$namespace\", name=\"$name\"}[$__rate_interval])) without (instance,job,node)", "hide": false, "instant": false, "legendFormat": "Remaining memory rate", @@ -1775,7 +1775,7 @@ "uid": "${ds_prometheus}" }, "editorMode": "code", - "expr": "d8_virtualization_virtualmachine_migration_dirty_memory_rate_bytes{namespace=\"$namespace\", name=\"$name\"}", + "expr": "sum(d8_virtualization_virtualmachine_migration_dirty_memory_rate_bytes{namespace=\"$namespace\", name=\"$name\"}) without (instance,job,node)", "hide": false, "instant": false, "legendFormat": "Dirty memory rate", diff --git a/openapi/values.yaml b/openapi/values.yaml index 61e6b641fc..a4da0203c7 100644 --- a/openapi/values.yaml +++ b/openapi/values.yaml @@ -122,6 +122,14 @@ properties: properties: nodeCount: type: integer + virtConfig: + type: object + default: {} + properties: + phase: + type: string + parallelMigrationsPerCluster: + type: integer moduleConfig: type: object additionalProperties: true diff --git a/templates/certificate.yaml b/templates/certificate.yaml index e81c91360e..f927acd36c 100644 --- a/templates/certificate.yaml +++ b/templates/certificate.yaml @@ -1,3 +1,5 @@ +{{/* Certificate is meaningless without specified publicDomainTemplate. */}} +{{- if ne "" (dig "modules" "publicDomainTemplate" "" .Values.global) }} {{- if eq (include "helm_lib_module_https_mode" .) "CertManager" }} --- apiVersion: cert-manager.io/v1 @@ -15,4 +17,5 @@ spec: issuerRef: name: {{ include "helm_lib_module_https_cert_manager_cluster_issuer_name" . }} kind: ClusterIssuer -{{- end }} \ No newline at end of file +{{- end }} +{{- end }} diff --git a/templates/kubevirt/_kubevirt_helpers.tpl b/templates/kubevirt/_kubevirt_helpers.tpl index 2d5802c8e0..a9024c8929 100644 --- a/templates/kubevirt/_kubevirt_helpers.tpl +++ b/templates/kubevirt/_kubevirt_helpers.tpl @@ -61,4 +61,27 @@ spec: {{- define "kubevirt.delve_strategic_patch_json" -}} '{{ include "kubevirt.delve_strategic_patch" . | fromYaml | toJson }}' -{{- end }} \ No newline at end of file +{{- end }} + +{{/* Calculate parallel migrations per cluster. + This template returns: + - Count of nodes with virt-handler if kubevirt config is in 'Deployed' phase. + - Current parallelMigrationsPerCluster if config is not in 'Deployed' phase. + - Default migrations count (2) if there is no kubevirt config. + This behaviour prevents unnecessary helm installs during installation. + + Values from + */}} +{{- define "kubevirt.parallel_migrations_per_cluster" -}} +{{- $default := 2 -}} +{{- $phase := .Values.virtualization.internal | dig "virtConfig" "phase" "" -}} +{{- if eq $phase "" -}} +{{- $default -}} +{{- else -}} +{{- if eq $phase "Deployed" -}} +{{- max $default ( .Values.virtualization.internal | dig "virtHandler" "nodeCount" 0 ) -}} +{{- else -}} +{{- .Values.virtualization.internal | dig "virtConfig" "parallelMigrationsPerCluster" $default -}} +{{- end -}} +{{- end -}} +{{- end -}} diff --git a/templates/kubevirt/kubevirt.yaml b/templates/kubevirt/kubevirt.yaml index 7137f65a28..9ae7fb71a2 100644 --- a/templates/kubevirt/kubevirt.yaml +++ b/templates/kubevirt/kubevirt.yaml @@ -31,7 +31,7 @@ spec: migrations: bandwidthPerMigration: 640Mi completionTimeoutPerGiB: 800 - parallelMigrationsPerCluster: {{ .Values.virtualization.internal | dig "virtHandler" "nodeCount" 1 }} + parallelMigrationsPerCluster: {{ include "kubevirt.parallel_migrations_per_cluster" . }} parallelOutboundMigrationsPerNode: 1 progressTimeout: 150 smbios: diff --git a/templates/nodegroupconfiguration-selinux.yaml b/templates/nodegroupconfiguration-selinux.yaml index 48f839706b..1a81b02797 100644 --- a/templates/nodegroupconfiguration-selinux.yaml +++ b/templates/nodegroupconfiguration-selinux.yaml @@ -46,7 +46,7 @@ spec: # Install a package function install_package() { - bb-yum-install "$1" + bb-dnf-install "$1" } if is_selinux_enabled; then diff --git a/templates/virtualization-api/deployment.yaml b/templates/virtualization-api/deployment.yaml index 178810e23f..722a01b7b1 100644 --- a/templates/virtualization-api/deployment.yaml +++ b/templates/virtualization-api/deployment.yaml @@ -85,19 +85,19 @@ spec: {{- else }} - --v=3 {{- end }} - - --tls-cert-file=/etc/virtualziation-api/certificates/tls.crt - - --tls-private-key-file=/etc/virtualziation-api/certificates/tls.key - - --proxy-client-cert-file=/etc/virtualziation-api-proxy/certificates/tls.crt - - --proxy-client-key-file=/etc/virtualziation-api-proxy/certificates/tls.key + - --tls-cert-file=/etc/virtualization-api/certificates/tls.crt + - --tls-private-key-file=/etc/virtualization-api/certificates/tls.key + - --proxy-client-cert-file=/etc/virtualization-api-proxy/certificates/tls.crt + - --proxy-client-key-file=/etc/virtualization-api-proxy/certificates/tls.key - --service-account-name=virtualization-api - --service-account-namespace=d8-{{ .Chart.Name }} image: {{ include "helm_lib_module_image" (list . "virtualizationApi") }} imagePullPolicy: IfNotPresent volumeMounts: - - mountPath: /etc/virtualziation-api/certificates + - mountPath: /etc/virtualization-api/certificates name: virtualization-api-tls readOnly: true - - mountPath: /etc/virtualziation-api-proxy/certificates + - mountPath: /etc/virtualization-api-proxy/certificates name: virtualization-api-proxy-tls readOnly: true - mountPath: /etc/virt-api/certificates diff --git a/templates/virtualization-controller/_helpers.tpl b/templates/virtualization-controller/_helpers.tpl index a8f6abb981..764e0d2df8 100644 --- a/templates/virtualization-controller/_helpers.tpl +++ b/templates/virtualization-controller/_helpers.tpl @@ -58,8 +58,10 @@ true {{- end }} - name: VIRTUAL_MACHINE_IP_LEASES_RETENTION_DURATION value: "10m" +{{- if ne "" (dig "modules" "publicDomainTemplate" "" .Values.global) }} - name: UPLOADER_INGRESS_HOST value: {{ include "helm_lib_module_public_domain" (list . "virtualization") }} +{{- end }} {{- if (include "helm_lib_module_https_ingress_tls_enabled" .) }} - name: UPLOADER_INGRESS_TLS_SECRET value: {{ include "helm_lib_module_https_secret_name" (list . "ingress-tls") }} diff --git a/tools/kubeconform/fixtures/module-values.yaml b/tools/kubeconform/fixtures/module-values.yaml index ebb642eb37..c817ddfd6b 100644 --- a/tools/kubeconform/fixtures/module-values.yaml +++ b/tools/kubeconform/fixtures/module-values.yaml @@ -394,7 +394,11 @@ virtualization: - 10.0.10.0/24 - 10.0.20.0/24 - 10.0.30.0/24 - + virtConfig: + phase: Deployed + parallelMigrationsPerCluster: 2 + virtHandler: + nodeCount: 1 logLevel: debug registry: base: some-registry.io/sys/deckhouse-oss/modules diff --git a/tools/mounts/README.md b/tools/mounts/README.md new file mode 100644 index 0000000000..728514dae9 --- /dev/null +++ b/tools/mounts/README.md @@ -0,0 +1,3 @@ +# Mount primitives + +This dir contains empty dir and empty file to use as mountpoints in the images. diff --git a/tools/mounts/mountdir/.gitkeep b/tools/mounts/mountdir/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/mounts/mountfile b/tools/mounts/mountfile new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tools/validation/doc_changes.go b/tools/validation/doc_changes.go index b25967ece4..08c9c2c274 100644 --- a/tools/validation/doc_changes.go +++ b/tools/validation/doc_changes.go @@ -74,7 +74,7 @@ func RunDocChangesValidation(info *DiffInfo) (exitCode int) { } var possibleDocRootsRe = regexp.MustCompile(`docs/|docs/documentation`) -var docsDirAllowedFileRe = regexp.MustCompile(`docs/(CONFIGURATION|CR|FAQ|README|ADMIN_GUIDE|USER_GUIDE|CHARACTERISTICS_DESCRIPTION|INSTALL)(\.ru)?.md`) +var docsDirAllowedFileRe = regexp.MustCompile(`docs/(CONFIGURATION|CR|FAQ|README|ADMIN_GUIDE|USER_GUIDE|CHARACTERISTICS_DESCRIPTION|INSTALL|RELEASE_NOTES)(\.ru)?.md`) var docsDirFileRe = regexp.MustCompile(`docs/[^/]+.md`) func checkDocFile(fName string, diffInfo *DiffInfo) (msg Message) { @@ -93,6 +93,7 @@ Only following file names are allowed in the module '/docs/' directory: CR.md FAQ.md README.md + RELEASE_NOTES.md ADMIN_GUIDE.md USER_GUIDE.md CHARACTERISTICS_DESCRIPTION.md