diff --git a/Makefile b/Makefile index 2190e3914a..b05afbaa81 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ DEFAULT_VERSION := 99.0.0 VERSION ?= $(DEFAULT_VERSION) # the version of the operator OPERATOR_SDK_VERSION ?= v1.35.0 ENVTEST_K8S_VERSION = 1.32 #refers to the version of kubebuilder assets to be downloaded by envtest binary # Kubernetes version from OpenShift 4.19.x -GOLANGCI_LINT_VERSION ?= v2.1.2 +GOLANGCI_LINT_VERSION ?= v2.6.1 KUSTOMIZE_VERSION ?= v5.2.1 CONTROLLER_TOOLS_VERSION ?= v0.16.5 OPM_VERSION ?= v1.23.0 @@ -47,7 +47,7 @@ IMAGE_TAG_BASE ?= openshift.io/oadp-operator BUNDLE_IMG ?= $(IMAGE_TAG_BASE)-bundle:v$(VERSION) # BUNDLE_GEN_FLAGS are the flags passed to the operator-sdk generate bundle command -BUNDLE_GEN_FLAGS ?= -q --extra-service-accounts "velero,non-admin-controller" --overwrite --version $(VERSION) $(BUNDLE_METADATA_OPTS) +BUNDLE_GEN_FLAGS ?= -q --extra-service-accounts "velero,non-admin-controller,oadp-vm-file-restore-controller-manager" --overwrite --version $(VERSION) $(BUNDLE_METADATA_OPTS) # USE_IMAGE_DIGESTS defines if images are resolved via tags or digests # You can enable this value if you would like to use SHA Based Digests diff --git a/README.md b/README.md index b870d50e59..d3a76cc073 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ Documentation in this repository are considered unofficial and for development p 5. [Use NooBaa as a Backup Storage Location](docs/config/noobaa/install_oadp_noobaa.md) 6. [Use Velero --features flag](docs/config/features_flag.md) 7. [Use Custom Plugin Images for Velero ](docs/config/custom_plugin_images.md) + 8. [Enable VM File Restore](docs/config/vm_file_restore.md) 5. Examples 1. [Sample Apps used in OADP CI](https://github.com/openshift/oadp-operator/tree/oadp-dev/tests/e2e/sample-applications) 2. [Stateless App Backup/Restore](docs/examples/stateless.md) diff --git a/api/v1alpha1/dataprotectionapplication_types.go b/api/v1alpha1/dataprotectionapplication_types.go index 75aa1e3808..4253846ae5 100644 --- a/api/v1alpha1/dataprotectionapplication_types.go +++ b/api/v1alpha1/dataprotectionapplication_types.go @@ -73,6 +73,10 @@ const GCPPluginImageKey UnsupportedImageKey = "gcpPluginImageFqin" const KubeVirtPluginImageKey UnsupportedImageKey = "kubevirtPluginImageFqin" const HypershiftPluginImageKey UnsupportedImageKey = "hypershiftPluginImageFqin" const NonAdminControllerImageKey UnsupportedImageKey = "nonAdminControllerImageFqin" +const VMFileRestoreControllerImageKey UnsupportedImageKey = "vmFileRestoreControllerImageFqin" +const VMFileRestoreAccessImageKey UnsupportedImageKey = "vmFileRestoreAccessImageFqin" +const VMFileRestoreSSHImageKey UnsupportedImageKey = "vmFileRestoreSSHImageFqin" +const VMFileRestoreBrowserImageKey UnsupportedImageKey = "vmFileRestoreBrowserImageFqin" const OperatorTypeKey UnsupportedImageKey = "operator-type" const OperatorTypeMTC = "mtc" @@ -728,6 +732,18 @@ type NonAdmin struct { BackupSyncPeriod *metav1.Duration `json:"backupSyncPeriod,omitempty"` } +// VMFileRestore defines VM file restore feature configuration +type VMFileRestore struct { + // Enable flag to deploy VM file restore controller + // By default is disabled + // +optional + Enable *bool `json:"enable,omitempty"` + + // Resource requirements for the VM file restore controller + // +optional + Resources *corev1.ResourceRequirements `json:"resources,omitempty"` +} + // DataMover defines the various config for DPA data mover type DataMover struct { // enable flag is used to specify whether you want to deploy the volume snapshot mover controller @@ -840,6 +856,10 @@ type DataProtectionApplicationSpec struct { // - kubevirtPluginImageFqin // - hypershiftPluginImageFqin // - nonAdminControllerImageFqin + // - vmFileRestoreControllerImageFqin + // - vmFileRestoreAccessImageFqin + // - vmFileRestoreSSHImageFqin + // - vmFileRestoreBrowserImageFqin // - operator-type // - tech-preview-ack // +optional @@ -873,6 +893,9 @@ type DataProtectionApplicationSpec struct { // nonAdmin defines the configuration for the DPA to enable backup and restore operations for non-admin users // +optional NonAdmin *NonAdmin `json:"nonAdmin,omitempty"` + // vmFileRestore defines the configuration for the DPA to enable VM file restore feature + // +optional + VMFileRestore *VMFileRestore `json:"vmFileRestore,omitempty"` // The format for log output. Valid values are text, json. (default text) // +kubebuilder:validation:Enum=text;json // +kubebuilder:default=text diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 8b88717200..c614e51c92 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -464,6 +464,11 @@ func (in *DataProtectionApplicationSpec) DeepCopyInto(out *DataProtectionApplica *out = new(NonAdmin) (*in).DeepCopyInto(*out) } + if in.VMFileRestore != nil { + in, out := &in.VMFileRestore, &out.VMFileRestore + *out = new(VMFileRestore) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataProtectionApplicationSpec. @@ -1259,6 +1264,31 @@ func (in *UploadTestStatus) DeepCopy() *UploadTestStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VMFileRestore) DeepCopyInto(out *VMFileRestore) { + *out = *in + if in.Enable != nil { + in, out := &in.Enable, &out.Enable + *out = new(bool) + **out = **in + } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = new(corev1.ResourceRequirements) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VMFileRestore. +func (in *VMFileRestore) DeepCopy() *VMFileRestore { + if in == nil { + return nil + } + out := new(VMFileRestore) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VeleroConfig) DeepCopyInto(out *VeleroConfig) { *out = *in diff --git a/bundle/manifests/oadp-operator.clusterserviceversion.yaml b/bundle/manifests/oadp-operator.clusterserviceversion.yaml index a0e19c2fca..cf45ed4573 100644 --- a/bundle/manifests/oadp-operator.clusterserviceversion.yaml +++ b/bundle/manifests/oadp-operator.clusterserviceversion.yaml @@ -688,6 +688,12 @@ spec: displayName: Plugins path: plugins version: v1 + - kind: VirtualMachineBackupsDiscovery + name: virtualmachinebackupsdiscoveries.oadp.openshift.io + version: v1alpha1 + - kind: VirtualMachineFileRestore + name: virtualmachinefilerestores.oadp.openshift.io + version: v1alpha1 - description: VolumeSnapshotLocation is a location where Velero stores volume snapshots. displayName: VolumeSnapshotLocation @@ -835,6 +841,149 @@ spec: verbs: - get serviceAccountName: non-admin-controller + - rules: + - apiGroups: + - "" + resources: + - events + - namespaces + - pods + - secrets + - serviceaccounts + - services + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - "" + resources: + - persistentvolumeclaims + verbs: + - get + - list + - watch + - apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete + - apiGroups: + - oadp.openshift.io + resources: + - virtualmachinebackupsdiscoveries + - virtualmachinefilerestores + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - oadp.openshift.io + resources: + - virtualmachinebackupsdiscoveries/finalizers + - virtualmachinefilerestores/finalizers + verbs: + - update + - apiGroups: + - oadp.openshift.io + resources: + - virtualmachinebackupsdiscoveries/status + - virtualmachinefilerestores/status + verbs: + - get + - patch + - update + - apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - route.openshift.io + resources: + - routes + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - velero.io + resources: + - backups + verbs: + - get + - list + - watch + - apiGroups: + - velero.io + resources: + - datadownloads + verbs: + - get + - list + - patch + - watch + - apiGroups: + - velero.io + resources: + - downloadrequests + verbs: + - create + - delete + - get + - list + - watch + - apiGroups: + - velero.io + resources: + - restores + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + serviceAccountName: oadp-vm-file-restore-controller-manager - rules: - apiGroups: - "" @@ -1135,6 +1284,14 @@ spec: value: registry.redhat.io/oadp/oadp-mustgather-rhel8:v1.2 - name: RELATED_IMAGE_NON_ADMIN_CONTROLLER value: quay.io/konveyor/oadp-non-admin:latest + - name: RELATED_IMAGE_VM_FILE_RESTORE_CONTROLLER + value: quay.io/konveyor/oadp-vm-file-restore:latest + - name: RELATED_IMAGE_VM_FILE_RESTORE_ACCESS + value: quay.io/konveyor/oadp-vmfr-access:latest + - name: RELATED_IMAGE_VM_FILE_RESTORE_SSH + value: quay.io/konveyor/oadp-vmfr-access-sshd:latest + - name: RELATED_IMAGE_VM_FILE_RESTORE_BROWSER + value: quay.io/konveyor/oadp-vmfr-access-filebrowser:latest image: quay.io/konveyor/oadp-operator:latest imagePullPolicy: Always livenessProbe: @@ -1297,4 +1454,12 @@ spec: name: mustgather - image: quay.io/konveyor/oadp-non-admin:latest name: non-admin-controller + - image: quay.io/konveyor/oadp-vm-file-restore:latest + name: vm-file-restore-controller + - image: quay.io/konveyor/oadp-vmfr-access:latest + name: vm-file-restore-access + - image: quay.io/konveyor/oadp-vmfr-access-sshd:latest + name: vm-file-restore-ssh + - image: quay.io/konveyor/oadp-vmfr-access-filebrowser:latest + name: vm-file-restore-browser version: 99.0.0 diff --git a/bundle/manifests/oadp.openshift.io_dataprotectionapplications.yaml b/bundle/manifests/oadp.openshift.io_dataprotectionapplications.yaml index 9a57440e1e..a74be011ff 100644 --- a/bundle/manifests/oadp.openshift.io_dataprotectionapplications.yaml +++ b/bundle/manifests/oadp.openshift.io_dataprotectionapplications.yaml @@ -2669,9 +2669,81 @@ spec: - kubevirtPluginImageFqin - hypershiftPluginImageFqin - nonAdminControllerImageFqin + - vmFileRestoreControllerImageFqin + - vmFileRestoreAccessImageFqin + - vmFileRestoreSSHImageFqin + - vmFileRestoreBrowserImageFqin - operator-type - tech-preview-ack type: object + vmFileRestore: + description: vmFileRestore defines the configuration for the DPA to enable VM file restore feature + properties: + enable: + description: |- + Enable flag to deploy VM file restore controller + By default is disabled + type: boolean + resources: + description: Resource requirements for the VM file restore controller + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object required: - configuration type: object diff --git a/bundle/manifests/oadp.openshift.io_virtualmachinebackupsdiscoveries.yaml b/bundle/manifests/oadp.openshift.io_virtualmachinebackupsdiscoveries.yaml new file mode 100644 index 0000000000..718a621e70 --- /dev/null +++ b/bundle/manifests/oadp.openshift.io_virtualmachinebackupsdiscoveries.yaml @@ -0,0 +1,417 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + creationTimestamp: null + name: virtualmachinebackupsdiscoveries.oadp.openshift.io +spec: + group: oadp.openshift.io + names: + kind: VirtualMachineBackupsDiscovery + listKind: VirtualMachineBackupsDiscoveryList + plural: virtualmachinebackupsdiscoveries + shortNames: + - vmbd + singular: virtualmachinebackupsdiscovery + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.virtualMachineName + name: VM + type: string + - jsonPath: .spec.virtualMachineNamespace + name: VMNS + type: string + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.discoveryStats.completed + name: Valid + type: integer + - jsonPath: .status.discoveryStats.skipped + name: Invalid + type: integer + name: v1alpha1 + schema: + openAPIV3Schema: + description: VirtualMachineBackupsDiscovery is the Schema for the virtualmachinebackupsdiscoveries + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the desired state of VirtualMachineBackupsDiscovery + properties: + endTime: + description: |- + Only include backups created before this time (optional time range filtering). + Supports both date-only (YYYY-MM-DD) and full RFC3339 (YYYY-MM-DDTHH:MM:SSZ) formats. + Date-only format defaults to end of day (23:59:59Z). + type: string + requestedBackups: + description: |- + Specific backup names to include in addition to any time-based filtering. + If specified, these backups will be included even if they fall outside the time range. + items: + type: string + type: array + startTime: + description: |- + Only include backups created after this time (optional time range filtering). + Supports both date-only (YYYY-MM-DD) and full RFC3339 (YYYY-MM-DDTHH:MM:SSZ) formats. + Date-only format defaults to start of day (00:00:00Z). + type: string + virtualMachineName: + description: Name of the VirtualMachine to discover backups for. + minLength: 1 + type: string + virtualMachineNamespace: + description: Namespace where the VirtualMachine is located. + minLength: 1 + type: string + required: + - virtualMachineName + - virtualMachineNamespace + type: object + status: + description: status defines the observed state of VirtualMachineBackupsDiscovery + properties: + backupDiscoveryProgress: + description: Detailed discovery progress for each candidate backup. + items: + description: BackupDiscoveryProgress contains detailed information + about backup discovery progress + properties: + createdAt: + description: |- + When the backup was taken (from backup.status.completionTimestamp). + For synced backups, this reflects when the backup actually completed, not when it was imported. + format: date-time + type: string + lastUpdated: + description: When this backup's discovery status was last updated. + format: date-time + type: string + message: + description: Human-readable message about the discovery status. + maxLength: 1024 + type: string + name: + description: Name of the backup resource. + type: string + namespace: + description: Namespace is the namespace of the backup resource + type: string + pvcs: + description: |- + PVCs contains the list of PVCs available in this backup + For a given VM + This field is populated during file restore processing + items: + description: |- + PVCInfo represents a PVC from a backup and all restores associated with it. + The combination of PVCUID + PVCName ensures uniqueness across multiple backups. + properties: + pvcName: + description: Name of the PVC at the time of the backup + type: string + pvcNamespace: + description: Namespace of the PVC at the time of the backup + type: string + pvcUID: + description: UID of the PVC at the time of the backup + type: string + size: + description: Size of the PVC in human-readable format + (e.g., "5Gi", "30Gi") + type: string + required: + - pvcName + - pvcNamespace + - pvcUID + type: object + type: array + status: + description: Current status of backup discovery for this backup. + enum: + - New + - InProgress + - Completed + - Skipped + - Failed + type: string + required: + - name + - namespace + - status + type: object + type: array + conditions: + description: |- + Conditions represent the current state of the VirtualMachineBackupsDiscovery resource. + This is the PRIMARY source of truth for resource state. + Each condition has a unique type and reflects the status of a specific aspect of the resource. + + Standard condition types for this resource (defined in types package): + - types.ConditionTypeProgressing: Discovery is actively running + - types.ConditionTypeAvailable: Valid backups are available for use + - types.ConditionTypeDegraded: Partial failures occurred (may still be usable) + - types.ConditionTypeReady: Summary condition (resource is usable) + + The status of each condition is one of True, False, or Unknown. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + discoveryStats: + description: Summary statistics about the backup discovery process. + properties: + completed: + minimum: 0 + type: integer + completionTime: + format: date-time + type: string + failed: + minimum: 0 + type: integer + inProgress: + minimum: 0 + type: integer + pending: + minimum: 0 + type: integer + skipped: + minimum: 0 + type: integer + startTime: + format: date-time + type: string + totalCandidates: + minimum: 0 + type: integer + required: + - completed + - failed + - inProgress + - pending + - skipped + - totalCandidates + type: object + invalidBackups: + description: Requested backups that don't contain the VM (only populated + when RequestedBackups is used). + items: + description: InvalidBackupInfo contains information about a backup + that doesn't contain the VM + properties: + createdAt: + description: |- + When the backup was taken (from backup.status.completionTimestamp). + For synced backups, this reflects when the backup actually completed, not when it was imported. + format: date-time + type: string + name: + description: Name of the backup resource. + type: string + namespace: + description: Namespace is the namespace of the backup resource + type: string + pvcs: + description: |- + PVCs contains the list of PVCs available in this backup + For a given VM + This field is populated during file restore processing + items: + description: |- + PVCInfo represents a PVC from a backup and all restores associated with it. + The combination of PVCUID + PVCName ensures uniqueness across multiple backups. + properties: + pvcName: + description: Name of the PVC at the time of the backup + type: string + pvcNamespace: + description: Namespace of the PVC at the time of the backup + type: string + pvcUID: + description: UID of the PVC at the time of the backup + type: string + size: + description: Size of the PVC in human-readable format + (e.g., "5Gi", "30Gi") + type: string + required: + - pvcName + - pvcNamespace + - pvcUID + type: object + type: array + reason: + description: Reason why this backup doesn't contain the VM or + couldn't be processed. + maxLength: 1024 + type: string + required: + - name + - namespace + type: object + type: array + observedGeneration: + description: |- + ObservedGeneration represents the .metadata.generation that the status was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.observedGeneration is 9, + the status is out of date with respect to the current state of the instance. + + IMPORTANT: Controllers must set this at the START of reconciliation, not at the end. + This prevents race conditions where clients see updated conditions but stale observedGeneration. + format: int64 + type: integer + phase: + description: |- + Phase indicates the overall phase of the backup discovery operation. + Derived from conditions for human readability. Matches Velero's phase model. + Automation should rely on conditions, not phase. + enum: + - New + - InProgress + - Completed + - PartiallyFailed + - Failed + type: string + validBackups: + description: Backups that contain the specified virtual machine and + are ready for file serving. + items: + description: VeleroBackupInfo contains information about a discovered + backup + properties: + createdAt: + description: |- + When the backup was taken (from backup.status.completionTimestamp). + For synced backups, this reflects when the backup actually completed, not when it was imported. + format: date-time + type: string + name: + description: Name of the backup resource. + type: string + namespace: + description: Namespace is the namespace of the backup resource + type: string + pvcs: + description: |- + PVCs contains the list of PVCs available in this backup + For a given VM + This field is populated during file restore processing + items: + description: |- + PVCInfo represents a PVC from a backup and all restores associated with it. + The combination of PVCUID + PVCName ensures uniqueness across multiple backups. + properties: + pvcName: + description: Name of the PVC at the time of the backup + type: string + pvcNamespace: + description: Namespace of the PVC at the time of the backup + type: string + pvcUID: + description: UID of the PVC at the time of the backup + type: string + size: + description: Size of the PVC in human-readable format + (e.g., "5Gi", "30Gi") + type: string + required: + - pvcName + - pvcNamespace + - pvcUID + type: object + type: array + required: + - name + - namespace + type: object + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: null + storedVersions: null diff --git a/bundle/manifests/oadp.openshift.io_virtualmachinefilerestores.yaml b/bundle/manifests/oadp.openshift.io_virtualmachinefilerestores.yaml new file mode 100644 index 0000000000..2f346583f7 --- /dev/null +++ b/bundle/manifests/oadp.openshift.io_virtualmachinefilerestores.yaml @@ -0,0 +1,444 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + creationTimestamp: null + name: virtualmachinefilerestores.oadp.openshift.io +spec: + group: oadp.openshift.io + names: + kind: VirtualMachineFileRestore + listKind: VirtualMachineFileRestoreList + plural: virtualmachinefilerestores + shortNames: + - vmfr + singular: virtualmachinefilerestore + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .spec.backupsDiscoveryRef + name: Discovery + type: string + - jsonPath: .status.fileServingInfo.podName + name: Pod + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: VirtualMachineFileRestore is the Schema for the virtualmachinefilerestores + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the desired state of VirtualMachineFileRestore + properties: + backupsDiscoveryRef: + description: |- + Reference to the VirtualMachineBackupsDiscovery resource in the same namespace + that contains the discovered backups to serve files from. + minLength: 1 + type: string + fileAccess: + description: |- + FileAccess defines which file access methods are enabled for this restore. + If not specified, defaults to HTTP file browser only. + properties: + fileBrowser: + description: |- + FileBrowser enables HTTPS web-based file browser access + If present (non-nil), FileBrowser access is enabled + properties: + credentialsSecretRef: + description: |- + CredentialsSecretRef references a Secret containing FileBrowser credentials. + The Secret must have a "password" key and optionally a "username" key. + If "username" is not provided in the Secret, it defaults to "oadp". + If CredentialsSecretRef is not specified, the controller generates both + username (defaults to "oadp") and password, storing them in a Secret + in the temporary restore namespace. + properties: + name: + description: Name of the Secret + minLength: 1 + type: string + namespace: + description: |- + Namespace where the Secret is located. + Defaults to the OADP namespace when not specified. + Note: Secrets outside TemporaryRestoreNamespace are automatically + copied to that namespace for mounting in the serving pod. + type: string + required: + - name + type: object + exposeExternally: + description: |- + ExposeExternally enables creation of an OpenShift Route for the FileBrowser service. + When enabled, creates a Route with reencrypt TLS termination for external HTTPS access. + Only effective on OpenShift clusters. + type: boolean + type: object + ssh: + description: |- + SSH provides read-only access to restored files via chrooted OpenSSH. + Supports SFTP, SCP, and rsync for file transfer only (no interactive shell access). + The SSH server runs in a chroot environment for security isolation. + properties: + credentialsSecretRef: + description: |- + CredentialsSecretRef references a Secret containing SSH authentication credentials. + The Secret must have an "authorized_keys" key for SSH key-based authentication. + The "username" key is optional and defaults to "oadp" if not provided. + Note: Only SSH key-based authentication is supported; password authentication is not available. + Takes precedence over inline Username and PublicKey fields. + properties: + name: + description: Name of the Secret + minLength: 1 + type: string + namespace: + description: |- + Namespace where the Secret is located. + Defaults to the OADP namespace when not specified. + Note: Secrets outside TemporaryRestoreNamespace are automatically + copied to that namespace for mounting in the serving pod. + type: string + required: + - name + type: object + publicKey: + description: |- + PublicKey for SSH key-based authentication + Public keys are not sensitive and can be specified inline + If both PublicKey and CredentialsSecretRef are empty, controller generates keypair + type: string + username: + description: |- + Username for SSH access + Defaults to "oadp" if not specified + type: string + type: object + type: object + x-kubernetes-validations: + - message: At least one of ssh or fileBrowser must be specified + rule: has(self.ssh) || has(self.fileBrowser) + namespacePrefix: + description: |- + NamespacePrefix specifies a prefix for automatically generated temporary namespaces. + Only used when RestoreNamespace is not specified. + If not specified, the generated namespace name will use the VM's namespace-name format. + The final namespace name will be: --- + type: string + restoreNamespace: + description: |- + RestoreNamespace specifies an existing namespace where file serving resources will be created. + If not specified, a temporary namespace will be created automatically. + The namespace must exist and be accessible to the controller. + type: string + selectedBackups: + description: |- + Specific backup names to serve files from, selected from the discovery results. + If not specified, all valid backups from the discovery will be used for file serving. + All specified backup names must exist in the ValidBackups list of the referenced discovery. + items: + type: string + type: array + required: + - backupsDiscoveryRef + type: object + status: + description: status defines the observed state of VirtualMachineFileRestore + properties: + conditions: + description: |- + Conditions represent the current state of the VirtualMachineFileRestore resource. + This is the PRIMARY source of truth for resource state. + Each condition has a unique type and reflects the status of a specific aspect of the resource. + + Standard condition types for this resource (defined in types package): + - types.ConditionTypeProgressing: Restore is actively running + - types.ConditionTypeAvailable: File serving resources are ready and accessible + - types.ConditionTypeDegraded: Partial failures occurred (may still be usable) + - types.ConditionTypeReady: Summary condition (resource is usable) + + The status of each condition is one of True, False, or Unknown. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + createdNamespace: + description: |- + CreatedNamespace contains information about the namespace used for file serving. + This will be set to the specified RestoreNamespace or the name of the auto-generated temporary namespace. + type: string + fileServingInfo: + description: Information about the file serving resources that have + been created. + properties: + fileBrowser: + description: FileBrowser contains HTTPS file browser access information, + if enabled. + properties: + clusterAccess: + description: |- + ClusterAccess provides the internal HTTPS URL, usable from within the cluster network. + Example: "https://vmfr-browser.restore-tmp.svc.cluster.local" + type: string + credentialsSecretRef: + description: |- + CredentialsSecretRef references a Secret containing login credentials for the file browser: + - "username" + - "password" + If not specified, the controller creates and manages this Secret automatically. + properties: + name: + description: Name of the Secret + minLength: 1 + type: string + namespace: + description: |- + Namespace where the Secret is located. + Defaults to the OADP namespace when not specified. + Note: Secrets outside TemporaryRestoreNamespace are automatically + copied to that namespace for mounting in the serving pod. + type: string + required: + - name + type: object + publicAccess: + description: |- + PublicAccess provides the external HTTPS URL, if exposed via Route or Ingress. + Example: "https://restore-files.apps.example.com" + type: string + type: object + ssh: + description: SSH contains SSH/SFTP/SCP/rsync access information, + if enabled. + properties: + clusterAccess: + description: |- + ClusterAccess provides the internal SSH endpoint, accessible within the cluster + or from environments connected to the cluster network (e.g. via VPN, oc port-forward). + SSH is only exposed within the cluster for security reasons. + Use 'oc port-forward' or 'kubectl port-forward' for external access. + Example: "ssh://vmfr-ssh.restore-tmp.svc.cluster.local:22" + type: string + credentialsSecretRef: + description: |- + CredentialsSecretRef references a Secret containing SSH connection details: + - "username" + - "authorized_keys" + - optionally "privateKey" + The Secret is created or referenced by the controller. + properties: + name: + description: Name of the Secret + minLength: 1 + type: string + namespace: + description: |- + Namespace where the Secret is located. + Defaults to the OADP namespace when not specified. + Note: Secrets outside TemporaryRestoreNamespace are automatically + copied to that namespace for mounting in the serving pod. + type: string + required: + - name + type: object + type: object + type: object + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed by the controller. + IMPORTANT: Controllers must set this at the START of reconciliation, not at the end. + This prevents race conditions where clients see updated conditions but stale observedGeneration. + format: int64 + type: integer + phase: + description: |- + Phase indicates the overall phase of the file restore operation. + Derived from conditions for human readability. Matches Velero's phase model. + Automation should rely on conditions, not phase. + enum: + - New + - InProgress + - Completed + - PartiallyFailed + - Failed + - Deleting + type: string + pvcRestores: + description: |- + PVCRestores contains PVC-grouped restore information showing which backups each PVC was restored from. + This provides a user-friendly view of the restoration data organized by PVC. + items: + description: |- + PVCRestoreInfo combines PVC metadata with restores. + PVC metadata is inlined for simplicity in JSON output. + properties: + pvcName: + description: Name of the PVC at the time of the backup + type: string + pvcNamespace: + description: Namespace of the PVC at the time of the backup + type: string + pvcUID: + description: UID of the PVC at the time of the backup + type: string + restores: + description: Restores contains all backup restores for this + PVC + items: + description: RestoreInfo contains information about a specific + restore of a PVC from a backup. + properties: + completedAt: + description: When the Velero Restore completed + format: date-time + type: string + createdAt: + description: When the Velero Restore object was created + format: date-time + type: string + failureReason: + description: Reason for failure if the restore failed + type: string + phase: + description: Phase of the Velero Restore object + enum: + - New + - FailedValidation + - InProgress + - WaitingForPluginOperations + - WaitingForPluginOperationsPartiallyFailed + - Completed + - PartiallyFailed + - Failed + - Finalizing + - FinalizingPartiallyFailed + type: string + state: + description: |- + State indicates the compatibility and processing state of this backup + Values: "available", "backup-deleted", "backup-missing", "unsupported-plugin", "extraction-failed", "processing", "failed" + type: string + timestamp: + description: Timestamp indicates when the backup was created + format: date-time + type: string + veleroBackupName: + description: Name of the backup this restore came from + type: string + veleroBackupNamespace: + description: Namespace of the backup this restore came + from + type: string + veleroRestoreName: + description: Name of the Velero Restore object + type: string + veleroRestoreNamespace: + description: Namespace of the Velero Restore object + type: string + required: + - veleroBackupName + - veleroBackupNamespace + type: object + type: array + size: + description: Size of the PVC in human-readable format (e.g., + "5Gi", "30Gi") + type: string + required: + - pvcName + - pvcNamespace + - pvcUID + type: object + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: null + storedVersions: null diff --git a/config/crd/bases/oadp.openshift.io_dataprotectionapplications.yaml b/config/crd/bases/oadp.openshift.io_dataprotectionapplications.yaml index 4e761b3091..26a2ffc872 100644 --- a/config/crd/bases/oadp.openshift.io_dataprotectionapplications.yaml +++ b/config/crd/bases/oadp.openshift.io_dataprotectionapplications.yaml @@ -2669,9 +2669,81 @@ spec: - kubevirtPluginImageFqin - hypershiftPluginImageFqin - nonAdminControllerImageFqin + - vmFileRestoreControllerImageFqin + - vmFileRestoreAccessImageFqin + - vmFileRestoreSSHImageFqin + - vmFileRestoreBrowserImageFqin - operator-type - tech-preview-ack type: object + vmFileRestore: + description: vmFileRestore defines the configuration for the DPA to enable VM file restore feature + properties: + enable: + description: |- + Enable flag to deploy VM file restore controller + By default is disabled + type: boolean + resources: + description: Resource requirements for the VM file restore controller + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object required: - configuration type: object diff --git a/config/crd/bases/oadp.openshift.io_virtualmachinebackupsdiscoveries.yaml b/config/crd/bases/oadp.openshift.io_virtualmachinebackupsdiscoveries.yaml new file mode 100644 index 0000000000..87a7a91c89 --- /dev/null +++ b/config/crd/bases/oadp.openshift.io_virtualmachinebackupsdiscoveries.yaml @@ -0,0 +1,411 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: virtualmachinebackupsdiscoveries.oadp.openshift.io +spec: + group: oadp.openshift.io + names: + kind: VirtualMachineBackupsDiscovery + listKind: VirtualMachineBackupsDiscoveryList + plural: virtualmachinebackupsdiscoveries + shortNames: + - vmbd + singular: virtualmachinebackupsdiscovery + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.virtualMachineName + name: VM + type: string + - jsonPath: .spec.virtualMachineNamespace + name: VMNS + type: string + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.discoveryStats.completed + name: Valid + type: integer + - jsonPath: .status.discoveryStats.skipped + name: Invalid + type: integer + name: v1alpha1 + schema: + openAPIV3Schema: + description: VirtualMachineBackupsDiscovery is the Schema for the virtualmachinebackupsdiscoveries + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the desired state of VirtualMachineBackupsDiscovery + properties: + endTime: + description: |- + Only include backups created before this time (optional time range filtering). + Supports both date-only (YYYY-MM-DD) and full RFC3339 (YYYY-MM-DDTHH:MM:SSZ) formats. + Date-only format defaults to end of day (23:59:59Z). + type: string + requestedBackups: + description: |- + Specific backup names to include in addition to any time-based filtering. + If specified, these backups will be included even if they fall outside the time range. + items: + type: string + type: array + startTime: + description: |- + Only include backups created after this time (optional time range filtering). + Supports both date-only (YYYY-MM-DD) and full RFC3339 (YYYY-MM-DDTHH:MM:SSZ) formats. + Date-only format defaults to start of day (00:00:00Z). + type: string + virtualMachineName: + description: Name of the VirtualMachine to discover backups for. + minLength: 1 + type: string + virtualMachineNamespace: + description: Namespace where the VirtualMachine is located. + minLength: 1 + type: string + required: + - virtualMachineName + - virtualMachineNamespace + type: object + status: + description: status defines the observed state of VirtualMachineBackupsDiscovery + properties: + backupDiscoveryProgress: + description: Detailed discovery progress for each candidate backup. + items: + description: BackupDiscoveryProgress contains detailed information + about backup discovery progress + properties: + createdAt: + description: |- + When the backup was taken (from backup.status.completionTimestamp). + For synced backups, this reflects when the backup actually completed, not when it was imported. + format: date-time + type: string + lastUpdated: + description: When this backup's discovery status was last updated. + format: date-time + type: string + message: + description: Human-readable message about the discovery status. + maxLength: 1024 + type: string + name: + description: Name of the backup resource. + type: string + namespace: + description: Namespace is the namespace of the backup resource + type: string + pvcs: + description: |- + PVCs contains the list of PVCs available in this backup + For a given VM + This field is populated during file restore processing + items: + description: |- + PVCInfo represents a PVC from a backup and all restores associated with it. + The combination of PVCUID + PVCName ensures uniqueness across multiple backups. + properties: + pvcName: + description: Name of the PVC at the time of the backup + type: string + pvcNamespace: + description: Namespace of the PVC at the time of the backup + type: string + pvcUID: + description: UID of the PVC at the time of the backup + type: string + size: + description: Size of the PVC in human-readable format + (e.g., "5Gi", "30Gi") + type: string + required: + - pvcName + - pvcNamespace + - pvcUID + type: object + type: array + status: + description: Current status of backup discovery for this backup. + enum: + - New + - InProgress + - Completed + - Skipped + - Failed + type: string + required: + - name + - namespace + - status + type: object + type: array + conditions: + description: |- + Conditions represent the current state of the VirtualMachineBackupsDiscovery resource. + This is the PRIMARY source of truth for resource state. + Each condition has a unique type and reflects the status of a specific aspect of the resource. + + Standard condition types for this resource (defined in types package): + - types.ConditionTypeProgressing: Discovery is actively running + - types.ConditionTypeAvailable: Valid backups are available for use + - types.ConditionTypeDegraded: Partial failures occurred (may still be usable) + - types.ConditionTypeReady: Summary condition (resource is usable) + + The status of each condition is one of True, False, or Unknown. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + discoveryStats: + description: Summary statistics about the backup discovery process. + properties: + completed: + minimum: 0 + type: integer + completionTime: + format: date-time + type: string + failed: + minimum: 0 + type: integer + inProgress: + minimum: 0 + type: integer + pending: + minimum: 0 + type: integer + skipped: + minimum: 0 + type: integer + startTime: + format: date-time + type: string + totalCandidates: + minimum: 0 + type: integer + required: + - completed + - failed + - inProgress + - pending + - skipped + - totalCandidates + type: object + invalidBackups: + description: Requested backups that don't contain the VM (only populated + when RequestedBackups is used). + items: + description: InvalidBackupInfo contains information about a backup + that doesn't contain the VM + properties: + createdAt: + description: |- + When the backup was taken (from backup.status.completionTimestamp). + For synced backups, this reflects when the backup actually completed, not when it was imported. + format: date-time + type: string + name: + description: Name of the backup resource. + type: string + namespace: + description: Namespace is the namespace of the backup resource + type: string + pvcs: + description: |- + PVCs contains the list of PVCs available in this backup + For a given VM + This field is populated during file restore processing + items: + description: |- + PVCInfo represents a PVC from a backup and all restores associated with it. + The combination of PVCUID + PVCName ensures uniqueness across multiple backups. + properties: + pvcName: + description: Name of the PVC at the time of the backup + type: string + pvcNamespace: + description: Namespace of the PVC at the time of the backup + type: string + pvcUID: + description: UID of the PVC at the time of the backup + type: string + size: + description: Size of the PVC in human-readable format + (e.g., "5Gi", "30Gi") + type: string + required: + - pvcName + - pvcNamespace + - pvcUID + type: object + type: array + reason: + description: Reason why this backup doesn't contain the VM or + couldn't be processed. + maxLength: 1024 + type: string + required: + - name + - namespace + type: object + type: array + observedGeneration: + description: |- + ObservedGeneration represents the .metadata.generation that the status was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.observedGeneration is 9, + the status is out of date with respect to the current state of the instance. + + IMPORTANT: Controllers must set this at the START of reconciliation, not at the end. + This prevents race conditions where clients see updated conditions but stale observedGeneration. + format: int64 + type: integer + phase: + description: |- + Phase indicates the overall phase of the backup discovery operation. + Derived from conditions for human readability. Matches Velero's phase model. + Automation should rely on conditions, not phase. + enum: + - New + - InProgress + - Completed + - PartiallyFailed + - Failed + type: string + validBackups: + description: Backups that contain the specified virtual machine and + are ready for file serving. + items: + description: VeleroBackupInfo contains information about a discovered + backup + properties: + createdAt: + description: |- + When the backup was taken (from backup.status.completionTimestamp). + For synced backups, this reflects when the backup actually completed, not when it was imported. + format: date-time + type: string + name: + description: Name of the backup resource. + type: string + namespace: + description: Namespace is the namespace of the backup resource + type: string + pvcs: + description: |- + PVCs contains the list of PVCs available in this backup + For a given VM + This field is populated during file restore processing + items: + description: |- + PVCInfo represents a PVC from a backup and all restores associated with it. + The combination of PVCUID + PVCName ensures uniqueness across multiple backups. + properties: + pvcName: + description: Name of the PVC at the time of the backup + type: string + pvcNamespace: + description: Namespace of the PVC at the time of the backup + type: string + pvcUID: + description: UID of the PVC at the time of the backup + type: string + size: + description: Size of the PVC in human-readable format + (e.g., "5Gi", "30Gi") + type: string + required: + - pvcName + - pvcNamespace + - pvcUID + type: object + type: array + required: + - name + - namespace + type: object + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/oadp.openshift.io_virtualmachinefilerestores.yaml b/config/crd/bases/oadp.openshift.io_virtualmachinefilerestores.yaml new file mode 100644 index 0000000000..5c2df75140 --- /dev/null +++ b/config/crd/bases/oadp.openshift.io_virtualmachinefilerestores.yaml @@ -0,0 +1,438 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: virtualmachinefilerestores.oadp.openshift.io +spec: + group: oadp.openshift.io + names: + kind: VirtualMachineFileRestore + listKind: VirtualMachineFileRestoreList + plural: virtualmachinefilerestores + shortNames: + - vmfr + singular: virtualmachinefilerestore + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .spec.backupsDiscoveryRef + name: Discovery + type: string + - jsonPath: .status.fileServingInfo.podName + name: Pod + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: VirtualMachineFileRestore is the Schema for the virtualmachinefilerestores + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the desired state of VirtualMachineFileRestore + properties: + backupsDiscoveryRef: + description: |- + Reference to the VirtualMachineBackupsDiscovery resource in the same namespace + that contains the discovered backups to serve files from. + minLength: 1 + type: string + fileAccess: + description: |- + FileAccess defines which file access methods are enabled for this restore. + If not specified, defaults to HTTP file browser only. + properties: + fileBrowser: + description: |- + FileBrowser enables HTTPS web-based file browser access + If present (non-nil), FileBrowser access is enabled + properties: + credentialsSecretRef: + description: |- + CredentialsSecretRef references a Secret containing FileBrowser credentials. + The Secret must have a "password" key and optionally a "username" key. + If "username" is not provided in the Secret, it defaults to "oadp". + If CredentialsSecretRef is not specified, the controller generates both + username (defaults to "oadp") and password, storing them in a Secret + in the temporary restore namespace. + properties: + name: + description: Name of the Secret + minLength: 1 + type: string + namespace: + description: |- + Namespace where the Secret is located. + Defaults to the OADP namespace when not specified. + Note: Secrets outside TemporaryRestoreNamespace are automatically + copied to that namespace for mounting in the serving pod. + type: string + required: + - name + type: object + exposeExternally: + description: |- + ExposeExternally enables creation of an OpenShift Route for the FileBrowser service. + When enabled, creates a Route with reencrypt TLS termination for external HTTPS access. + Only effective on OpenShift clusters. + type: boolean + type: object + ssh: + description: |- + SSH provides read-only access to restored files via chrooted OpenSSH. + Supports SFTP, SCP, and rsync for file transfer only (no interactive shell access). + The SSH server runs in a chroot environment for security isolation. + properties: + credentialsSecretRef: + description: |- + CredentialsSecretRef references a Secret containing SSH authentication credentials. + The Secret must have an "authorized_keys" key for SSH key-based authentication. + The "username" key is optional and defaults to "oadp" if not provided. + Note: Only SSH key-based authentication is supported; password authentication is not available. + Takes precedence over inline Username and PublicKey fields. + properties: + name: + description: Name of the Secret + minLength: 1 + type: string + namespace: + description: |- + Namespace where the Secret is located. + Defaults to the OADP namespace when not specified. + Note: Secrets outside TemporaryRestoreNamespace are automatically + copied to that namespace for mounting in the serving pod. + type: string + required: + - name + type: object + publicKey: + description: |- + PublicKey for SSH key-based authentication + Public keys are not sensitive and can be specified inline + If both PublicKey and CredentialsSecretRef are empty, controller generates keypair + type: string + username: + description: |- + Username for SSH access + Defaults to "oadp" if not specified + type: string + type: object + type: object + x-kubernetes-validations: + - message: At least one of ssh or fileBrowser must be specified + rule: has(self.ssh) || has(self.fileBrowser) + namespacePrefix: + description: |- + NamespacePrefix specifies a prefix for automatically generated temporary namespaces. + Only used when RestoreNamespace is not specified. + If not specified, the generated namespace name will use the VM's namespace-name format. + The final namespace name will be: --- + type: string + restoreNamespace: + description: |- + RestoreNamespace specifies an existing namespace where file serving resources will be created. + If not specified, a temporary namespace will be created automatically. + The namespace must exist and be accessible to the controller. + type: string + selectedBackups: + description: |- + Specific backup names to serve files from, selected from the discovery results. + If not specified, all valid backups from the discovery will be used for file serving. + All specified backup names must exist in the ValidBackups list of the referenced discovery. + items: + type: string + type: array + required: + - backupsDiscoveryRef + type: object + status: + description: status defines the observed state of VirtualMachineFileRestore + properties: + conditions: + description: |- + Conditions represent the current state of the VirtualMachineFileRestore resource. + This is the PRIMARY source of truth for resource state. + Each condition has a unique type and reflects the status of a specific aspect of the resource. + + Standard condition types for this resource (defined in types package): + - types.ConditionTypeProgressing: Restore is actively running + - types.ConditionTypeAvailable: File serving resources are ready and accessible + - types.ConditionTypeDegraded: Partial failures occurred (may still be usable) + - types.ConditionTypeReady: Summary condition (resource is usable) + + The status of each condition is one of True, False, or Unknown. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + createdNamespace: + description: |- + CreatedNamespace contains information about the namespace used for file serving. + This will be set to the specified RestoreNamespace or the name of the auto-generated temporary namespace. + type: string + fileServingInfo: + description: Information about the file serving resources that have + been created. + properties: + fileBrowser: + description: FileBrowser contains HTTPS file browser access information, + if enabled. + properties: + clusterAccess: + description: |- + ClusterAccess provides the internal HTTPS URL, usable from within the cluster network. + Example: "https://vmfr-browser.restore-tmp.svc.cluster.local" + type: string + credentialsSecretRef: + description: |- + CredentialsSecretRef references a Secret containing login credentials for the file browser: + - "username" + - "password" + If not specified, the controller creates and manages this Secret automatically. + properties: + name: + description: Name of the Secret + minLength: 1 + type: string + namespace: + description: |- + Namespace where the Secret is located. + Defaults to the OADP namespace when not specified. + Note: Secrets outside TemporaryRestoreNamespace are automatically + copied to that namespace for mounting in the serving pod. + type: string + required: + - name + type: object + publicAccess: + description: |- + PublicAccess provides the external HTTPS URL, if exposed via Route or Ingress. + Example: "https://restore-files.apps.example.com" + type: string + type: object + ssh: + description: SSH contains SSH/SFTP/SCP/rsync access information, + if enabled. + properties: + clusterAccess: + description: |- + ClusterAccess provides the internal SSH endpoint, accessible within the cluster + or from environments connected to the cluster network (e.g. via VPN, oc port-forward). + SSH is only exposed within the cluster for security reasons. + Use 'oc port-forward' or 'kubectl port-forward' for external access. + Example: "ssh://vmfr-ssh.restore-tmp.svc.cluster.local:22" + type: string + credentialsSecretRef: + description: |- + CredentialsSecretRef references a Secret containing SSH connection details: + - "username" + - "authorized_keys" + - optionally "privateKey" + The Secret is created or referenced by the controller. + properties: + name: + description: Name of the Secret + minLength: 1 + type: string + namespace: + description: |- + Namespace where the Secret is located. + Defaults to the OADP namespace when not specified. + Note: Secrets outside TemporaryRestoreNamespace are automatically + copied to that namespace for mounting in the serving pod. + type: string + required: + - name + type: object + type: object + type: object + observedGeneration: + description: |- + ObservedGeneration is the most recent generation observed by the controller. + IMPORTANT: Controllers must set this at the START of reconciliation, not at the end. + This prevents race conditions where clients see updated conditions but stale observedGeneration. + format: int64 + type: integer + phase: + description: |- + Phase indicates the overall phase of the file restore operation. + Derived from conditions for human readability. Matches Velero's phase model. + Automation should rely on conditions, not phase. + enum: + - New + - InProgress + - Completed + - PartiallyFailed + - Failed + - Deleting + type: string + pvcRestores: + description: |- + PVCRestores contains PVC-grouped restore information showing which backups each PVC was restored from. + This provides a user-friendly view of the restoration data organized by PVC. + items: + description: |- + PVCRestoreInfo combines PVC metadata with restores. + PVC metadata is inlined for simplicity in JSON output. + properties: + pvcName: + description: Name of the PVC at the time of the backup + type: string + pvcNamespace: + description: Namespace of the PVC at the time of the backup + type: string + pvcUID: + description: UID of the PVC at the time of the backup + type: string + restores: + description: Restores contains all backup restores for this + PVC + items: + description: RestoreInfo contains information about a specific + restore of a PVC from a backup. + properties: + completedAt: + description: When the Velero Restore completed + format: date-time + type: string + createdAt: + description: When the Velero Restore object was created + format: date-time + type: string + failureReason: + description: Reason for failure if the restore failed + type: string + phase: + description: Phase of the Velero Restore object + enum: + - New + - FailedValidation + - InProgress + - WaitingForPluginOperations + - WaitingForPluginOperationsPartiallyFailed + - Completed + - PartiallyFailed + - Failed + - Finalizing + - FinalizingPartiallyFailed + type: string + state: + description: |- + State indicates the compatibility and processing state of this backup + Values: "available", "backup-deleted", "backup-missing", "unsupported-plugin", "extraction-failed", "processing", "failed" + type: string + timestamp: + description: Timestamp indicates when the backup was created + format: date-time + type: string + veleroBackupName: + description: Name of the backup this restore came from + type: string + veleroBackupNamespace: + description: Namespace of the backup this restore came + from + type: string + veleroRestoreName: + description: Name of the Velero Restore object + type: string + veleroRestoreNamespace: + description: Namespace of the Velero Restore object + type: string + required: + - veleroBackupName + - veleroBackupNamespace + type: object + type: array + size: + description: Size of the PVC in human-readable format (e.g., + "5Gi", "30Gi") + type: string + required: + - pvcName + - pvcNamespace + - pvcUID + type: object + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 7194143c21..f867d09cd4 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -22,6 +22,8 @@ resources: - bases/oadp.openshift.io_nonadminbackups.yaml - bases/oadp.openshift.io_nonadminrestores.yaml - bases/oadp.openshift.io_nonadmindownloadrequests.yaml +- bases/oadp.openshift.io_virtualmachinebackupsdiscoveries.yaml +- bases/oadp.openshift.io_virtualmachinefilerestores.yaml - bases/oadp.openshift.io_dataprotectiontests.yaml #+kubebuilder:scaffold:crdkustomizeresource diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 1304283022..866b4464c0 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -88,6 +88,14 @@ spec: value: registry.redhat.io/oadp/oadp-mustgather-rhel8:v1.2 - name: RELATED_IMAGE_NON_ADMIN_CONTROLLER value: quay.io/konveyor/oadp-non-admin:latest + - name: RELATED_IMAGE_VM_FILE_RESTORE_CONTROLLER + value: quay.io/konveyor/oadp-vm-file-restore:latest + - name: RELATED_IMAGE_VM_FILE_RESTORE_ACCESS + value: quay.io/konveyor/oadp-vmfr-access:latest + - name: RELATED_IMAGE_VM_FILE_RESTORE_SSH + value: quay.io/konveyor/oadp-vmfr-access-sshd:latest + - name: RELATED_IMAGE_VM_FILE_RESTORE_BROWSER + value: quay.io/konveyor/oadp-vmfr-access-filebrowser:latest args: - --leader-elect image: controller:latest diff --git a/config/manifests/kustomization.yaml b/config/manifests/kustomization.yaml index 5a625e6754..a0106525ec 100644 --- a/config/manifests/kustomization.yaml +++ b/config/manifests/kustomization.yaml @@ -7,6 +7,7 @@ resources: - ../scorecard - ../velero - ../non-admin-controller_rbac +- ../vm-file-restore-controller_rbac # [WEBHOOK] To enable webhooks, uncomment all the sections with [WEBHOOK] prefix. # Do NOT uncomment sections with prefix [CERTMANAGER], as OLM does not support cert-manager. diff --git a/config/vm-file-restore-controller_rbac/kustomization.yaml b/config/vm-file-restore-controller_rbac/kustomization.yaml new file mode 100644 index 0000000000..55d4d0cc20 --- /dev/null +++ b/config/vm-file-restore-controller_rbac/kustomization.yaml @@ -0,0 +1,9 @@ +resources: +# All RBAC will be applied under this service account in +# the deployment namespace. You may comment out this resource +# if your manager will use a service account that exists at +# runtime. Be sure to update RoleBinding and ClusterRoleBinding +# subjects if changing service account names. +- service_account.yaml +- role.yaml +- role_binding.yaml diff --git a/config/vm-file-restore-controller_rbac/role.yaml b/config/vm-file-restore-controller_rbac/role.yaml new file mode 100644 index 0000000000..87b6a929b9 --- /dev/null +++ b/config/vm-file-restore-controller_rbac/role.yaml @@ -0,0 +1,147 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: oadp-vm-file-restore-controller-manager-role +rules: +- apiGroups: + - "" + resources: + - events + - namespaces + - pods + - secrets + - serviceaccounts + - services + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - persistentvolumeclaims + verbs: + - get + - list + - watch +- apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - oadp.openshift.io + resources: + - virtualmachinebackupsdiscoveries + - virtualmachinefilerestores + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - oadp.openshift.io + resources: + - virtualmachinebackupsdiscoveries/finalizers + - virtualmachinefilerestores/finalizers + verbs: + - update +- apiGroups: + - oadp.openshift.io + resources: + - virtualmachinebackupsdiscoveries/status + - virtualmachinefilerestores/status + verbs: + - get + - patch + - update +- apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - route.openshift.io + resources: + - routes + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - velero.io + resources: + - backups + verbs: + - get + - list + - watch +- apiGroups: + - velero.io + resources: + - datadownloads + verbs: + - get + - list + - patch + - watch +- apiGroups: + - velero.io + resources: + - downloadrequests + verbs: + - create + - delete + - get + - list + - watch +- apiGroups: + - velero.io + resources: + - restores + verbs: + - create + - delete + - get + - list + - patch + - update + - watch diff --git a/config/vm-file-restore-controller_rbac/role_binding.yaml b/config/vm-file-restore-controller_rbac/role_binding.yaml new file mode 100644 index 0000000000..7244a050fe --- /dev/null +++ b/config/vm-file-restore-controller_rbac/role_binding.yaml @@ -0,0 +1,19 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/name: clusterrolebinding + app.kubernetes.io/instance: oadp-vm-file-restore-controller-manager-rolebinding + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: oadp-operator + app.kubernetes.io/part-of: oadp-operator + app.kubernetes.io/managed-by: kustomize + name: oadp-vm-file-restore-controller-manager-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: oadp-vm-file-restore-controller-manager-role +subjects: +- kind: ServiceAccount + name: oadp-vm-file-restore-controller-manager + namespace: system diff --git a/config/vm-file-restore-controller_rbac/service_account.yaml b/config/vm-file-restore-controller_rbac/service_account.yaml new file mode 100644 index 0000000000..7810ec02c4 --- /dev/null +++ b/config/vm-file-restore-controller_rbac/service_account.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app.kubernetes.io/name: serviceaccount + app.kubernetes.io/instance: oadp-vm-file-restore-controller-manager-sa + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: oadp-operator + app.kubernetes.io/part-of: oadp-operator + app.kubernetes.io/managed-by: kustomize + name: oadp-vm-file-restore-controller-manager + namespace: system diff --git a/docs/config/vm_file_restore.md b/docs/config/vm_file_restore.md new file mode 100644 index 0000000000..bf6af2fab0 --- /dev/null +++ b/docs/config/vm_file_restore.md @@ -0,0 +1,231 @@ +
+

VM File Restore Configuration

+
+ +### Enable VM File Restore + +The VM File Restore feature allows you to perform granular file-level restore from VM backups without restoring the entire virtual machine. This is particularly useful when you only need to recover specific files or directories from a backed-up VM. + +### Prerequisites + +Before enabling VM File Restore, ensure the following prerequisites are met: + +1. **OpenShift Virtualization**: KubeVirt must be installed and configured in your OpenShift cluster +2. **Required Plugins**: The following Velero plugins must be configured in your DPA: + - `kubevirt` - Provides VM backup and restore capabilities + - `openshift` - Required for OpenShift-specific functionality + +### Basic Configuration + +To enable VM File Restore, add the `vmFileRestore` section to your DataProtectionApplication CR: + +```yaml +apiVersion: oadp.openshift.io/v1alpha1 +kind: DataProtectionApplication +metadata: + name: dpa-sample + namespace: openshift-adp +spec: + configuration: + velero: + defaultPlugins: + - openshift + - aws + - kubevirt # Required for VM file restore + + # Enable VM File Restore + vmFileRestore: + enable: true + + backupLocations: + - name: default + velero: + provider: aws + default: true + objectStorage: + bucket: my-backup-bucket + prefix: velero + config: + region: us-east-1 +``` + +### Custom Resource Configuration + +You can specify custom resource limits and requests for the VM File Restore controller: + +```yaml +apiVersion: oadp.openshift.io/v1alpha1 +kind: DataProtectionApplication +metadata: + name: dpa-sample + namespace: openshift-adp +spec: + configuration: + velero: + defaultPlugins: + - openshift + - aws + - kubevirt + + vmFileRestore: + enable: true + resources: + limits: + cpu: "1" + memory: 256Mi + requests: + cpu: 100m + memory: 128Mi + + backupLocations: + - name: default + velero: + provider: aws + default: true + objectStorage: + bucket: my-backup-bucket + prefix: velero + config: + region: us-east-1 +``` + +**Default Resources** (if not specified): +- CPU Limit: 500m +- Memory Limit: 128Mi +- CPU Request: 10m +- Memory Request: 64Mi + +### Image Overrides + +For disconnected environments or testing, you can override the VM File Restore controller images using `unsupportedOverrides`: + +```yaml +apiVersion: oadp.openshift.io/v1alpha1 +kind: DataProtectionApplication +metadata: + name: dpa-sample + namespace: openshift-adp +spec: + configuration: + velero: + defaultPlugins: + - openshift + - aws + - kubevirt + + vmFileRestore: + enable: true + + unsupportedOverrides: + vmFileRestoreControllerImageFqin: "my-registry.io/oadp-vm-file-restore:v1.0.0" + vmFileRestoreAccessImageFqin: "my-registry.io/oadp-vmfr-access:v1.0.0" + vmFileRestoreSSHImageFqin: "my-registry.io/oadp-vmfr-access-sshd:v1.0.0" + vmFileRestoreBrowserImageFqin: "my-registry.io/oadp-vmfr-access-filebrowser:v1.0.0" + + backupLocations: + - name: default + velero: + provider: aws + default: true + objectStorage: + bucket: my-backup-bucket + prefix: velero + config: + region: us-east-1 +``` + +### Using VM File Restore + +Once enabled, the VM File Restore controller will be deployed in the OADP namespace. You can then use the following Custom Resources: + +#### 1. VirtualMachineBackupsDiscovery (VMBD) + +Discover available VM backups: + +```yaml +apiVersion: oadp.openshift.io/v1alpha1 +kind: VirtualMachineBackupsDiscovery +metadata: + name: my-vm-discovery + namespace: openshift-adp +spec: + backupName: my-vm-backup +``` + +#### 2. VirtualMachineFileRestore (VMFR) + +Restore specific files from a VM backup: + +```yaml +apiVersion: oadp.openshift.io/v1alpha1 +kind: VirtualMachineFileRestore +metadata: + name: my-vm-file-restore + namespace: openshift-adp +spec: + backupName: my-vm-backup + volumeName: my-vm-disk + fileAccessMethod: ssh # or filebrowser +``` + +### File Access Methods + +The VM File Restore feature supports two methods for accessing restored files: + +1. **SSH Access**: Connect to restored files via SSH (requires SSH client) +2. **FileBrowser**: Web-based file browser interface for easy file access + +### Validation Requirements + +When VM File Restore is enabled, OADP validates: + +- ✓ Velero configuration is present +- ✓ kubevirt-velero-plugin is configured in defaultPlugins +- ✓ openshift-velero-plugin is configured in defaultPlugins +- ✓ Only one DPA instance across the cluster has VM File Restore enabled + +### Disabling VM File Restore + +To disable VM File Restore, set `enable` to `false`: + +```yaml +apiVersion: oadp.openshift.io/v1alpha1 +kind: DataProtectionApplication +metadata: + name: dpa-sample + namespace: openshift-adp +spec: + configuration: + velero: + defaultPlugins: + - openshift + - aws + - kubevirt + + vmFileRestore: + enable: false # Disables VM file restore +``` + +The VM File Restore controller deployment will be automatically removed when disabled. + +### Troubleshooting + +**VM File Restore controller not deploying:** +- Check that both `kubevirt` and `openshift` plugins are in defaultPlugins +- Verify that KubeVirt is installed in your cluster +- Check OADP operator logs for validation errors + +**Cannot create VirtualMachineFileRestore resources:** +- Ensure VM File Restore is enabled in the DPA +- Verify the controller deployment is running: `oc get deployment -n openshift-adp oadp-vm-file-restore-controller-manager` +- Check controller logs for errors: `oc logs -n openshift-adp deployment/oadp-vm-file-restore-controller-manager` + +**Multiple DPA error:** +- Only one DPA instance across the entire cluster can have VM File Restore enabled +- Disable VM File Restore in other DPA instances if the error occurs + +### Additional Resources + +- [OpenShift Virtualization Documentation](https://docs.openshift.com/container-platform/latest/virt/about_virt/about-virt.html) +- [KubeVirt Project](https://kubevirt.io/) +- [OADP VM File Restore Repository](https://github.com/migtools/oadp-vm-file-restore) diff --git a/internal/controller/dataprotectionapplication_controller.go b/internal/controller/dataprotectionapplication_controller.go index 72af6b3ef2..abb253fed6 100644 --- a/internal/controller/dataprotectionapplication_controller.go +++ b/internal/controller/dataprotectionapplication_controller.go @@ -111,6 +111,7 @@ func (r *DataProtectionApplicationReconciler) Reconcile(ctx context.Context, req r.ReconcileNodeAgentDaemonset, r.ReconcileVeleroMetricsSVC, r.ReconcileNonAdminController, + r.ReconcileVMFileRestoreController, ) if err != nil { diff --git a/internal/controller/validator.go b/internal/controller/validator.go index 63956b3139..afc2f553d2 100644 --- a/internal/controller/validator.go +++ b/internal/controller/validator.go @@ -305,6 +305,60 @@ func (r *DataProtectionApplicationReconciler) ValidateDataProtectionCR(log logr. } } + // validate VM file restore enable + if r.checkVMFileRestoreEnabled() { + dpaList := &oadpv1alpha1.DataProtectionApplicationList{} + err = r.ClusterWideClient.List(r.Context, dpaList) + if err != nil { + return false, err + } + for _, dpa := range dpaList.Items { + if dpa.Namespace != r.NamespacedName.Namespace && (&DataProtectionApplicationReconciler{dpa: &dpa}).checkVMFileRestoreEnabled() { + vmFileRestoreDeployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: vmFileRestoreObjectName, + Namespace: dpa.Namespace, + }, + } + if err := r.ClusterWideClient.Get( + r.Context, + types.NamespacedName{ + Name: vmFileRestoreDeployment.Name, + Namespace: vmFileRestoreDeployment.Namespace, + }, + vmFileRestoreDeployment, + ); err == nil { + return false, fmt.Errorf("only a single instance of VM File Restore Controller can be installed across the entire cluster. VM File Restore controller is already configured and installed in %s namespace", dpa.Namespace) + } + } + } + + // Check if Velero is configured + if r.dpa.Spec.Configuration == nil || r.dpa.Spec.Configuration.Velero == nil { + return false, errors.New("Velero must be configured to enable VM file restore") + } + + // Check if required plugins are configured + hasKubevirtPlugin := false + hasOpenShiftPlugin := false + if r.dpa.Spec.Configuration.Velero.DefaultPlugins != nil { + for _, plugin := range r.dpa.Spec.Configuration.Velero.DefaultPlugins { + if plugin == oadpv1alpha1.DefaultPluginKubeVirt { + hasKubevirtPlugin = true + } + if plugin == oadpv1alpha1.DefaultPluginOpenShift { + hasOpenShiftPlugin = true + } + } + } + if !hasKubevirtPlugin { + return false, errors.New("VM file restore requires kubevirt-velero-plugin. Please add 'kubevirt' to spec.configuration.velero.defaultPlugins") + } + if !hasOpenShiftPlugin { + return false, errors.New("VM file restore requires openshift-velero-plugin. Please add 'openshift' to spec.configuration.velero.defaultPlugins") + } + } + return true, nil } diff --git a/internal/controller/validator_test.go b/internal/controller/validator_test.go index 67a74da5b8..d52812d7ef 100644 --- a/internal/controller/validator_test.go +++ b/internal/controller/validator_test.go @@ -2,6 +2,7 @@ package controller import ( "fmt" + "strings" "testing" "time" @@ -2836,3 +2837,277 @@ func (t *testLogSink) Error(err error, msg string, keysAndValues ...interface{}) } func (t *testLogSink) WithValues(keysAndValues ...interface{}) logr.LogSink { return t } func (t *testLogSink) WithName(name string) logr.LogSink { return t } + +func TestVMFileRestoreValidation(t *testing.T) { + tests := []struct { + name string + namespace string + dpa *oadpv1alpha1.DataProtectionApplication + clusterWideDPAs []client.Object + wantErr bool + errorContains string + }{ + { + name: "VM file restore disabled - no validation", + namespace: "test-ns", + dpa: &oadpv1alpha1.DataProtectionApplication{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dpa", + Namespace: "test-ns", + }, + Spec: oadpv1alpha1.DataProtectionApplicationSpec{ + Configuration: &oadpv1alpha1.ApplicationConfig{ + Velero: &oadpv1alpha1.VeleroConfig{ + NoDefaultBackupLocation: true, + }, + }, + VMFileRestore: &oadpv1alpha1.VMFileRestore{ + Enable: ptr.To(false), + }, + BackupImages: ptr.To(false), + }, + }, + wantErr: false, + }, + { + name: "VM file restore enabled but Velero not configured - error", + namespace: "test-ns", + dpa: &oadpv1alpha1.DataProtectionApplication{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dpa", + Namespace: "test-ns", + }, + Spec: oadpv1alpha1.DataProtectionApplicationSpec{ + VMFileRestore: &oadpv1alpha1.VMFileRestore{ + Enable: ptr.To(true), + }, + }, + }, + wantErr: true, + errorContains: "DPA CR Velero configuration cannot be nil", + }, + { + name: "VM file restore enabled but kubevirt plugin missing - error", + namespace: "test-ns", + dpa: &oadpv1alpha1.DataProtectionApplication{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dpa", + Namespace: "test-ns", + }, + Spec: oadpv1alpha1.DataProtectionApplicationSpec{ + Configuration: &oadpv1alpha1.ApplicationConfig{ + Velero: &oadpv1alpha1.VeleroConfig{ + DefaultPlugins: []oadpv1alpha1.DefaultPlugin{ + oadpv1alpha1.DefaultPluginOpenShift, + }, + NoDefaultBackupLocation: true, + }, + }, + VMFileRestore: &oadpv1alpha1.VMFileRestore{ + Enable: ptr.To(true), + }, + BackupImages: ptr.To(false), + }, + }, + wantErr: true, + errorContains: "VM file restore requires kubevirt-velero-plugin", + }, + { + name: "VM file restore enabled but openshift plugin missing - error", + namespace: "test-ns", + dpa: &oadpv1alpha1.DataProtectionApplication{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dpa", + Namespace: "test-ns", + }, + Spec: oadpv1alpha1.DataProtectionApplicationSpec{ + Configuration: &oadpv1alpha1.ApplicationConfig{ + Velero: &oadpv1alpha1.VeleroConfig{ + DefaultPlugins: []oadpv1alpha1.DefaultPlugin{ + oadpv1alpha1.DefaultPluginKubeVirt, + }, + NoDefaultBackupLocation: true, + }, + }, + VMFileRestore: &oadpv1alpha1.VMFileRestore{ + Enable: ptr.To(true), + }, + BackupImages: ptr.To(false), + }, + }, + wantErr: true, + errorContains: "VM file restore requires openshift-velero-plugin", + }, + { + name: "VM file restore enabled with all requirements - success", + namespace: "test-ns", + dpa: &oadpv1alpha1.DataProtectionApplication{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dpa", + Namespace: "test-ns", + }, + Spec: oadpv1alpha1.DataProtectionApplicationSpec{ + Configuration: &oadpv1alpha1.ApplicationConfig{ + Velero: &oadpv1alpha1.VeleroConfig{ + DefaultPlugins: []oadpv1alpha1.DefaultPlugin{ + oadpv1alpha1.DefaultPluginKubeVirt, + oadpv1alpha1.DefaultPluginOpenShift, + }, + NoDefaultBackupLocation: true, + }, + }, + VMFileRestore: &oadpv1alpha1.VMFileRestore{ + Enable: ptr.To(true), + }, + BackupImages: ptr.To(false), + }, + }, + wantErr: false, + }, + { + name: "Multiple DPAs with VM file restore in different namespaces - error", + namespace: "test-ns-1", + dpa: &oadpv1alpha1.DataProtectionApplication{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dpa-1", + Namespace: "test-ns-1", + }, + Spec: oadpv1alpha1.DataProtectionApplicationSpec{ + Configuration: &oadpv1alpha1.ApplicationConfig{ + Velero: &oadpv1alpha1.VeleroConfig{ + DefaultPlugins: []oadpv1alpha1.DefaultPlugin{ + oadpv1alpha1.DefaultPluginKubeVirt, + oadpv1alpha1.DefaultPluginOpenShift, + }, + NoDefaultBackupLocation: true, + }, + }, + VMFileRestore: &oadpv1alpha1.VMFileRestore{ + Enable: ptr.To(true), + }, + BackupImages: ptr.To(false), + }, + }, + clusterWideDPAs: []client.Object{ + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: vmFileRestoreObjectName, + Namespace: "test-ns-2", + }, + }, + &oadpv1alpha1.DataProtectionApplication{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dpa-2", + Namespace: "test-ns-2", + }, + Spec: oadpv1alpha1.DataProtectionApplicationSpec{ + Configuration: &oadpv1alpha1.ApplicationConfig{ + Velero: &oadpv1alpha1.VeleroConfig{ + DefaultPlugins: []oadpv1alpha1.DefaultPlugin{ + oadpv1alpha1.DefaultPluginKubeVirt, + oadpv1alpha1.DefaultPluginOpenShift, + }, + NoDefaultBackupLocation: true, + }, + }, + VMFileRestore: &oadpv1alpha1.VMFileRestore{ + Enable: ptr.To(true), + }, + BackupImages: ptr.To(false), + }, + }, + }, + wantErr: true, + errorContains: "only a single instance of VM File Restore Controller can be installed across the entire cluster", + }, + { + name: "VM file restore enabled with custom resources - success", + namespace: "test-ns", + dpa: &oadpv1alpha1.DataProtectionApplication{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dpa", + Namespace: "test-ns", + }, + Spec: oadpv1alpha1.DataProtectionApplicationSpec{ + Configuration: &oadpv1alpha1.ApplicationConfig{ + Velero: &oadpv1alpha1.VeleroConfig{ + DefaultPlugins: []oadpv1alpha1.DefaultPlugin{ + oadpv1alpha1.DefaultPluginKubeVirt, + oadpv1alpha1.DefaultPluginOpenShift, + }, + NoDefaultBackupLocation: true, + }, + }, + VMFileRestore: &oadpv1alpha1.VMFileRestore{ + Enable: ptr.To(true), + Resources: &corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + corev1.ResourceMemory: resource.MustParse("256Mi"), + }, + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + }, + }, + BackupImages: ptr.To(false), + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup fake client + objects := []client.Object{tt.dpa} + if tt.clusterWideDPAs != nil { + objects = append(objects, tt.clusterWideDPAs...) + } + + fakeClient, err := getFakeClientFromObjects(objects...) + if err != nil { + t.Errorf("error in creating fake client, likely programmer error: %v", err) + return + } + + r := &DataProtectionApplicationReconciler{ + Client: fakeClient, + ClusterWideClient: fakeClient, + Scheme: fakeClient.Scheme(), + Context: newContextForTest(), + NamespacedName: types.NamespacedName{ + Namespace: tt.namespace, + Name: tt.dpa.Name, + }, + dpa: tt.dpa, + EventRecorder: record.NewFakeRecorder(10), + } + + // Run validation + valid, err := r.ValidateDataProtectionCR(logr.Discard()) + + if tt.wantErr { + if err == nil { + t.Errorf("ValidateDataProtectionCR() expected error but got none") + return + } + if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("ValidateDataProtectionCR() error = %v, want error containing %v", err, tt.errorContains) + } + if valid { + t.Errorf("ValidateDataProtectionCR() = %v, want false when error", valid) + } + } else { + if err != nil { + t.Errorf("ValidateDataProtectionCR() unexpected error = %v", err) + return + } + if !valid { + t.Errorf("ValidateDataProtectionCR() = %v, want true", valid) + } + } + }) + } +} diff --git a/internal/controller/vmfilerestore_controller.go b/internal/controller/vmfilerestore_controller.go new file mode 100644 index 0000000000..0728765d37 --- /dev/null +++ b/internal/controller/vmfilerestore_controller.go @@ -0,0 +1,420 @@ +package controller + +import ( + "fmt" + "os" + "reflect" + "strconv" + + "github.com/go-logr/logr" + "github.com/sirupsen/logrus" + "golang.org/x/exp/maps" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + k8serror "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + oadpv1alpha1 "github.com/openshift/oadp-operator/api/v1alpha1" + "github.com/openshift/oadp-operator/pkg/common" +) + +const ( + vmFileRestoreObjectName = "oadp-vm-file-restore-controller-manager" + vmFileRestoreControlPlaneKey = "control-plane" + vmFileRestoreControlPlaneValue = "oadp-vm-file-restore-controller" + vmfrDpaResourceVersionAnnotation = oadpv1alpha1.OadpOperatorLabel + "-vmfr-dpa-resource-version" +) + +var ( + vmFileRestoreControlPlaneLabel = map[string]string{ + vmFileRestoreControlPlaneKey: vmFileRestoreControlPlaneValue, + } + vmFileRestoreDeploymentLabels = map[string]string{ + "app.kubernetes.io/component": "manager", + "app.kubernetes.io/created-by": common.OADPOperator, + "app.kubernetes.io/instance": vmFileRestoreObjectName, + "app.kubernetes.io/managed-by": "kustomize", + "app.kubernetes.io/name": "deployment", + "app.kubernetes.io/part-of": common.OADPOperator, + } + + vmfrDpaResourceVersion = "" + previousVMFileRestoreConfiguration *oadpv1alpha1.VMFileRestore = nil +) + +// ReconcileVMFileRestoreController manages the VM file restore controller deployment +func (r *DataProtectionApplicationReconciler) ReconcileVMFileRestoreController(log logr.Logger) (bool, error) { + vmFileRestoreDeployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: vmFileRestoreObjectName, + Namespace: r.NamespacedName.Namespace, + }, + } + + // Delete (possible) previously deployment + if !r.checkVMFileRestoreEnabled() { + if err := r.Get( + r.Context, + types.NamespacedName{ + Name: vmFileRestoreDeployment.Name, + Namespace: vmFileRestoreDeployment.Namespace, + }, + vmFileRestoreDeployment, + ); err != nil { + if k8serror.IsNotFound(err) { + return true, nil + } + return false, err + } + + if err := r.Delete( + r.Context, + vmFileRestoreDeployment, + &client.DeleteOptions{PropagationPolicy: ptr.To(metav1.DeletePropagationForeground)}, + ); err != nil { + r.EventRecorder.Event( + vmFileRestoreDeployment, + corev1.EventTypeWarning, + "VMFileRestoreDeploymentDeleteFailed", + fmt.Sprintf("Could not delete VM file restore controller deployment %s/%s: %s", vmFileRestoreDeployment.Namespace, vmFileRestoreDeployment.Name, err), + ) + return false, err + } + r.EventRecorder.Event( + vmFileRestoreDeployment, + corev1.EventTypeNormal, + "VMFileRestoreDeploymentDeleteSucceed", + fmt.Sprintf("VM file restore controller deployment %s/%s deleted", vmFileRestoreDeployment.Namespace, vmFileRestoreDeployment.Name), + ) + return true, nil + } + + operation, err := controllerutil.CreateOrUpdate( + r.Context, + r.Client, + vmFileRestoreDeployment, + func() error { + err := r.buildVMFileRestoreDeployment(vmFileRestoreDeployment) + if err != nil { + return err + } + + // Setting controller owner reference on the VM file restore controller deployment + return controllerutil.SetControllerReference(r.dpa, vmFileRestoreDeployment, r.Scheme) + }, + ) + if err != nil { + return false, err + } + + if operation != controllerutil.OperationResultNone { + r.EventRecorder.Event( + vmFileRestoreDeployment, + corev1.EventTypeNormal, + "VMFileRestoreDeploymentReconciled", + fmt.Sprintf("VM file restore controller deployment %s/%s %s", vmFileRestoreDeployment.Namespace, vmFileRestoreDeployment.Name, operation), + ) + } + return true, nil +} + +func (r *DataProtectionApplicationReconciler) buildVMFileRestoreDeployment(deploymentObject *appsv1.Deployment) error { + vmFileRestoreControllerImage := r.getVMFileRestoreControllerImage() + imagePullPolicy, err := common.GetImagePullPolicy(r.dpa.Spec.ImagePullPolicy, vmFileRestoreControllerImage) + if err != nil { + r.Log.Error(err, "imagePullPolicy regex failed") + } + ensureVMFileRestoreRequiredLabels(deploymentObject) + err = ensureVMFileRestoreRequiredSpecs(deploymentObject, r.dpa, vmFileRestoreControllerImage, imagePullPolicy, r) + if err != nil { + return err + } + return nil +} + +func ensureVMFileRestoreRequiredLabels(deploymentObject *appsv1.Deployment) { + maps.Copy(vmFileRestoreDeploymentLabels, vmFileRestoreControlPlaneLabel) + deploymentObjectLabels := deploymentObject.GetLabels() + if deploymentObjectLabels == nil { + deploymentObject.SetLabels(vmFileRestoreDeploymentLabels) + } else { + for key, value := range vmFileRestoreDeploymentLabels { + deploymentObjectLabels[key] = value + } + deploymentObject.SetLabels(deploymentObjectLabels) + } +} + +func ensureVMFileRestoreRequiredSpecs( + deploymentObject *appsv1.Deployment, + dpa *oadpv1alpha1.DataProtectionApplication, + image string, + imagePullPolicy corev1.PullPolicy, + r *DataProtectionApplicationReconciler, +) error { + // Build environment variables + envVars := []corev1.EnvVar{ + { + Name: "WATCH_NAMESPACE", + Value: deploymentObject.Namespace, + }, + { + Name: "VMFR_ACCESS_IMAGE", + Value: r.getVMFileRestoreAccessImage(), + }, + { + Name: "VMFR_SSH_IMAGE", + Value: r.getVMFileRestoreSSHImage(), + }, + { + Name: "VMFR_BROWSER_IMAGE", + Value: r.getVMFileRestoreBrowserImage(), + }, + } + + // Add log level if configured + if dpa.Spec.Configuration != nil && dpa.Spec.Configuration.Velero != nil { + envVars = append(envVars, corev1.EnvVar{ + Name: common.LogLevelEnvVar, + Value: func() string { + level, err := logrus.ParseLevel(dpa.Spec.Configuration.Velero.LogLevel) + if err != nil { + return "" + } + return strconv.FormatUint(uint64(level), 10) + }(), + }) + } + + // Add log format if configured + if len(dpa.Spec.LogFormat) > 0 { + envVars = append(envVars, corev1.EnvVar{ + Name: common.LogFormatEnvVar, + Value: string(dpa.Spec.LogFormat), + }) + } + + // Track DPA resource version for change detection + if len(vmfrDpaResourceVersion) == 0 || + !reflect.DeepEqual(dpa.Spec.VMFileRestore, previousVMFileRestoreConfiguration) { + vmfrDpaResourceVersion = dpa.GetResourceVersion() + previousVMFileRestoreConfiguration = dpa.Spec.VMFileRestore + } + + podAnnotations := map[string]string{ + vmfrDpaResourceVersionAnnotation: vmfrDpaResourceVersion, + } + + // Get resource requirements + resources := r.getVMFileRestoreResources() + + // Set deployment spec + deploymentObject.Spec.Replicas = ptr.To(int32(1)) + deploymentObject.Spec.Selector = &metav1.LabelSelector{ + MatchLabels: vmFileRestoreControlPlaneLabel, + } + + // Set template labels + templateObjectLabels := deploymentObject.Spec.Template.GetLabels() + if templateObjectLabels == nil { + deploymentObject.Spec.Template.SetLabels(vmFileRestoreControlPlaneLabel) + } else { + templateObjectLabels[vmFileRestoreControlPlaneKey] = vmFileRestoreControlPlaneLabel[vmFileRestoreControlPlaneKey] + deploymentObject.Spec.Template.SetLabels(templateObjectLabels) + } + + // Set template annotations + templateObjectAnnotations := deploymentObject.Spec.Template.GetAnnotations() + if templateObjectAnnotations == nil { + deploymentObject.Spec.Template.SetAnnotations(podAnnotations) + } else { + templateObjectAnnotations[vmfrDpaResourceVersionAnnotation] = podAnnotations[vmfrDpaResourceVersionAnnotation] + deploymentObject.Spec.Template.SetAnnotations(templateObjectAnnotations) + } + + // Set pod security context only if not already set (to avoid reconciliation loops) + if deploymentObject.Spec.Template.Spec.SecurityContext == nil { + deploymentObject.Spec.Template.Spec.SecurityContext = &corev1.PodSecurityContext{ + RunAsNonRoot: ptr.To(true), + SeccompProfile: &corev1.SeccompProfile{ + Type: corev1.SeccompProfileTypeRuntimeDefault, + }, + } + } + + // Build container spec + vmFileRestoreContainerFound := false + containerSpec := corev1.Container{ + Name: "manager", + Image: image, + ImagePullPolicy: imagePullPolicy, + Command: []string{"/manager"}, + Args: []string{ + "--leader-elect", + "--health-probe-bind-address=:8081", + "--metrics-bind-address=:8443", + "--metrics-secure=true", + }, + Env: envVars, + Ports: []corev1.ContainerPort{ + { + Name: "https", + ContainerPort: 8443, + Protocol: corev1.ProtocolTCP, + }, + }, + Resources: resources, + SecurityContext: &corev1.SecurityContext{ + AllowPrivilegeEscalation: ptr.To(false), + Capabilities: &corev1.Capabilities{ + Drop: []corev1.Capability{"ALL"}, + }, + ReadOnlyRootFilesystem: ptr.To(true), + }, + LivenessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: "/healthz", + Port: intstr.FromInt(8081), + Scheme: corev1.URISchemeHTTP, + }, + }, + InitialDelaySeconds: 15, + PeriodSeconds: 20, + }, + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + HTTPGet: &corev1.HTTPGetAction{ + Path: "/readyz", + Port: intstr.FromInt(8081), + Scheme: corev1.URISchemeHTTP, + }, + }, + InitialDelaySeconds: 5, + PeriodSeconds: 10, + }, + TerminationMessagePolicy: corev1.TerminationMessageFallbackToLogsOnError, + } + + if len(deploymentObject.Spec.Template.Spec.Containers) == 0 { + deploymentObject.Spec.Template.Spec.Containers = []corev1.Container{containerSpec} + vmFileRestoreContainerFound = true + } else { + for index, container := range deploymentObject.Spec.Template.Spec.Containers { + if container.Name == "manager" { + // Update only dynamic fields that can change based on DPA configuration + // Static fields (Command, Args, Ports, SecurityContext, Probes) are left as-is + vmFileRestoreContainer := &deploymentObject.Spec.Template.Spec.Containers[index] + vmFileRestoreContainer.Image = image + vmFileRestoreContainer.ImagePullPolicy = imagePullPolicy + vmFileRestoreContainer.Env = envVars + vmFileRestoreContainer.Resources = resources + vmFileRestoreContainer.TerminationMessagePolicy = corev1.TerminationMessageFallbackToLogsOnError + vmFileRestoreContainerFound = true + break + } + } + } + + if !vmFileRestoreContainerFound { + return fmt.Errorf("could not find VM file restore container in Deployment") + } + + deploymentObject.Spec.Template.Spec.RestartPolicy = corev1.RestartPolicyAlways + deploymentObject.Spec.Template.Spec.ServiceAccountName = vmFileRestoreObjectName + return nil +} + +func (r *DataProtectionApplicationReconciler) checkVMFileRestoreEnabled() bool { + if r.dpa.Spec.VMFileRestore != nil && r.dpa.Spec.VMFileRestore.Enable != nil { + return *r.dpa.Spec.VMFileRestore.Enable + } + return false +} + +func (r *DataProtectionApplicationReconciler) getVMFileRestoreControllerImage() string { + dpa := r.dpa + unsupportedOverride := dpa.Spec.UnsupportedOverrides[oadpv1alpha1.VMFileRestoreControllerImageKey] + if unsupportedOverride != "" { + return unsupportedOverride + } + + environmentVariable := os.Getenv("RELATED_IMAGE_VM_FILE_RESTORE_CONTROLLER") + if environmentVariable != "" { + return environmentVariable + } + + return "quay.io/konveyor/oadp-vm-file-restore:latest" +} + +func (r *DataProtectionApplicationReconciler) getVMFileRestoreAccessImage() string { + dpa := r.dpa + unsupportedOverride := dpa.Spec.UnsupportedOverrides[oadpv1alpha1.VMFileRestoreAccessImageKey] + if unsupportedOverride != "" { + return unsupportedOverride + } + + environmentVariable := os.Getenv("RELATED_IMAGE_VM_FILE_RESTORE_ACCESS") + if environmentVariable != "" { + return environmentVariable + } + + return "quay.io/konveyor/oadp-vmfr-access:latest" +} + +func (r *DataProtectionApplicationReconciler) getVMFileRestoreSSHImage() string { + dpa := r.dpa + unsupportedOverride := dpa.Spec.UnsupportedOverrides[oadpv1alpha1.VMFileRestoreSSHImageKey] + if unsupportedOverride != "" { + return unsupportedOverride + } + + environmentVariable := os.Getenv("RELATED_IMAGE_VM_FILE_RESTORE_SSH") + if environmentVariable != "" { + return environmentVariable + } + + return "quay.io/konveyor/oadp-vmfr-access-sshd:latest" +} + +func (r *DataProtectionApplicationReconciler) getVMFileRestoreBrowserImage() string { + dpa := r.dpa + unsupportedOverride := dpa.Spec.UnsupportedOverrides[oadpv1alpha1.VMFileRestoreBrowserImageKey] + if unsupportedOverride != "" { + return unsupportedOverride + } + + environmentVariable := os.Getenv("RELATED_IMAGE_VM_FILE_RESTORE_BROWSER") + if environmentVariable != "" { + return environmentVariable + } + + return "quay.io/konveyor/oadp-vmfr-access-filebrowser:latest" +} + +func (r *DataProtectionApplicationReconciler) getVMFileRestoreResources() corev1.ResourceRequirements { + dpa := r.dpa + + // If custom resources specified in DPA, use them + if dpa.Spec.VMFileRestore != nil && dpa.Spec.VMFileRestore.Resources != nil { + return *dpa.Spec.VMFileRestore.Resources + } + + // Default resource requirements (matching upstream oadp-vm-file-restore) + return corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("10m"), + corev1.ResourceMemory: resource.MustParse("64Mi"), + }, + } +} diff --git a/internal/controller/vmfilerestore_controller_test.go b/internal/controller/vmfilerestore_controller_test.go new file mode 100644 index 0000000000..9a87a04737 --- /dev/null +++ b/internal/controller/vmfilerestore_controller_test.go @@ -0,0 +1,898 @@ +package controller + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/go-logr/logr" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "k8s.io/utils/ptr" + + oadpv1alpha1 "github.com/openshift/oadp-operator/api/v1alpha1" +) + +const ( + defaultVMFileRestoreControllerImage = "quay.io/konveyor/oadp-vm-file-restore:latest" + defaultVMFileRestoreAccessImage = "quay.io/konveyor/oadp-vmfr-access:latest" + defaultVMFileRestoreSSHImage = "quay.io/konveyor/oadp-vmfr-access-sshd:latest" + defaultVMFileRestoreBrowserImage = "quay.io/konveyor/oadp-vmfr-access-filebrowser:latest" +) + +type ReconcileVMFileRestoreControllerScenario struct { + namespace string + dpa string + errMessage string + eventWords []string + vmFileRestoreEnabled bool + deployment *appsv1.Deployment +} + +func createTestVMFileRestoreDeployment(namespace string) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: vmFileRestoreObjectName, + Namespace: namespace, + Labels: map[string]string{ + "test": "test", + "app.kubernetes.io/name": "wrong", + vmFileRestoreControlPlaneKey: "super-wrong", + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(2)), + Selector: &metav1.LabelSelector{ + MatchLabels: vmFileRestoreControlPlaneLabel, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: vmFileRestoreControlPlaneLabel, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "manager", + Image: "wrong", + }, + }, + ServiceAccountName: "wrong-one", + }, + }, + }, + } +} + +func runReconcileVMFileRestoreControllerTest( + scenario ReconcileVMFileRestoreControllerScenario, + updateTestScenario func(scenario ReconcileVMFileRestoreControllerScenario), + ctx context.Context, + controllerImageEnvValue string, + accessImageEnvValue string, + sshImageEnvValue string, + browserImageEnvValue string, +) { + updateTestScenario(scenario) + + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: scenario.namespace, + }, + } + gomega.Expect(k8sClient.Create(ctx, namespace)).To(gomega.Succeed()) + + dpa := &oadpv1alpha1.DataProtectionApplication{ + ObjectMeta: metav1.ObjectMeta{ + Name: scenario.dpa, + Namespace: scenario.namespace, + }, + Spec: oadpv1alpha1.DataProtectionApplicationSpec{ + Configuration: &oadpv1alpha1.ApplicationConfig{ + Velero: &oadpv1alpha1.VeleroConfig{}, + }, + VMFileRestore: &oadpv1alpha1.VMFileRestore{ + Enable: ptr.To(scenario.vmFileRestoreEnabled), + }, + }, + } + gomega.Expect(k8sClient.Create(ctx, dpa)).To(gomega.Succeed()) + + if scenario.deployment != nil { + gomega.Expect(k8sClient.Create(ctx, scenario.deployment)).To(gomega.Succeed()) + } + + os.Setenv("RELATED_IMAGE_VM_FILE_RESTORE_CONTROLLER", controllerImageEnvValue) + os.Setenv("RELATED_IMAGE_VM_FILE_RESTORE_ACCESS", accessImageEnvValue) + os.Setenv("RELATED_IMAGE_VM_FILE_RESTORE_SSH", sshImageEnvValue) + os.Setenv("RELATED_IMAGE_VM_FILE_RESTORE_BROWSER", browserImageEnvValue) + + event := record.NewFakeRecorder(5) + r := &DataProtectionApplicationReconciler{ + Client: k8sClient, + Scheme: testEnv.Scheme, + Context: ctx, + NamespacedName: types.NamespacedName{ + Name: scenario.dpa, + Namespace: scenario.namespace, + }, + EventRecorder: event, + dpa: dpa, + } + result, err := r.ReconcileVMFileRestoreController(logr.Discard()) + + if len(scenario.errMessage) == 0 { + gomega.Expect(result).To(gomega.BeTrue()) + gomega.Expect(err).To(gomega.Not(gomega.HaveOccurred())) + } else { + gomega.Expect(result).To(gomega.BeFalse()) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.ContainSubstring(scenario.errMessage)) + } + + if scenario.eventWords != nil { + gomega.Expect(len(event.Events)).To(gomega.Equal(1)) + message := <-event.Events + for _, word := range scenario.eventWords { + gomega.Expect(message).To(gomega.ContainSubstring(word)) + } + } else { + gomega.Expect(len(event.Events)).To(gomega.Equal(0)) + } +} + +var _ = ginkgo.Describe("Test ReconcileVMFileRestoreController function", func() { + var ( + ctx = context.Background() + currentTestScenario ReconcileVMFileRestoreControllerScenario + updateTestScenario = func(scenario ReconcileVMFileRestoreControllerScenario) { + currentTestScenario = scenario + } + ) + + ginkgo.AfterEach(func() { + os.Unsetenv("RELATED_IMAGE_VM_FILE_RESTORE_CONTROLLER") + os.Unsetenv("RELATED_IMAGE_VM_FILE_RESTORE_ACCESS") + os.Unsetenv("RELATED_IMAGE_VM_FILE_RESTORE_SSH") + os.Unsetenv("RELATED_IMAGE_VM_FILE_RESTORE_BROWSER") + + deployment := &appsv1.Deployment{} + if k8sClient.Get( + ctx, + types.NamespacedName{ + Name: vmFileRestoreObjectName, + Namespace: currentTestScenario.namespace, + }, + deployment, + ) == nil { + gomega.Expect(k8sClient.Delete(ctx, deployment)).To(gomega.Succeed()) + } + + dpa := &oadpv1alpha1.DataProtectionApplication{ + ObjectMeta: metav1.ObjectMeta{ + Name: currentTestScenario.dpa, + Namespace: currentTestScenario.namespace, + }, + } + gomega.Expect(k8sClient.Delete(ctx, dpa)).To(gomega.Succeed()) + + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: currentTestScenario.namespace, + }, + } + gomega.Expect(k8sClient.Delete(ctx, namespace)).To(gomega.Succeed()) + }) + + ginkgo.DescribeTable("Reconcile is true", + func(scenario ReconcileVMFileRestoreControllerScenario) { + runReconcileVMFileRestoreControllerTest( + scenario, + updateTestScenario, + ctx, + defaultVMFileRestoreControllerImage, + defaultVMFileRestoreAccessImage, + defaultVMFileRestoreSSHImage, + defaultVMFileRestoreBrowserImage, + ) + }, + ginkgo.Entry("Should create VM file restore deployment", ReconcileVMFileRestoreControllerScenario{ + namespace: "vmfr-test-1", + dpa: "vmfr-test-1-dpa", + eventWords: []string{"Normal", "VMFileRestoreDeploymentReconciled", "created"}, + vmFileRestoreEnabled: true, + }), + ginkgo.Entry("Should update VM file restore deployment", ReconcileVMFileRestoreControllerScenario{ + namespace: "vmfr-test-2", + dpa: "vmfr-test-2-dpa", + eventWords: []string{"Normal", "VMFileRestoreDeploymentReconciled", "updated"}, + vmFileRestoreEnabled: true, + deployment: createTestVMFileRestoreDeployment("vmfr-test-2"), + }), + ginkgo.Entry("Should delete VM file restore deployment", ReconcileVMFileRestoreControllerScenario{ + namespace: "vmfr-test-3", + dpa: "vmfr-test-3-dpa", + eventWords: []string{"Normal", "VMFileRestoreDeploymentDeleteSucceed", "deleted"}, + vmFileRestoreEnabled: false, + deployment: createTestVMFileRestoreDeployment("vmfr-test-3"), + }), + ginkgo.Entry("Should do nothing when disabled", ReconcileVMFileRestoreControllerScenario{ + namespace: "vmfr-test-4", + dpa: "vmfr-test-4-dpa", + vmFileRestoreEnabled: false, + }), + ) + + ginkgo.DescribeTable("Reconcile is false", + func(scenario ReconcileVMFileRestoreControllerScenario) { + runReconcileVMFileRestoreControllerTest( + scenario, + updateTestScenario, + ctx, + defaultVMFileRestoreControllerImage, + defaultVMFileRestoreAccessImage, + defaultVMFileRestoreSSHImage, + defaultVMFileRestoreBrowserImage, + ) + }, + ginkgo.Entry("Should error because manager container was not found in Deployment", ReconcileVMFileRestoreControllerScenario{ + namespace: "vmfr-test-error-1", + dpa: "vmfr-test-error-1-dpa", + errMessage: "could not find VM file restore container in Deployment", + vmFileRestoreEnabled: true, + deployment: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: vmFileRestoreObjectName, + Namespace: "vmfr-test-error-1", + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: vmFileRestoreControlPlaneLabel, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: vmFileRestoreControlPlaneLabel, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: "wrong", + Image: defaultVMFileRestoreControllerImage, + }}, + }, + }, + }, + }, + }), + ) +}) + +func TestGetVMFileRestoreControllerImage(t *testing.T) { + tests := []struct { + name string + envValue string + overrideValue string + expectedResult string + }{ + { + name: "Should return override value when set", + envValue: "env-image:v1", + overrideValue: "override-image:v1", + expectedResult: "override-image:v1", + }, + { + name: "Should return env value when override not set", + envValue: "env-image:v1", + overrideValue: "", + expectedResult: "env-image:v1", + }, + { + name: "Should return default when neither set", + envValue: "", + overrideValue: "", + expectedResult: defaultVMFileRestoreControllerImage, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue != "" { + os.Setenv("RELATED_IMAGE_VM_FILE_RESTORE_CONTROLLER", tt.envValue) + defer os.Unsetenv("RELATED_IMAGE_VM_FILE_RESTORE_CONTROLLER") + } + + dpa := &oadpv1alpha1.DataProtectionApplication{ + Spec: oadpv1alpha1.DataProtectionApplicationSpec{}, + } + + if tt.overrideValue != "" { + dpa.Spec.UnsupportedOverrides = map[oadpv1alpha1.UnsupportedImageKey]string{ + oadpv1alpha1.VMFileRestoreControllerImageKey: tt.overrideValue, + } + } + + r := &DataProtectionApplicationReconciler{dpa: dpa} + result := r.getVMFileRestoreControllerImage() + + if result != tt.expectedResult { + t.Errorf("expected %s, got %s", tt.expectedResult, result) + } + }) + } +} + +func TestGetVMFileRestoreAccessImage(t *testing.T) { + tests := []struct { + name string + envValue string + overrideValue string + expectedResult string + }{ + { + name: "Should return override value when set", + envValue: "env-access-image:v1", + overrideValue: "override-access-image:v1", + expectedResult: "override-access-image:v1", + }, + { + name: "Should return env value when override not set", + envValue: "env-access-image:v1", + overrideValue: "", + expectedResult: "env-access-image:v1", + }, + { + name: "Should return default when neither set", + envValue: "", + overrideValue: "", + expectedResult: defaultVMFileRestoreAccessImage, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue != "" { + os.Setenv("RELATED_IMAGE_VM_FILE_RESTORE_ACCESS", tt.envValue) + defer os.Unsetenv("RELATED_IMAGE_VM_FILE_RESTORE_ACCESS") + } + + dpa := &oadpv1alpha1.DataProtectionApplication{ + Spec: oadpv1alpha1.DataProtectionApplicationSpec{}, + } + + if tt.overrideValue != "" { + dpa.Spec.UnsupportedOverrides = map[oadpv1alpha1.UnsupportedImageKey]string{ + oadpv1alpha1.VMFileRestoreAccessImageKey: tt.overrideValue, + } + } + + r := &DataProtectionApplicationReconciler{dpa: dpa} + result := r.getVMFileRestoreAccessImage() + + if result != tt.expectedResult { + t.Errorf("expected %s, got %s", tt.expectedResult, result) + } + }) + } +} + +func TestGetVMFileRestoreResources(t *testing.T) { + tests := []struct { + name string + customResources *corev1.ResourceRequirements + expectedCPULimit string + expectedMemLimit string + expectedCPURequest string + expectedMemRequest string + }{ + { + name: "Should return default resources when not specified", + customResources: nil, + expectedCPULimit: "500m", + expectedMemLimit: "128Mi", + expectedCPURequest: "10m", + expectedMemRequest: "64Mi", + }, + { + name: "Should return custom resources when specified", + customResources: &corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + corev1.ResourceMemory: resource.MustParse("256Mi"), + }, + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + }, + expectedCPULimit: "1", + expectedMemLimit: "256Mi", + expectedCPURequest: "100m", + expectedMemRequest: "128Mi", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dpa := &oadpv1alpha1.DataProtectionApplication{ + Spec: oadpv1alpha1.DataProtectionApplicationSpec{ + VMFileRestore: &oadpv1alpha1.VMFileRestore{}, + }, + } + + if tt.customResources != nil { + dpa.Spec.VMFileRestore.Resources = tt.customResources + } + + r := &DataProtectionApplicationReconciler{dpa: dpa} + result := r.getVMFileRestoreResources() + + if result.Limits.Cpu().String() != tt.expectedCPULimit { + t.Errorf("CPU limit: expected %s, got %s", tt.expectedCPULimit, result.Limits.Cpu().String()) + } + if result.Limits.Memory().String() != tt.expectedMemLimit { + t.Errorf("Memory limit: expected %s, got %s", tt.expectedMemLimit, result.Limits.Memory().String()) + } + if result.Requests.Cpu().String() != tt.expectedCPURequest { + t.Errorf("CPU request: expected %s, got %s", tt.expectedCPURequest, result.Requests.Cpu().String()) + } + if result.Requests.Memory().String() != tt.expectedMemRequest { + t.Errorf("Memory request: expected %s, got %s", tt.expectedMemRequest, result.Requests.Memory().String()) + } + }) + } +} + +func TestGetVMFileRestoreSSHImage(t *testing.T) { + tests := []struct { + name string + envValue string + overrideValue string + expectedResult string + }{ + { + name: "Should return override value when set", + envValue: "env-ssh-image:v1", + overrideValue: "override-ssh-image:v1", + expectedResult: "override-ssh-image:v1", + }, + { + name: "Should return env value when override not set", + envValue: "env-ssh-image:v1", + overrideValue: "", + expectedResult: "env-ssh-image:v1", + }, + { + name: "Should return default when neither set", + envValue: "", + overrideValue: "", + expectedResult: defaultVMFileRestoreSSHImage, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue != "" { + os.Setenv("RELATED_IMAGE_VM_FILE_RESTORE_SSH", tt.envValue) + defer os.Unsetenv("RELATED_IMAGE_VM_FILE_RESTORE_SSH") + } + + dpa := &oadpv1alpha1.DataProtectionApplication{ + Spec: oadpv1alpha1.DataProtectionApplicationSpec{}, + } + + if tt.overrideValue != "" { + dpa.Spec.UnsupportedOverrides = map[oadpv1alpha1.UnsupportedImageKey]string{ + oadpv1alpha1.VMFileRestoreSSHImageKey: tt.overrideValue, + } + } + + r := &DataProtectionApplicationReconciler{dpa: dpa} + result := r.getVMFileRestoreSSHImage() + + if result != tt.expectedResult { + t.Errorf("expected %s, got %s", tt.expectedResult, result) + } + }) + } +} + +func TestGetVMFileRestoreBrowserImage(t *testing.T) { + tests := []struct { + name string + envValue string + overrideValue string + expectedResult string + }{ + { + name: "Should return override value when set", + envValue: "env-browser-image:v1", + overrideValue: "override-browser-image:v1", + expectedResult: "override-browser-image:v1", + }, + { + name: "Should return env value when override not set", + envValue: "env-browser-image:v1", + overrideValue: "", + expectedResult: "env-browser-image:v1", + }, + { + name: "Should return default when neither set", + envValue: "", + overrideValue: "", + expectedResult: defaultVMFileRestoreBrowserImage, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue != "" { + os.Setenv("RELATED_IMAGE_VM_FILE_RESTORE_BROWSER", tt.envValue) + defer os.Unsetenv("RELATED_IMAGE_VM_FILE_RESTORE_BROWSER") + } + + dpa := &oadpv1alpha1.DataProtectionApplication{ + Spec: oadpv1alpha1.DataProtectionApplicationSpec{}, + } + + if tt.overrideValue != "" { + dpa.Spec.UnsupportedOverrides = map[oadpv1alpha1.UnsupportedImageKey]string{ + oadpv1alpha1.VMFileRestoreBrowserImageKey: tt.overrideValue, + } + } + + r := &DataProtectionApplicationReconciler{dpa: dpa} + result := r.getVMFileRestoreBrowserImage() + + if result != tt.expectedResult { + t.Errorf("expected %s, got %s", tt.expectedResult, result) + } + }) + } +} + +func TestCheckVMFileRestoreEnabled(t *testing.T) { + tests := []struct { + name string + vmFileRestore *oadpv1alpha1.VMFileRestore + expectedResult bool + }{ + { + name: "Should return false when VMFileRestore is nil", + vmFileRestore: nil, + expectedResult: false, + }, + { + name: "Should return false when Enable is nil", + vmFileRestore: &oadpv1alpha1.VMFileRestore{ + Enable: nil, + }, + expectedResult: false, + }, + { + name: "Should return false when Enable is false", + vmFileRestore: &oadpv1alpha1.VMFileRestore{ + Enable: ptr.To(false), + }, + expectedResult: false, + }, + { + name: "Should return true when Enable is true", + vmFileRestore: &oadpv1alpha1.VMFileRestore{ + Enable: ptr.To(true), + }, + expectedResult: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dpa := &oadpv1alpha1.DataProtectionApplication{ + Spec: oadpv1alpha1.DataProtectionApplicationSpec{ + VMFileRestore: tt.vmFileRestore, + }, + } + + r := &DataProtectionApplicationReconciler{dpa: dpa} + result := r.checkVMFileRestoreEnabled() + + if result != tt.expectedResult { + t.Errorf("expected %v, got %v", tt.expectedResult, result) + } + }) + } +} + +func TestEnsureVMFileRestoreRequiredLabels(t *testing.T) { + tests := []struct { + name string + existingLabels map[string]string + expectedLabels map[string]string + }{ + { + name: "Should set labels when deployment has no labels", + existingLabels: nil, + expectedLabels: map[string]string{ + "control-plane": "oadp-vm-file-restore-controller", + "app.kubernetes.io/component": "manager", + "app.kubernetes.io/created-by": "oadp-operator", + "app.kubernetes.io/instance": "oadp-vm-file-restore-controller-manager", + "app.kubernetes.io/managed-by": "kustomize", + "app.kubernetes.io/name": "deployment", + "app.kubernetes.io/part-of": "oadp-operator", + }, + }, + { + name: "Should preserve existing labels and add required labels", + existingLabels: map[string]string{ + "custom-label": "custom-value", + }, + expectedLabels: map[string]string{ + "custom-label": "custom-value", + "control-plane": "oadp-vm-file-restore-controller", + "app.kubernetes.io/component": "manager", + "app.kubernetes.io/created-by": "oadp-operator", + "app.kubernetes.io/instance": "oadp-vm-file-restore-controller-manager", + "app.kubernetes.io/managed-by": "kustomize", + "app.kubernetes.io/name": "deployment", + "app.kubernetes.io/part-of": "oadp-operator", + }, + }, + { + name: "Should overwrite existing required labels with correct values", + existingLabels: map[string]string{ + "control-plane": "wrong-value", + "app.kubernetes.io/component": "wrong-component", + }, + expectedLabels: map[string]string{ + "control-plane": "oadp-vm-file-restore-controller", + "app.kubernetes.io/component": "manager", + "app.kubernetes.io/created-by": "oadp-operator", + "app.kubernetes.io/instance": "oadp-vm-file-restore-controller-manager", + "app.kubernetes.io/managed-by": "kustomize", + "app.kubernetes.io/name": "deployment", + "app.kubernetes.io/part-of": "oadp-operator", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Labels: tt.existingLabels, + }, + } + + ensureVMFileRestoreRequiredLabels(deployment) + + labels := deployment.GetLabels() + for key, expectedValue := range tt.expectedLabels { + if actualValue, exists := labels[key]; !exists { + t.Errorf("expected label %s to exist", key) + } else if actualValue != expectedValue { + t.Errorf("label %s: expected %s, got %s", key, expectedValue, actualValue) + } + } + + // Verify no unexpected labels were added (beyond expected and existing custom ones) + for key := range labels { + if _, expected := tt.expectedLabels[key]; !expected { + t.Errorf("unexpected label %s in deployment", key) + } + } + }) + } +} + +func TestBuildVMFileRestoreDeployment(t *testing.T) { + tests := []struct { + name string + dpa *oadpv1alpha1.DataProtectionApplication + envVars map[string]string + expectedImage string + expectedEnvCount int + expectedReplicas int32 + expectedSAName string + expectError bool + errorContains string + }{ + { + name: "Should build deployment with default resources and images", + dpa: &oadpv1alpha1.DataProtectionApplication{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dpa", + Namespace: "test-namespace", + ResourceVersion: "12345", + }, + Spec: oadpv1alpha1.DataProtectionApplicationSpec{ + VMFileRestore: &oadpv1alpha1.VMFileRestore{ + Enable: ptr.To(true), + }, + Configuration: &oadpv1alpha1.ApplicationConfig{ + Velero: &oadpv1alpha1.VeleroConfig{ + LogLevel: "info", + }, + }, + LogFormat: "text", + }, + }, + expectedImage: "quay.io/konveyor/oadp-vm-file-restore:latest", + expectedEnvCount: 6, // WATCH_NAMESPACE, VMFR_ACCESS_IMAGE, VMFR_SSH_IMAGE, VMFR_BROWSER_IMAGE, LOG_LEVEL, LOG_FORMAT + expectedReplicas: 1, + expectedSAName: "oadp-vm-file-restore-controller-manager", + expectError: false, + }, + { + name: "Should use custom resources when specified in DPA", + dpa: &oadpv1alpha1.DataProtectionApplication{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dpa", + Namespace: "test-namespace", + ResourceVersion: "12345", + }, + Spec: oadpv1alpha1.DataProtectionApplicationSpec{ + VMFileRestore: &oadpv1alpha1.VMFileRestore{ + Enable: ptr.To(true), + Resources: &corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + corev1.ResourceMemory: resource.MustParse("256Mi"), + }, + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + }, + }, + Configuration: &oadpv1alpha1.ApplicationConfig{ + Velero: &oadpv1alpha1.VeleroConfig{}, + }, + }, + }, + expectedImage: "quay.io/konveyor/oadp-vm-file-restore:latest", + expectedEnvCount: 5, // WATCH_NAMESPACE, VMFR_ACCESS_IMAGE, VMFR_SSH_IMAGE, VMFR_BROWSER_IMAGE, LOG_LEVEL + expectedReplicas: 1, + expectedSAName: "oadp-vm-file-restore-controller-manager", + expectError: false, + }, + { + name: "Should use override images from unsupportedOverrides", + dpa: &oadpv1alpha1.DataProtectionApplication{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-dpa", + Namespace: "test-namespace", + ResourceVersion: "12345", + }, + Spec: oadpv1alpha1.DataProtectionApplicationSpec{ + VMFileRestore: &oadpv1alpha1.VMFileRestore{ + Enable: ptr.To(true), + }, + UnsupportedOverrides: map[oadpv1alpha1.UnsupportedImageKey]string{ + oadpv1alpha1.VMFileRestoreControllerImageKey: "custom-registry.io/vmfr:v1.0", + }, + Configuration: &oadpv1alpha1.ApplicationConfig{ + Velero: &oadpv1alpha1.VeleroConfig{}, + }, + }, + }, + expectedImage: "custom-registry.io/vmfr:v1.0", + expectedEnvCount: 5, // WATCH_NAMESPACE, VMFR_ACCESS_IMAGE, VMFR_SSH_IMAGE, VMFR_BROWSER_IMAGE, LOG_LEVEL + expectedReplicas: 1, + expectedSAName: "oadp-vm-file-restore-controller-manager", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set environment variables if provided + for key, value := range tt.envVars { + os.Setenv(key, value) + defer os.Unsetenv(key) + } + + r := &DataProtectionApplicationReconciler{ + dpa: tt.dpa, + } + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: vmFileRestoreObjectName, + Namespace: tt.dpa.Namespace, + }, + } + + err := r.buildVMFileRestoreDeployment(deployment) + + if tt.expectError { + if err == nil { + t.Errorf("expected error but got none") + } else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("expected error to contain '%s', got: %v", tt.errorContains, err) + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + // Verify deployment spec + if *deployment.Spec.Replicas != tt.expectedReplicas { + t.Errorf("replicas: expected %d, got %d", tt.expectedReplicas, *deployment.Spec.Replicas) + } + + if deployment.Spec.Template.Spec.ServiceAccountName != tt.expectedSAName { + t.Errorf("serviceAccountName: expected %s, got %s", tt.expectedSAName, deployment.Spec.Template.Spec.ServiceAccountName) + } + + // Verify container exists and has correct image + if len(deployment.Spec.Template.Spec.Containers) == 0 { + t.Fatalf("no containers found in deployment") + } + + container := deployment.Spec.Template.Spec.Containers[0] + if container.Name != "manager" { + t.Errorf("container name: expected 'manager', got %s", container.Name) + } + + if container.Image != tt.expectedImage { + t.Errorf("container image: expected %s, got %s", tt.expectedImage, container.Image) + } + + // Verify environment variables + if len(container.Env) != tt.expectedEnvCount { + t.Errorf("env var count: expected %d, got %d", tt.expectedEnvCount, len(container.Env)) + } + + // Verify security context + if deployment.Spec.Template.Spec.SecurityContext.RunAsNonRoot == nil || !*deployment.Spec.Template.Spec.SecurityContext.RunAsNonRoot { + t.Error("expected runAsNonRoot to be true") + } + + if container.SecurityContext.AllowPrivilegeEscalation == nil || *container.SecurityContext.AllowPrivilegeEscalation { + t.Error("expected allowPrivilegeEscalation to be false") + } + + if container.SecurityContext.ReadOnlyRootFilesystem == nil || !*container.SecurityContext.ReadOnlyRootFilesystem { + t.Error("expected readOnlyRootFilesystem to be true") + } + + // Verify probes exist + if container.LivenessProbe == nil { + t.Error("expected liveness probe to be set") + } + + if container.ReadinessProbe == nil { + t.Error("expected readiness probe to be set") + } + + // Verify labels + labels := deployment.GetLabels() + if labels["control-plane"] != "oadp-vm-file-restore-controller" { + t.Errorf("control-plane label: expected 'oadp-vm-file-restore-controller', got %s", labels["control-plane"]) + } + + // Verify pod annotations include DPA resource version + podAnnotations := deployment.Spec.Template.GetAnnotations() + if _, exists := podAnnotations[vmfrDpaResourceVersionAnnotation]; !exists { + t.Error("expected pod annotation with DPA resource version") + } + + // Verify custom resources if specified + if tt.dpa.Spec.VMFileRestore.Resources != nil { + expectedCPULimit := tt.dpa.Spec.VMFileRestore.Resources.Limits.Cpu().String() + actualCPULimit := container.Resources.Limits.Cpu().String() + if actualCPULimit != expectedCPULimit { + t.Errorf("CPU limit: expected %s, got %s", expectedCPULimit, actualCPULimit) + } + } + }) + } +}