From 4ada27800a5b867161b270a83716d206da2c3877 Mon Sep 17 00:00:00 2001 From: deckhouse-BOaTswain <89150800+deckhouse-BOaTswain@users.noreply.github.com> Date: Thu, 11 Sep 2025 12:41:57 +0300 Subject: [PATCH 01/12] Backport: chore(module): do not lock main queue on empty publicClusterDomain (#1451) chore(module): do not lock main queue on empty publicClusterDomain (#1440) Degrade gracefuly on empty settings.modules.publicClusterDomain (mc/global): - Do not create Certificate resource for cert-manager if publicClusterDomain is empty. - Do not show "external" URL in CVI/VI status, only "inCluster" URL. Also, "virt.deckhouse.io" annotation group is deprecated, use "virtualization.deckhouse.io" for external upload url on Ingress resource. Signed-off-by: Ivan Mikheykin Co-authored-by: Ivan Mikheykin --- .../pkg/common/annotations/annotations.go | 10 ++++++-- .../pkg/config/load_dvcr_settings.go | 6 ----- .../pkg/controller/service/stat_service.go | 4 +++- .../controller/service/uploader_service.go | 4 ++++ .../controller/uploader/uploader_ingress.go | 24 +++++++++++++------ templates/certificate.yaml | 5 +++- .../virtualization-controller/_helpers.tpl | 2 ++ 7 files changed, 38 insertions(+), 17 deletions(-) diff --git a/images/virtualization-artifact/pkg/common/annotations/annotations.go b/images/virtualization-artifact/pkg/common/annotations/annotations.go index d734de75cc..0404c2ca1e 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,11 @@ const ( // AnnVMOPEvacuation is an annotation on vmop that represents a vmop created by evacuation controller AnnVMOPEvacuation = AnnAPIGroupV + "/evacuation" + // 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/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/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/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/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/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") }} From 30d98e37af543f0844473fa1e14f3206ade06725 Mon Sep 17 00:00:00 2001 From: deckhouse-BOaTswain <89150800+deckhouse-BOaTswain@users.noreply.github.com> Date: Thu, 11 Sep 2025 12:44:32 +0300 Subject: [PATCH 02/12] Backport: chore(module): reduce module restarts during installation (#1452) chore(module): reduce module restarts during installation (#1445) * chore(module): reduce module restarts during installation - Run discover cluster IP hook before TLS certificate generator for DVCR hook. - Set updated value for parallelMigrationsPerCluster if kubevirt config is Deployed. Signed-off-by: Ivan Mikheykin Co-authored-by: Ivan Mikheykin --- .../hook.go | 3 +- .../hooks/discovery-workload-nodes/hook.go | 50 +++++++++++++++++++ openapi/values.yaml | 8 +++ templates/kubevirt/_kubevirt_helpers.tpl | 25 +++++++++- templates/kubevirt/kubevirt.yaml | 2 +- tools/kubeconform/fixtures/module-values.yaml | 6 ++- 6 files changed, 90 insertions(+), 4 deletions(-) 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/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/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/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 From 57693bc980bba0c65ced5580968a6559b50200e6 Mon Sep 17 00:00:00 2001 From: deckhouse-BOaTswain <89150800+deckhouse-BOaTswain@users.noreply.github.com> Date: Thu, 11 Sep 2025 12:47:23 +0300 Subject: [PATCH 03/12] Backport: chore(core): stable flags order for kubevirt components (#1453) chore(core): stable flags order for kubevirt components (#1449) chore(core): stable flags order for components Converting from map to array in flagsToArray may change flags order. This leads to unnecessary restarts of virt-handler, virt-controller, and virt-api. Signed-off-by: Ivan Mikheykin Co-authored-by: Ivan Mikheykin --- build/components/versions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/components/versions.yml b/build/components/versions.yml index 5fb98de68c..e14c8c3341 100644 --- a/build/components/versions.yml +++ b/build/components/versions.yml @@ -3,7 +3,7 @@ firmware: libvirt: v10.9.0 edk2: stable202411 core: - 3p-kubevirt: v1.3.1-v12n.10 + 3p-kubevirt: v1.3.1-v12n.11 3p-containerized-data-importer: v1.60.3-v12n.9 distribution: 2.8.3 package: From 052c2bbd3962a8523ded93f371ac258519aadb17 Mon Sep 17 00:00:00 2001 From: deckhouse-BOaTswain <89150800+deckhouse-BOaTswain@users.noreply.github.com> Date: Thu, 11 Sep 2025 12:49:25 +0300 Subject: [PATCH 04/12] Backport: feat(vmop): add Restore operation (#1454) feat(vmop): add Restore operation (#1307) Description This pull request implements a comprehensive VM restore operation feature. The changes include: Added new Restore operation type to VirtualMachineOperationType. Added maintenance condition support for VMs during restore operations Implemented restore controller with comprehensive resource management Added support for resource tracking during restore operations Enhanced CRD definitions with restore operation specifications The implementation provides three restore modes: DryRun: Validates compatibility without making changes Strict: Restores exactly as in the snapshot, failing if dependencies are missing BestEffort: Restores while handling missing external dependencies gracefully Why do we need it, and what problem does it solve? Virtual machine snapshots are essential for backup and disaster recovery scenarios, but without a proper restore mechanism, snapshots are only half of the solution. This PR implements a reliable way to restore VMs, ensuring that VMs can be recovered with proper dependency validation and conflict resolution. What is the expected result? Create restore operations using the new VirtualMachineOperation with type: Restore. Specify restore parameters including the source snapshot name and restore mode. Monitor restore progress through the operation status and resource tracking. Handle restore conflicts through different restore modes (Strict vs BestEffort). Validate restore compatibility using DryRun mode before actual restoration --------- Signed-off-by: Daniil Antoshin Co-authored-by: Daniil Antoshin Co-authored-by: Ivan Mikheykin --- api/core/v1alpha2/events.go | 3 + .../v1alpha2/virtual_machine_operation.go | 81 +++- api/core/v1alpha2/vmopcondition/condition.go | 48 ++ api/core/v1alpha2/zz_generated.deepcopy.go | 42 ++ .../generated/openapi/zz_generated.openapi.go | 108 ++++- crds/doc-ru-virtualmachineoperations.yaml | 36 +- crds/virtualmachineoperations.yaml | 59 +++ .../generated/openapi/zz_generated.openapi.go | 109 ++++- .../pkg/common/annotations/annotations.go | 5 + .../pkg/common/steptaker/runner.go | 2 +- .../pkg/common/testutil/testutil.go | 29 ++ .../service/restorer/common/common.go | 55 +++ .../controller/service/restorer/interfaces.go | 32 ++ .../restorers/provisioner_restorer.go | 131 ++++++ .../restorers/provisioner_restorer_test.go | 202 +++++++++ .../service/restorer/restorers/vd_restorer.go | 181 ++++++++ .../restorer/restorers/vd_restorer_test.go | 260 +++++++++++ .../service/restorer/restorers/vm_restorer.go | 282 ++++++++++++ .../restorer/restorers/vm_restorer_test.go | 424 ++++++++++++++++++ .../restorer/restorers/vmbda_restorer.go | 165 +++++++ .../restorer/restorers/vmbda_restorer_test.go | 315 +++++++++++++ .../restorer/restorers/vmip_restorer.go | 164 +++++++ .../restorer/restorers/vmip_restorer_test.go | 321 +++++++++++++ .../restorer/restorers/vmmac_restorer.go | 164 +++++++ .../restorer/restorers/vmmac_restorer_test.go | 317 +++++++++++++ .../{restorer.go => secret_restorer.go} | 68 +-- .../service/restorer/snapshot_resources.go | 317 +++++++++++++ .../pkg/controller/vm/internal/maintenance.go | 5 + .../validators/maintenance_validator.go | 54 --- .../pkg/controller/vm/vm_webhook.go | 1 - .../migration/internal/handler/lifecycle.go | 11 + .../powerstate/internal/handler/lifecycle.go | 11 + .../pkg/controller/vmop/service/service.go | 8 - .../vmop/snapshot/internal/common/common.go | 56 +++ .../snapshot/internal/handler/deletion.go | 110 +++++ .../internal/handler/deletion_test.go | 89 ++++ .../snapshot/internal/handler/lifecycle.go | 157 +++++++ .../vmop/snapshot/internal/handler/service.go | 33 ++ .../snapshot/internal/handler/suite_test.go | 59 +++ .../snapshot/internal/service/operation.go | 47 ++ .../vmop/snapshot/internal/service/restore.go | 141 ++++++ .../internal/step/enter_maintenance_step.go | 126 ++++++ .../internal/step/exit_maintenance_step.go | 124 +++++ .../snapshot/internal/step/process_step.go | 118 +++++ .../internal/step/vmsnapshot_ready_step.go | 95 ++++ .../snapshot/internal/watcher/predicates.go | 31 ++ .../vmop/snapshot/internal/watcher/vd.go | 98 ++++ .../vmop/snapshot/internal/watcher/vm.go | 80 ++++ .../vmop/snapshot/internal/watcher/vmbda.go | 98 ++++ .../vmop/snapshot/internal/watcher/vmop.go | 49 ++ .../vmop/snapshot/snapshot_controller.go | 73 +++ .../pkg/controller/vmop/vmop_controller.go | 6 +- .../vmrestore/internal/life_cycle.go | 4 +- .../internal/restorer/vm_restorer.go | 3 +- 54 files changed, 5499 insertions(+), 108 deletions(-) create mode 100644 images/virtualization-artifact/pkg/controller/service/restorer/common/common.go create mode 100644 images/virtualization-artifact/pkg/controller/service/restorer/interfaces.go create mode 100644 images/virtualization-artifact/pkg/controller/service/restorer/restorers/provisioner_restorer.go create mode 100644 images/virtualization-artifact/pkg/controller/service/restorer/restorers/provisioner_restorer_test.go create mode 100644 images/virtualization-artifact/pkg/controller/service/restorer/restorers/vd_restorer.go create mode 100644 images/virtualization-artifact/pkg/controller/service/restorer/restorers/vd_restorer_test.go create mode 100644 images/virtualization-artifact/pkg/controller/service/restorer/restorers/vm_restorer.go create mode 100644 images/virtualization-artifact/pkg/controller/service/restorer/restorers/vm_restorer_test.go create mode 100644 images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmbda_restorer.go create mode 100644 images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmbda_restorer_test.go create mode 100644 images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmip_restorer.go create mode 100644 images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmip_restorer_test.go create mode 100644 images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmmac_restorer.go create mode 100644 images/virtualization-artifact/pkg/controller/service/restorer/restorers/vmmac_restorer_test.go rename images/virtualization-artifact/pkg/controller/service/restorer/{restorer.go => secret_restorer.go} (79%) create mode 100644 images/virtualization-artifact/pkg/controller/service/restorer/snapshot_resources.go delete mode 100644 images/virtualization-artifact/pkg/controller/vm/internal/validators/maintenance_validator.go create mode 100644 images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/common/common.go create mode 100644 images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/deletion.go create mode 100644 images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/deletion_test.go create mode 100644 images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/lifecycle.go create mode 100644 images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/service.go create mode 100644 images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/handler/suite_test.go create mode 100644 images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/service/operation.go create mode 100644 images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/service/restore.go create mode 100644 images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/step/enter_maintenance_step.go create mode 100644 images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/step/exit_maintenance_step.go create mode 100644 images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/step/process_step.go create mode 100644 images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/step/vmsnapshot_ready_step.go create mode 100644 images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/predicates.go create mode 100644 images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/vd.go create mode 100644 images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/vm.go create mode 100644 images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/vmbda.go create mode 100644 images/virtualization-artifact/pkg/controller/vmop/snapshot/internal/watcher/vmop.go create mode 100644 images/virtualization-artifact/pkg/controller/vmop/snapshot/snapshot_controller.go 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/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/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/common/annotations/annotations.go b/images/virtualization-artifact/pkg/common/annotations/annotations.go index 0404c2ca1e..5c6a00b78d 100644 --- a/images/virtualization-artifact/pkg/common/annotations/annotations.go +++ b/images/virtualization-artifact/pkg/common/annotations/annotations.go @@ -98,6 +98,11 @@ 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. 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/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/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/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) } } } From 9bddfe4a54fa48cf86ed4971265cc236df0fff0b Mon Sep 17 00:00:00 2001 From: deckhouse-BOaTswain <89150800+deckhouse-BOaTswain@users.noreply.github.com> Date: Thu, 11 Sep 2025 14:29:21 +0300 Subject: [PATCH 05/12] Backport: docs: update due v1.0.0 (#1455) docs: update due v1.0.0 (#1406) Signed-off-by: Pavel Tishkov Signed-off-by: Vladislav Panfilov Co-authored-by: Pavel Tishkov Co-authored-by: Vladislav Panfilov --- README.md | 10 +- docs/INSTALL.md | 28 ++--- docs/INSTALL.ru.md | 28 ++--- docs/RELEASE_NOTES.md | 54 ++++++++++ docs/RELEASE_NOTES.ru.md | 51 ++++++++++ docs/USER_GUIDE.md | 151 ++++++++------------------- docs/USER_GUIDE.ru.md | 152 ++++++++-------------------- docs/images/vm-restore-clone.drawio | 90 +++------------- docs/images/vm-restore-clone.png | Bin 75537 -> 51038 bytes docs/images/vm-restore-clone.ru.png | Bin 75537 -> 51038 bytes tools/validation/doc_changes.go | 3 +- 11 files changed, 242 insertions(+), 325 deletions(-) create mode 100644 docs/RELEASE_NOTES.md create mode 100644 docs/RELEASE_NOTES.ru.md 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/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 a57ae0f76f183ba17d995ef3534ee09dd979049e..67e237ffd62031451a3bd1793614fc36bdea39e9 100644 GIT binary patch literal 51038 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

h3M4cM8KmD-bq=1Z?A%U_>z#k6(M0 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

h3M4cM8KmD-bq=1Z?A%U_>z#k6(M0 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 Date: Thu, 11 Sep 2025 18:24:59 +0300 Subject: [PATCH 06/12] Backport: Changelog v1.0.0 (#1458) Changelog v1.0.0 (#1429) Signed-off-by: deckhouse-BOaTswain <89150800+deckhouse-boatswain@users.noreply.github.com> Co-authored-by: nevermarine --- CHANGELOG/CHANGELOG-v1.0.0.yml | 45 ++++++++++++++++++++++++++++++++++ CHANGELOG/CHANGELOG-v1.0.md | 29 ++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 CHANGELOG/CHANGELOG-v1.0.0.yml create mode 100644 CHANGELOG/CHANGELOG-v1.0.md 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) + From 2aba26a92f79c0904918906c40a5aa4817f4e2cc Mon Sep 17 00:00:00 2001 From: deckhouse-BOaTswain <89150800+deckhouse-BOaTswain@users.noreply.github.com> Date: Mon, 22 Sep 2025 15:50:47 +0300 Subject: [PATCH 07/12] Backport: chore(module): rename containers to support integrity checks (#1483) chore(module): rename containers to support integrity checks 1. Rename containers of kubevirt Pods, cdi Pods and dvcr Pods created in non-system namespaces (namespaces without d8- prefix). 2. Mount container-disk binary into /var/run in container with user uploaded image (support attaching cvi, vi to vm). Related PRs: https://github.com/deckhouse/3p-kubevirt/pull/19 https://github.com/deckhouse/3p-containerized-data-importer/pull/17 Signed-off-by: Ivan Mikheykin Co-authored-by: Ivan Mikheykin --- .werf/defines/images.tmpl | 2 +- build/components/versions.yml | 4 ++-- .../pkg/audit/events/vm/vm_control.go | 3 ++- .../pkg/audit/events/vm/vm_control_test.go | 4 ++-- images/virtualization-artifact/pkg/common/consts.go | 6 +++--- images/virtualization-artifact/pkg/common/vm/vm.go | 10 ++++++++++ .../pkg/controller/powerstate/shutdown_reason.go | 10 ++++------ .../pkg/controller/vm/internal/statistic.go | 2 +- .../pkg/controller/vm/internal/statistic_test.go | 2 +- 9 files changed, 26 insertions(+), 17 deletions(-) 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/build/components/versions.yml b/build/components/versions.yml index e14c8c3341..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.11 - 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/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/consts.go b/images/virtualization-artifact/pkg/common/consts.go index fb16ef545b..9558583725 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 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/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/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), From 52bb4125b146f70fee9769122ccbd670f217e636 Mon Sep 17 00:00:00 2001 From: deckhouse-BOaTswain <89150800+deckhouse-BOaTswain@users.noreply.github.com> Date: Mon, 22 Sep 2025 15:51:00 +0300 Subject: [PATCH 08/12] Backport: chore(core): fix build glib2 (#1484) chore(core): fix build glib2 (#1478) chore(core): fix build packages Signed-off-by: Nikita Korolev Co-authored-by: Nikita Korolev <141920865+universal-itengineer@users.noreply.github.com> --- images/packages/glib2/werf.inc.yaml | 4 ---- 1 file changed, 4 deletions(-) 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}} From 340c1b29ad8792e41c6a45c8142ac4bbe48f83fe Mon Sep 17 00:00:00 2001 From: deckhouse-BOaTswain <89150800+deckhouse-BOaTswain@users.noreply.github.com> Date: Mon, 22 Sep 2025 15:51:57 +0300 Subject: [PATCH 09/12] Backport: chore(module): fix install packages via dnf and yum (#1485) chore(module): fix install packages via dnf and yum (#1464) Signed-off-by: Nikita Korolev Co-authored-by: Nikita Korolev <141920865+universal-itengineer@users.noreply.github.com> --- templates/nodegroupconfiguration-selinux.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From d8ebeb5d5bde8d443dbd9d28346a71070532ebde Mon Sep 17 00:00:00 2001 From: deckhouse-BOaTswain <89150800+deckhouse-BOaTswain@users.noreply.github.com> Date: Mon, 22 Sep 2025 15:58:17 +0300 Subject: [PATCH 10/12] Backport: fix(docs): english documentation remove cyrillic characters cdi_kubevirt_patching (#1486) fix(docs): english documentation remove cyrillic characters cdi_kubevirt_patching (#1481) Signed-off-by: Nikita Korolev Co-authored-by: Nikita Korolev <141920865+universal-itengineer@users.noreply.github.com> --- docs/internal/cdi_kubevirt_patching.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From c793d5ae38ae3c1304f14ff1eda8e636d8ac4941 Mon Sep 17 00:00:00 2001 From: deckhouse-BOaTswain <89150800+deckhouse-BOaTswain@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:01:57 +0300 Subject: [PATCH 11/12] Backport: chore(module): dedup lines on live migration memory graph (#1487) chore(module): dedup lines on live migration memory graph (#1474) chore(module): live migration graph: combine lines Combine legends and unify line colors for different migrations on live migration memory graph. - Drop some labels to not split graph lines. - Use bytes/sec(SI) unit. Signed-off-by: Ivan Mikheykin Co-authored-by: Ivan Mikheykin --- monitoring/grafana-dashboards/main/virtual-machine.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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", From 05fe2f1c6d14b22b52e4f9b4082c4a75d0907e98 Mon Sep 17 00:00:00 2001 From: Ivan Mikheykin Date: Mon, 22 Sep 2025 16:11:01 +0300 Subject: [PATCH 12/12] Backport: chore(module): add pre-created mount points to images (#1488) chore(module): add pre-created mount points to images Images with pre-created mount points: - cdi-apiserver - cdi-cloner - cdi-controller - cdi-importer - cdi-operator - dvcr - dvcr-importer - dvcr-uploader - kube-api-rewriter - virt-api - virt-controller - virt-handler - virt-launcher - virt-operator - virtualization-api - virtualization-audit - virtualization-controller - hp pods Some notes: - Create /var/run subdirectories in /run, as /var/run is a symlink to ../run. - Add /var, /run and symlink /var/run -> ../run in 'distroless' base image. - Pre-create /var, /run and symlink /var/run -> ../run in kube-api-rewriter image. - Remove unused extraheaders settings in dvcr-importer and dvcr-uploader. (cherry picked from commit 7344c01d155c64086442f4361f83c2e0fb0b1983) Signed-off-by: Nikita Korolev Signed-off-by: Ivan Mikheykin Co-authored-by: YuryLysov Co-authored-by: Nikita Korolev --- .prettierignore | 1 + .werf/defines/image-mountpoints.tmpl | 32 +++++++++++++ images/cdi-apiserver/mount-points.yaml | 7 +++ images/cdi-apiserver/werf.inc.yaml | 2 + images/cdi-cloner/mount-points.yaml | 7 +++ images/cdi-cloner/werf.inc.yaml | 2 + images/cdi-controller/mount-points.yaml | 13 +++++ images/cdi-controller/werf.inc.yaml | 2 + images/cdi-importer/mount-points.yaml | 17 +++++++ images/cdi-importer/werf.inc.yaml | 2 + images/cdi-operator/mount-points.yaml | 4 ++ images/cdi-operator/werf.inc.yaml | 2 + images/distroless/werf.inc.yaml | 10 +++- images/dvcr-importer/mount-points.yaml | 7 +++ images/dvcr-importer/werf.inc.yaml | 2 + images/dvcr-uploader/mount-points.yaml | 4 ++ images/dvcr-uploader/werf.inc.yaml | 2 + images/dvcr/mount-points.yaml | 7 +++ images/dvcr/werf.inc.yaml | 2 + images/kube-api-rewriter/mount-points.yaml | 7 +++ images/kube-api-rewriter/werf.inc.yaml | 11 ++++- images/virt-api/mount-points.yaml | 10 ++++ images/virt-api/werf.inc.yaml | 2 + images/virt-controller/mount-points.yaml | 7 +++ images/virt-controller/werf.inc.yaml | 2 + images/virt-handler/mount-points.yaml | 21 ++++++++ images/virt-handler/werf.inc.yaml | 2 + images/virt-launcher/mount-points.yaml | 48 +++++++++++++++++++ images/virt-launcher/werf.inc.yaml | 2 + images/virt-operator/mount-points.yaml | 6 +++ images/virt-operator/werf.inc.yaml | 2 + images/virtualization-api/mount-points.yaml | 6 +++ images/virtualization-api/werf.inc.yaml | 2 + .../pkg/common/consts.go | 6 --- .../pkg/controller/importer/importer_pod.go | 20 -------- .../pkg/controller/importer/settings.go | 1 - .../pkg/controller/uploader/settings.go | 1 - .../pkg/controller/uploader/uploader_pod.go | 20 -------- images/virtualization-audit/mount-points.yaml | 4 ++ images/virtualization-audit/werf.inc.yaml | 2 + .../mount-points.yaml | 5 ++ .../virtualization-controller/werf.inc.yaml | 2 + templates/virtualization-api/deployment.yaml | 12 ++--- tools/mounts/README.md | 3 ++ tools/mounts/mountdir/.gitkeep | 0 tools/mounts/mountfile | 0 46 files changed, 272 insertions(+), 57 deletions(-) create mode 100644 .werf/defines/image-mountpoints.tmpl create mode 100644 images/cdi-apiserver/mount-points.yaml create mode 100644 images/cdi-cloner/mount-points.yaml create mode 100644 images/cdi-controller/mount-points.yaml create mode 100644 images/cdi-importer/mount-points.yaml create mode 100644 images/cdi-operator/mount-points.yaml create mode 100644 images/dvcr-importer/mount-points.yaml create mode 100644 images/dvcr-uploader/mount-points.yaml create mode 100644 images/dvcr/mount-points.yaml create mode 100644 images/kube-api-rewriter/mount-points.yaml create mode 100644 images/virt-api/mount-points.yaml create mode 100644 images/virt-controller/mount-points.yaml create mode 100644 images/virt-handler/mount-points.yaml create mode 100644 images/virt-launcher/mount-points.yaml create mode 100644 images/virt-operator/mount-points.yaml create mode 100644 images/virtualization-api/mount-points.yaml create mode 100644 images/virtualization-audit/mount-points.yaml create mode 100644 images/virtualization-controller/mount-points.yaml create mode 100644 tools/mounts/README.md create mode 100644 tools/mounts/mountdir/.gitkeep create mode 100644 tools/mounts/mountfile 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/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/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/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/common/consts.go b/images/virtualization-artifact/pkg/common/consts.go index 9558583725..1a78a19bf3 100644 --- a/images/virtualization-artifact/pkg/common/consts.go +++ b/images/virtualization-artifact/pkg/common/consts.go @@ -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/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/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_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-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/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/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